Browse Source

Improved UI to invite users from account's other projects. Fixes #258.

The team size limit is applied to the number of distinct users across all projects. Fixes #332.
pull/340/head
Pēteris Caune 5 years ago
parent
commit
0ff4bd01e0
No known key found for this signature in database GPG Key ID: E28D7679E9A9EDE2
8 changed files with 130 additions and 21 deletions
  1. +4
    -0
      CHANGELOG.md
  2. +12
    -2
      hc/accounts/models.py
  3. +23
    -1
      hc/accounts/tests/test_project.py
  4. +21
    -1
      hc/accounts/tests/test_project_model.py
  5. +13
    -7
      hc/accounts/views.py
  6. +14
    -0
      static/css/projects.css
  7. +9
    -3
      static/js/project.js
  8. +34
    -7
      templates/accounts/project.html

+ 4
- 0
CHANGELOG.md View File

@ -3,8 +3,12 @@ All notable changes to this project will be documented in this file.
## v1.14.0 - Unreleased ## v1.14.0 - Unreleased
### Improvements
- Improved UI to invite users from account's other projects (#258)
### Bug Fixes ### Bug Fixes
- The "render_docs" command checks if markdown and pygments is installed (#329) - The "render_docs" command checks if markdown and pygments is installed (#329)
- The team size limit is applied to the n. of distinct users across all projects (#332)
## v1.13.0 - 2020-02-13 ## v1.13.0 - 2020-02-13


+ 12
- 2
hc/accounts/models.py View File

@ -245,8 +245,18 @@ class Project(models.Model):
self.api_key_readonly = urlsafe_b64encode(os.urandom(24)).decode() self.api_key_readonly = urlsafe_b64encode(os.urandom(24)).decode()
self.save() self.save()
def can_invite(self):
return self.member_set.count() < self.owner_profile.team_limit
def team(self):
return User.objects.filter(memberships__project=self).order_by("email")
def invite_suggestions(self):
q = User.objects.filter(memberships__project__owner_id=self.owner_id)
q = q.exclude(memberships__project=self)
return q.distinct().order_by("email")
def can_invite_new_users(self):
q = User.objects.filter(memberships__project__owner_id=self.owner_id)
used = q.distinct().count()
return used < self.owner_profile.team_limit
def invite(self, user): def invite(self, user):
Member.objects.create(user=user, project=self) Member.objects.create(user=user, project=self)


+ 23
- 1
hc/accounts/tests/test_project.py View File

@ -3,7 +3,7 @@ from django.core import mail
from django.conf import settings from django.conf import settings
from django.test.utils import override_settings from django.test.utils import override_settings
from hc.test import BaseTestCase from hc.test import BaseTestCase
from hc.accounts.models import Member
from hc.accounts.models import Member, Project
from hc.api.models import TokenBucket from hc.api.models import TokenBucket
@ -88,6 +88,28 @@ class ProjectTestCase(BaseTestCase):
) )
self.assertHTMLEqual(mail.outbox[0].subject, subj) self.assertHTMLEqual(mail.outbox[0].subject, subj)
def test_it_adds_member_from_another_team(self):
# With team limit at zero, we should not be able to invite any new users
self.profile.team_limit = 0
self.profile.save()
# But Charlie will have an existing membership in another Alice's project
# so Alice *should* be able to invite Charlie:
p2 = Project.objects.create(owner=self.alice)
Member.objects.create(user=self.charlie, project=p2)
self.client.login(username="[email protected]", password="password")
form = {"invite_team_member": "1", "email": "[email protected]"}
r = self.client.post(self.url, form)
self.assertEqual(r.status_code, 200)
q = Member.objects.filter(project=self.project, user=self.charlie)
self.assertEqual(q.count(), 1)
# And this should not have affected the rate limit:
q = TokenBucket.objects.filter(value="invite-%d" % self.alice.id)
self.assertFalse(q.exists())
@override_settings(SECRET_KEY="test-secret") @override_settings(SECRET_KEY="test-secret")
def test_it_rate_limits_invites(self): def test_it_rate_limits_invites(self):
obj = TokenBucket(value="invite-%d" % self.alice.id) obj = TokenBucket(value="invite-%d" % self.alice.id)


