forked from GithubBackups/healthchecks
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.
This commit is contained in:
parent
683dda9c5d
commit
0ff4bd01e0
@ -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
|
||||||
|
@ -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):
|
def team(self):
|
||||||
return self.member_set.count() < self.owner_profile.team_limit
|
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)
|
||||||
|
@ -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="alice@example.org", password="password")
|
||||||
|
form = {"invite_team_member": "1", "email": "charlie@example.org"}
|
||||||
|
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)
|
||||||
|
@ -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())
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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;
|
||||||
}
|
}
|
@ -14,8 +14,14 @@ $(function() {
|
|||||||
$('#itm-email').focus();
|
$('#itm-email').focus();
|
||||||
})
|
})
|
||||||
|
|
||||||
$('#set-team-name-modal').on('shown.bs.modal', function () {
|
$('#set-project-name-modal').on('shown.bs.modal', function () {
|
||||||
$('#team-name').focus();
|
$('#project-name').focus();
|
||||||
})
|
})
|
||||||
|
|
||||||
});
|
$(".add-to-team").click(function() {
|
||||||
|
$("#itm-email").val(this.dataset.email);
|
||||||
|
$("#invite-team-member-modal form").submit();
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
@ -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 %}
|
{% if project.team.exists or invite_suggestions %}
|
||||||
<table class="table">
|
<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">×</button>
|
<button type="button" class="close" data-dismiss="modal">×</button>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user