forked from GithubBackups/healthchecks
Team access WIP
This commit is contained in:
parent
e0f38ad18d
commit
1bc0f82d25
@ -19,3 +19,11 @@ class ReportSettingsForm(forms.Form):
|
|||||||
|
|
||||||
class SetPasswordForm(forms.Form):
|
class SetPasswordForm(forms.Form):
|
||||||
password = forms.CharField()
|
password = forms.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class InviteTeamMemberForm(forms.Form):
|
||||||
|
email = LowercaseEmailField()
|
||||||
|
|
||||||
|
|
||||||
|
class RemoveTeamMemberForm(forms.Form):
|
||||||
|
email = LowercaseEmailField()
|
||||||
|
44
hc/accounts/migrations/0005_auto_20160509_0801.py
Normal file
44
hc/accounts/migrations/0005_auto_20160509_0801.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.9 on 2016-05-09 08:01
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('accounts', '0004_profile_api_key'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Member',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='profile',
|
||||||
|
name='team_access_allowed',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='profile',
|
||||||
|
name='team_name',
|
||||||
|
field=models.CharField(blank=True, max_length=200),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='member',
|
||||||
|
name='team',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='accounts.Profile'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='member',
|
||||||
|
name='user',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
]
|
@ -21,7 +21,12 @@ class ProfileManager(models.Manager):
|
|||||||
|
|
||||||
|
|
||||||
class Profile(models.Model):
|
class Profile(models.Model):
|
||||||
|
# Owner:
|
||||||
user = models.OneToOneField(User, blank=True, null=True)
|
user = models.OneToOneField(User, blank=True, null=True)
|
||||||
|
|
||||||
|
team_name = models.CharField(max_length=200, blank=True)
|
||||||
|
team_access_allowed = models.BooleanField(default=False)
|
||||||
|
|
||||||
next_report_date = models.DateTimeField(null=True, blank=True)
|
next_report_date = models.DateTimeField(null=True, blank=True)
|
||||||
reports_allowed = models.BooleanField(default=True)
|
reports_allowed = models.BooleanField(default=True)
|
||||||
ping_log_limit = models.IntegerField(default=100)
|
ping_log_limit = models.IntegerField(default=100)
|
||||||
@ -30,13 +35,19 @@ class Profile(models.Model):
|
|||||||
|
|
||||||
objects = ProfileManager()
|
objects = ProfileManager()
|
||||||
|
|
||||||
def send_instant_login_link(self):
|
def __str__(self):
|
||||||
|
return self.team_name or self.user.email
|
||||||
|
|
||||||
|
def send_instant_login_link(self, inviting_profile=None):
|
||||||
token = str(uuid.uuid4())
|
token = str(uuid.uuid4())
|
||||||
self.token = make_password(token)
|
self.token = make_password(token)
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
path = reverse("hc-check-token", args=[self.user.username, token])
|
path = reverse("hc-check-token", args=[self.user.username, token])
|
||||||
ctx = {"login_link": settings.SITE_ROOT + path}
|
ctx = {
|
||||||
|
"login_link": settings.SITE_ROOT + path,
|
||||||
|
"inviting_profile": inviting_profile
|
||||||
|
}
|
||||||
emails.login(self.user.email, ctx)
|
emails.login(self.user.email, ctx)
|
||||||
|
|
||||||
def send_set_password_link(self):
|
def send_set_password_link(self):
|
||||||
@ -69,3 +80,14 @@ class Profile(models.Model):
|
|||||||
}
|
}
|
||||||
|
|
||||||
emails.report(self.user.email, ctx)
|
emails.report(self.user.email, ctx)
|
||||||
|
|
||||||
|
def invite(self, user):
|
||||||
|
member = Member(team=self, user=user)
|
||||||
|
member.save()
|
||||||
|
|
||||||
|
Profile.objects.for_user(user).send_instant_login_link(self)
|
||||||
|
|
||||||
|
|
||||||
|
class Member(models.Model):
|
||||||
|
team = models.ForeignKey(Profile)
|
||||||
|
user = models.ForeignKey(User)
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
|
from django.contrib.auth.models import User
|
||||||
from django.core import mail
|
from django.core import mail
|
||||||
|
|
||||||
from hc.test import BaseTestCase
|
from hc.test import BaseTestCase
|
||||||
from hc.accounts.models import Profile
|
from hc.accounts.models import Profile, Member
|
||||||
from hc.api.models import Check
|
from hc.api.models import Check
|
||||||
|
|
||||||
|
|
||||||
@ -56,3 +57,35 @@ class LoginTestCase(BaseTestCase):
|
|||||||
|
|
||||||
self.assertEqual(message.subject, 'Monthly Report')
|
self.assertEqual(message.subject, 'Monthly Report')
|
||||||
self.assertIn("Test Check", message.body)
|
self.assertIn("Test Check", message.body)
|
||||||
|
|
||||||
|
def test_it_adds_team_member(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
form = {"invite_team_member": "1", "email": "bob@example.org"}
|
||||||
|
r = self.client.post("/accounts/profile/", form)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
profile = Profile.objects.for_user(self.alice)
|
||||||
|
member = profile.member_set.get()
|
||||||
|
|
||||||
|
self.assertEqual(member.user.email, "bob@example.org")
|
||||||
|
|
||||||
|
# And an email should have been sent
|
||||||
|
subj = ('You have been invited to join'
|
||||||
|
' alice@example.org on healthchecks.io')
|
||||||
|
self.assertEqual(mail.outbox[0].subject, subj)
|
||||||
|
|
||||||
|
def test_it_removes_team_member(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
bob = User(username="bob", email="bob@example.org")
|
||||||
|
bob.save()
|
||||||
|
|
||||||
|
m = Member(team=Profile.objects.for_user(self.alice), user=bob)
|
||||||
|
m.save()
|
||||||
|
|
||||||
|
form = {"remove_team_member": "1", "email": "bob@example.org"}
|
||||||
|
r = self.client.post("/accounts/profile/", form)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
self.assertEqual(Member.objects.count(), 0)
|
||||||
|
@ -10,9 +10,10 @@ from django.contrib.auth.models import User
|
|||||||
from django.core import signing
|
from django.core import signing
|
||||||
from django.http import HttpResponseBadRequest
|
from django.http import HttpResponseBadRequest
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import redirect, render
|
||||||
from hc.accounts.forms import (EmailPasswordForm, ReportSettingsForm,
|
from hc.accounts.forms import (EmailPasswordForm, InviteTeamMemberForm,
|
||||||
|
RemoveTeamMemberForm, ReportSettingsForm,
|
||||||
SetPasswordForm)
|
SetPasswordForm)
|
||||||
from hc.accounts.models import Profile
|
from hc.accounts.models import Profile, Member
|
||||||
from hc.api.models import Channel, Check
|
from hc.api.models import Channel, Check
|
||||||
|
|
||||||
|
|
||||||
@ -141,6 +142,25 @@ def profile(request):
|
|||||||
profile.reports_allowed = form.cleaned_data["reports_allowed"]
|
profile.reports_allowed = form.cleaned_data["reports_allowed"]
|
||||||
profile.save()
|
profile.save()
|
||||||
messages.info(request, "Your settings have been updated!")
|
messages.info(request, "Your settings have been updated!")
|
||||||
|
elif "invite_team_member" in request.POST:
|
||||||
|
form = InviteTeamMemberForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
|
||||||
|
email = form.cleaned_data["email"]
|
||||||
|
try:
|
||||||
|
user = User.objects.get(email=email)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
user = _make_user(email)
|
||||||
|
|
||||||
|
profile.invite(user)
|
||||||
|
messages.info(request, "Invitation to %s sent!" % email)
|
||||||
|
elif "remove_team_member" in request.POST:
|
||||||
|
form = RemoveTeamMemberForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
|
||||||
|
email = form.cleaned_data["email"]
|
||||||
|
Member.objects.filter(team=profile, user__email=email).delete()
|
||||||
|
messages.info(request, "%s removed from team!" % email)
|
||||||
|
|
||||||
ctx = {
|
ctx = {
|
||||||
"profile": profile,
|
"profile": profile,
|
||||||
|
@ -109,9 +109,11 @@ def create_plan(request):
|
|||||||
profile = Profile.objects.for_user(request.user)
|
profile = Profile.objects.for_user(request.user)
|
||||||
if plan_id == "P5":
|
if plan_id == "P5":
|
||||||
profile.ping_log_limit = 1000
|
profile.ping_log_limit = 1000
|
||||||
|
profile.team_access_allowed = True
|
||||||
profile.save()
|
profile.save()
|
||||||
elif plan_id == "P20":
|
elif plan_id == "P20":
|
||||||
profile.ping_log_limit = 10000
|
profile.ping_log_limit = 10000
|
||||||
|
profile.team_access_allowed = True
|
||||||
profile.save()
|
profile.save()
|
||||||
|
|
||||||
request.session["first_charge"] = True
|
request.session["first_charge"] = True
|
||||||
|
13
static/js/profile.js
Normal file
13
static/js/profile.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
$(function() {
|
||||||
|
|
||||||
|
$(".member-remove").click(function() {
|
||||||
|
var $this = $(this);
|
||||||
|
|
||||||
|
$("#rtm-email").text($this.data("email"));
|
||||||
|
$("#remove-team-member-email").val($this.data("email"));
|
||||||
|
$('#remove-team-member-modal').modal("show");
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
@ -96,8 +96,62 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-sm-6">
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-body settings-block">
|
||||||
|
<h2>Team Access</h2>
|
||||||
|
{% if profile.team_access_allowed %}
|
||||||
|
{% if profile.member_set.count %}
|
||||||
|
<table class="table">
|
||||||
|
<tr>
|
||||||
|
<td>{{ profile.user.email }}</td>
|
||||||
|
<td>Owner</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
{% for member in profile.member_set.all %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ member.user.email }} </td>
|
||||||
|
<td>Member</td>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
data-email="{{ member.user.email }}"
|
||||||
|
class="pull-right member-remove">Remove</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p>
|
||||||
|
<strong>Invite team members to your account.</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Share access to your checks and configured integrations
|
||||||
|
without having to share a login.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="btn btn-primary pull-right"
|
||||||
|
data-toggle="modal"
|
||||||
|
data-target="#invite-team-member-modal">Invite a Team Member</a>
|
||||||
|
{% else %}
|
||||||
|
<p>
|
||||||
|
<strong>Invite team members to your account.</strong>
|
||||||
|
Share access to your checks and configured integrations
|
||||||
|
without having to share a login.</p>
|
||||||
|
<p>
|
||||||
|
To enable team access, please upgrade to
|
||||||
|
one of the <a href="{% url 'hc-pricing' %}">paid plans</a>.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div id="revoke-api-key-modal" class="modal">
|
<div id="revoke-api-key-modal" class="modal">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<form id="revoke-api-key-form" method="post">
|
<form id="revoke-api-key-form" method="post">
|
||||||
@ -127,5 +181,78 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="remove-team-member-modal" class="modal">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<form id="remove-team-member-form" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<button type="button" class="close" data-dismiss="modal">×</span></button>
|
||||||
|
<h4 class="remove-check-title">Remove Team Member</h4>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>You are about to remove <span id="rtm-email"></span> from the team.</p>
|
||||||
|
<p>Are you sure?</p>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="email"
|
||||||
|
id="remove-team-member-email" />
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
name="remove_team_member"
|
||||||
|
class="btn btn-danger">Remove Member from Team</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="invite-team-member-modal" class="modal">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<form method="post" class="form-horizontal">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<button type="button" class="close" data-dismiss="modal">×</span></button>
|
||||||
|
<h4 class="remove-check-title">Invite a Team Member</h4>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<ul>
|
||||||
|
<li>Team Members can create and manage Checks and Integrations</li>
|
||||||
|
<li>Only the team owner (you) can view and edit billing settings</li>
|
||||||
|
</ul>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="itm-email" class="col-sm-2 control-label">Email</label>
|
||||||
|
<div class="col-sm-9">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
class="form-control"
|
||||||
|
id="itm-email"
|
||||||
|
name="email"
|
||||||
|
placeholder="friend@example.org">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
name="invite_team_member"
|
||||||
|
class="btn btn-primary">Send Invite</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{% compress js %}
|
||||||
|
<script src="{% static 'js/jquery-2.1.4.min.js' %}"></script>
|
||||||
|
<script src="{% static 'js/bootstrap.min.js' %}"></script>
|
||||||
|
<script src="{% static 'js/profile.js' %}"></script>
|
||||||
|
{% endcompress %}
|
||||||
|
{% endblock %}
|
@ -1 +1,5 @@
|
|||||||
Log in to healthchecks.io
|
{% if inviting_profile %}
|
||||||
|
You have been invited to join {{ inviting_profile }} on healthchecks.io
|
||||||
|
{% else %}
|
||||||
|
Log in to healthchecks.io
|
||||||
|
{% endif %}
|
||||||
|
@ -54,6 +54,7 @@
|
|||||||
<li class="list-group-item"><i class="fa fa-check"></i> Unlimited Checks</li>
|
<li class="list-group-item"><i class="fa fa-check"></i> Unlimited Checks</li>
|
||||||
<li class="list-group-item"><i class="fa fa-check"></i> Unlimited Alerts</li>
|
<li class="list-group-item"><i class="fa fa-check"></i> Unlimited Alerts</li>
|
||||||
<li class="list-group-item">100 log entries / check</li>
|
<li class="list-group-item">100 log entries / check</li>
|
||||||
|
<li class="list-group-item"> </li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="panel-footer">
|
<div class="panel-footer">
|
||||||
{% if request.user.is_authenticated %}
|
{% if request.user.is_authenticated %}
|
||||||
@ -87,6 +88,7 @@
|
|||||||
<li class="list-group-item">Unlimited Checks</li>
|
<li class="list-group-item">Unlimited Checks</li>
|
||||||
<li class="list-group-item">Unlimited Alerts</li>
|
<li class="list-group-item">Unlimited Alerts</li>
|
||||||
<li class="list-group-item">1000 log entries / check</li>
|
<li class="list-group-item">1000 log entries / check</li>
|
||||||
|
<li class="list-group-item">Team Access</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="panel-footer">
|
<div class="panel-footer">
|
||||||
{% if request.user.is_authenticated %}
|
{% if request.user.is_authenticated %}
|
||||||
@ -127,6 +129,7 @@
|
|||||||
<li class="list-group-item">Unlimited Checks</li>
|
<li class="list-group-item">Unlimited Checks</li>
|
||||||
<li class="list-group-item">Unlimited Alerts</li>
|
<li class="list-group-item">Unlimited Alerts</li>
|
||||||
<li class="list-group-item">10'000 log entries / check</li>
|
<li class="list-group-item">10'000 log entries / check</li>
|
||||||
|
<li class="list-group-item">Team Access</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="panel-footer">
|
<div class="panel-footer">
|
||||||
{% if request.user.is_authenticated %}
|
{% if request.user.is_authenticated %}
|
||||||
@ -191,9 +194,8 @@
|
|||||||
|
|
||||||
<h2>If I cancel my paid plan, do I get a refund?</h2>
|
<h2>If I cancel my paid plan, do I get a refund?</h2>
|
||||||
<p>
|
<p>
|
||||||
You can easily cancel your subscription at any time.
|
You can easily cancel your subscription at any time, but
|
||||||
There are no cancellation fees, though no refunds are
|
no refunds are provided for prorated periods.
|
||||||
provided for prorated periods.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user