+ 21
- 1
hc/accounts/tests/test_project_model.py View File

@ -1,5 +1,5 @@
from hc.test import BaseTestCase from hc.test import BaseTestCase
from hc.accounts.models import Project
from hc.accounts.models import Member, Project
from hc.api.models import Check, Channel from hc.api.models import Check, Channel
@ -27,3 +27,23 @@ class ProjectModelTestCase(BaseTestCase):
def test_it_handles_no_channels(self): def test_it_handles_no_channels(self):
# It's an issue if the project has no channels at all: # It's an issue if the project has no channels at all:
self.assertTrue(self.project.have_channel_issues()) self.assertTrue(self.project.have_channel_issues())
def test_it_allows_third_user(self):
# Alice is the owner, and Bob is invited -- there is space for the third user:
self.assertTrue(self.project.can_invite_new_users())
def test_it_allows_same_user_in_multiple_projects(self):
p2 = Project.objects.create(owner=self.alice)
Member.objects.create(user=self.bob, project=p2)
# Bob's membership in two projects counts as one seat,
# one seat should be still free:
self.assertTrue(self.project.can_invite_new_users())
def test_it_checks_team_limit(self):
p2 = Project.objects.create(owner=self.alice)
Member.objects.create(user=self.charlie, project=p2)
# Alice and Bob are in one project, Charlie is in another,
# so no seats left:
self.assertFalse(self.project.can_invite_new_users())

+ 13
- 7
hc/accounts/views.py View File

@ -265,6 +265,7 @@ def project(request, code):
return HttpResponseNotFound() return HttpResponseNotFound()
is_owner = project.owner_id == request.user.id is_owner = project.owner_id == request.user.id
invite_suggestions = project.invite_suggestions()
ctx = { ctx = {
"page": "project", "page": "project",
"project": project, "project": project,
@ -273,6 +274,7 @@ def project(request, code):
"project_name_status": "default", "project_name_status": "default",
"api_status": "default", "api_status": "default",
"team_status": "default", "team_status": "default",
"invite_suggestions": invite_suggestions,
} }
if request.method == "POST": if request.method == "POST":
@ -293,15 +295,22 @@ def project(request, code):
elif "show_api_keys" in request.POST: elif "show_api_keys" in request.POST:
ctx["show_api_keys"] = True ctx["show_api_keys"] = True
elif "invite_team_member" in request.POST: elif "invite_team_member" in request.POST:
if not is_owner or not project.can_invite():
if not is_owner:
return HttpResponseForbidden() return HttpResponseForbidden()
form = InviteTeamMemberForm(request.POST) form = InviteTeamMemberForm(request.POST)
if form.is_valid(): if form.is_valid():
if not TokenBucket.authorize_invite(request.user):
return render(request, "try_later.html")
email = form.cleaned_data["email"] email = form.cleaned_data["email"]
if not invite_suggestions.filter(email=email).exists():
# We're inviting a new user. Are we within team size limit?
if not project.can_invite_new_users():
return HttpResponseForbidden()
# And are we not hitting a rate limit?
if not TokenBucket.authorize_invite(request.user):
return render(request, "try_later.html")
try: try:
user = User.objects.get(email=email) user = User.objects.get(email=email)
except User.DoesNotExist: except User.DoesNotExist:
@ -343,9 +352,6 @@ def project(request, code):
ctx["project_name_updated"] = True ctx["project_name_updated"] = True
ctx["project_name_status"] = "success" ctx["project_name_status"] = "success"
# Count members right before rendering the template, in case
# we just invited or removed someone
ctx["num_members"] = project.member_set.count()
return render(request, "accounts/project.html", ctx) return render(request, "accounts/project.html", ctx)


+ 14
- 0
static/css/projects.css View File

@ -44,4 +44,18 @@
#project-selector #add-project:hover .project { #project-selector #add-project:hover .project {
border-color: #0091EA; border-color: #0091EA;
color: #333; color: #333;
}
.invite-suggestion {
color: #888;
}
#suggestions-row td {
border-top: 0;
font-size: 85%;
padding-top: 20px;
}
#team-table th {
border-top: 0;
} }

+ 9
- 3
static/js/project.js View File

@ -14,8 +14,14 @@ $(function() {
$('#itm-email').focus(); $('#itm-email').focus();
}) })
$('#set-team-name-modal').on('shown.bs.modal', function () {
$('#team-name').focus();
$('#set-project-name-modal').on('shown.bs.modal', function () {
$('#project-name').focus();
}) })
});
$(".add-to-team").click(function() {
$("#itm-email").val(this.dataset.email);
$("#invite-team-member-modal form").submit();
return false;
});
});

+ 34
- 7
templates/accounts/project.html View File

@ -90,27 +90,53 @@
<div class="panel panel-{{ team_status }}"> <div class="panel panel-{{ team_status }}">
<div class="panel-body settings-block"> <div class="panel-body settings-block">
<h2>Team Access</h2> <h2>Team Access</h2>
{% if num_members %}
<table class="table">
{% if project.team.exists or invite_suggestions %}
<table id="team-table" class="table">
<tr>
<th>Email</th>
<th>Role</th>
<th></th>
</tr>
<tr> <tr>
<td>{{ project.owner.email }}</td> <td>{{ project.owner.email }}</td>
<td>Owner</td> <td>Owner</td>
<td></td> <td></td>
</tr> </tr>
{% for member in project.member_set.all %}
{% for user in project.team %}
<tr> <tr>
<td>{{ member.user.email }} </td>
<td>{{ user.email }} </td>
<td>Member</td> <td>Member</td>
<td> <td>
{% if is_owner %} {% if is_owner %}
<a <a
href="#" href="#"
data-email="{{ member.user.email }}"
data-email="{{ user.email }}"
class="pull-right member-remove">Remove</a> class="pull-right member-remove">Remove</a>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
{% if is_owner and invite_suggestions %}
<tr id="suggestions-row">
<td colspan="3">
Add Users from Other Teams
</td>
</tr>
{% for user in project.invite_suggestions %}
<tr class="invite-suggestion">
<td>{{ user.email }} </td>
<td></td>
<td>
<a
href="#"
data-email="{{ user.email }}"
class="pull-right add-to-team">Add to Team</a>
</td>
</tr>
{% endfor %}
{% endif %}
</table> </table>
{% else %} {% else %}
<p> <p>
@ -123,7 +149,7 @@
<br /> <br />
{% if is_owner %} {% if is_owner %}
{% if project.can_invite%}
{% if project.can_invite_new_users %}
<a <a
href="#" href="#"
class="btn btn-primary pull-right" class="btn btn-primary pull-right"
@ -132,7 +158,7 @@
{% else %} {% else %}
<div class="alert alert-info"> <div class="alert alert-info">
<strong>Team size limit reached.</strong> <strong>Team size limit reached.</strong>
To invite more members, please
To invite new members by email, please
<a href="{% url 'hc-pricing' %}">upgrade your account!</a> <a href="{% url 'hc-pricing' %}">upgrade your account!</a>
</div> </div>
{% endif %} {% endif %}
@ -234,6 +260,7 @@
<div class="modal-dialog"> <div class="modal-dialog">
<form method="post" class="form-horizontal"> <form method="post" class="form-horizontal">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="invite_team_member" value="1" />
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<button type="button" class="close" data-dismiss="modal">&times;</button> <button type="button" class="close" data-dismiss="modal">&times;</button>


Loading…
Cancel
Save