From b013a92c434bcd26e0d16804ab653514c3c514e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Tue, 22 Jan 2019 15:44:54 +0200 Subject: [PATCH] Move project-specific settings to a new "Project Settings" page --- CHANGELOG.md | 6 +- hc/accounts/forms.py | 4 +- hc/accounts/models.py | 37 ++- hc/accounts/tests/test_profile.py | 92 ------- hc/accounts/tests/test_project.py | 104 ++++++++ hc/accounts/views.py | 55 ++-- hc/payments/tests/test_pricing.py | 2 +- hc/payments/views.py | 2 - hc/urls.py | 5 +- static/js/{profile.js => project.js} | 0 templates/accounts/billing.html | 13 - templates/accounts/profile.html | 278 +------------------- templates/accounts/project.html | 292 ++++++++++++++++++++++ templates/base.html | 20 +- templates/emails/login-body-html.html | 12 +- templates/emails/login-body-text.html | 4 +- templates/emails/login-subject.html | 4 +- templates/payments/pricing_not_owner.html | 6 +- 18 files changed, 482 insertions(+), 454 deletions(-) create mode 100644 hc/accounts/tests/test_project.py rename static/js/{profile.js => project.js} (100%) create mode 100644 templates/accounts/project.html diff --git a/CHANGELOG.md b/CHANGELOG.md index d5fc1aab..46e16571 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,11 @@ All notable changes to this project will be documented in this file. ## Unreleased ### Improvements -- Database schema: set Check.user to not null - Database schema: add uniqueness constraint to Check.code -- Database schema: add Ping.kind field -- Database schema: remove Ping.start and Ping.fail fields +- Database schema: add Ping.kind field. Remove "start" and "fail" fields. - Add "Email Settings..." dialog and "Subject Must Contain" setting - Database schema: add the Project model - +- Move project-specific settings to a new "Project Settings" page ## 1.4.0 - 2018-12-25 diff --git a/hc/accounts/forms.py b/hc/accounts/forms.py index e3cec19c..5f84d941 100644 --- a/hc/accounts/forms.py +++ b/hc/accounts/forms.py @@ -95,5 +95,5 @@ class RemoveTeamMemberForm(forms.Form): email = LowercaseEmailField() -class TeamNameForm(forms.Form): - team_name = forms.CharField(max_length=200, required=True) +class ProjectNameForm(forms.Form): + name = forms.CharField(max_length=200, required=True) diff --git a/hc/accounts/models.py b/hc/accounts/models.py index 0590cabc..a099bb8b 100644 --- a/hc/accounts/models.py +++ b/hc/accounts/models.py @@ -79,7 +79,7 @@ class Profile(models.Model): def check_token(self, token, salt): return salt in self.token and check_password(token, self.token) - def send_instant_login_link(self, inviting_profile=None, redirect_url=None): + def send_instant_login_link(self, inviting_project=None, redirect_url=None): token = self.prepare_token("login") path = reverse("hc-check-token", args=[self.user.username, token]) if redirect_url: @@ -88,7 +88,7 @@ class Profile(models.Model): ctx = { "button_text": "Sign In", "button_url": settings.SITE_ROOT + path, - "inviting_profile": inviting_profile + "inviting_project": inviting_project } emails.login(self.user.email, ctx) @@ -166,20 +166,6 @@ class Profile(models.Model): emails.report(self.user.email, ctx, headers) return True - def can_invite(self): - return self.member_count() < self.team_limit - - def invite(self, user): - project = self.get_own_project() - Member.objects.create(user=user, project=project) - - # Switch the invited user over to the new team so they - # notice the new team on next visit: - user.profile.current_project = project - user.profile.save() - - user.profile.send_instant_login_link(self) - def sms_sent_this_month(self): # IF last_sms_date was never set, we have not sent any messages yet. if not self.last_sms_date: @@ -210,12 +196,6 @@ class Profile(models.Model): return project - def member_count(self): - return Member.objects.filter(project__owner__profile=self).count() - - def members(self): - return Member.objects.filter(project__owner__profile=self).all() - class Project(models.Model): code = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) @@ -242,6 +222,19 @@ class Project(models.Model): self.api_key_readonly = urlsafe_b64encode(os.urandom(24)).decode() self.save() + def can_invite(self): + return self.member_set.count() < self.owner_profile.team_limit + + def invite(self, user): + Member.objects.create(user=user, project=self) + + # Switch the invited user over to the new team so they + # notice the new team on next visit: + user.profile.current_project = self + user.profile.save() + + user.profile.send_instant_login_link(self) + def set_next_nag_date(self): """ Set next_nag_date on profiles of all members of this project. """ diff --git a/hc/accounts/tests/test_profile.py b/hc/accounts/tests/test_profile.py index 90ac3864..fecc9f39 100644 --- a/hc/accounts/tests/test_profile.py +++ b/hc/accounts/tests/test_profile.py @@ -27,45 +27,6 @@ class ProfileTestCase(BaseTestCase): expected_subject = "Set password on %s" % settings.SITE_NAME self.assertEqual(mail.outbox[0].subject, expected_subject) - def test_it_shows_api_keys(self): - self.project.api_key_readonly = "R" * 32 - self.project.save() - - self.client.login(username="alice@example.org", password="password") - - form = {"show_api_keys": "1"} - r = self.client.post("/accounts/profile/", form) - self.assertEqual(r.status_code, 200) - - self.assertContains(r, "X" * 32) - self.assertContains(r, "R" * 32) - - def test_it_creates_api_key(self): - self.client.login(username="alice@example.org", password="password") - - form = {"create_api_keys": "1"} - r = self.client.post("/accounts/profile/", form) - self.assertEqual(r.status_code, 200) - - self.project.refresh_from_db() - api_key = self.project.api_key - self.assertTrue(len(api_key) > 10) - self.assertFalse("b'" in api_key) - - def test_it_revokes_api_key(self): - self.project.api_key_readonly = "R" * 32 - self.project.save() - - self.client.login(username="alice@example.org", password="password") - - form = {"revoke_api_keys": "1"} - r = self.client.post("/accounts/profile/", form) - assert r.status_code == 200 - - self.project.refresh_from_db() - self.assertEqual(self.project.api_key, "") - self.assertEqual(self.project.api_key_readonly, "") - def test_it_sends_report(self): check = Check(project=self.project, name="Test Check") check.last_ping = now() @@ -132,59 +93,6 @@ class ProfileTestCase(BaseTestCase): self.assertEqual(len(mail.outbox), 0) - def test_it_adds_team_member(self): - self.client.login(username="alice@example.org", password="password") - - form = {"invite_team_member": "1", "email": "frank@example.org"} - r = self.client.post("/accounts/profile/", form) - self.assertEqual(r.status_code, 200) - - members = self.project.member_set.all() - self.assertEqual(members.count(), 2) - - member = Member.objects.get(project=self.project, - user__email="frank@example.org") - - profile = member.user.profile - self.assertEqual(profile.current_project, self.project) - - # And an email should have been sent - subj = ('You have been invited to join' - ' alice@example.org on %s' % settings.SITE_NAME) - self.assertEqual(mail.outbox[0].subject, subj) - - def test_it_checks_team_size(self): - self.profile.team_limit = 0 - self.profile.save() - - self.client.login(username="alice@example.org", password="password") - - form = {"invite_team_member": "1", "email": "frank@example.org"} - r = self.client.post("/accounts/profile/", form) - self.assertEqual(r.status_code, 403) - - def test_it_removes_team_member(self): - self.client.login(username="alice@example.org", password="password") - - form = {"remove_team_member": "1", "email": "bob@example.org"} - r = self.client.post("/accounts/profile/", form) - self.assertEqual(r.status_code, 200) - - self.assertEqual(Member.objects.count(), 0) - - self.bobs_profile.refresh_from_db() - self.assertEqual(self.bobs_profile.current_project, None) - - def test_it_sets_team_name(self): - self.client.login(username="alice@example.org", password="password") - - form = {"set_team_name": "1", "team_name": "Alpha Team"} - r = self.client.post("/accounts/profile/", form) - self.assertEqual(r.status_code, 200) - - self.project.refresh_from_db() - self.assertEqual(self.project.name, "Alpha Team") - def test_it_switches_to_own_team(self): self.client.login(username="bob@example.org", password="password") diff --git a/hc/accounts/tests/test_project.py b/hc/accounts/tests/test_project.py new file mode 100644 index 00000000..97f16c5c --- /dev/null +++ b/hc/accounts/tests/test_project.py @@ -0,0 +1,104 @@ +from django.core import mail + +from django.conf import settings +from hc.test import BaseTestCase +from hc.accounts.models import Member + + +class ProfileTestCase(BaseTestCase): + def setUp(self): + super(ProfileTestCase, self).setUp() + + self.url = "/projects/%s/settings/" % self.project.code + + def test_it_shows_api_keys(self): + self.project.api_key_readonly = "R" * 32 + self.project.save() + + self.client.login(username="alice@example.org", password="password") + + form = {"show_api_keys": "1"} + r = self.client.post(self.url, form) + self.assertEqual(r.status_code, 200) + + self.assertContains(r, "X" * 32) + self.assertContains(r, "R" * 32) + + def test_it_creates_api_key(self): + self.client.login(username="alice@example.org", password="password") + + form = {"create_api_keys": "1"} + r = self.client.post(self.url, form) + self.assertEqual(r.status_code, 200) + + self.project.refresh_from_db() + api_key = self.project.api_key + self.assertTrue(len(api_key) > 10) + self.assertFalse("b'" in api_key) + + def test_it_revokes_api_key(self): + self.project.api_key_readonly = "R" * 32 + self.project.save() + + self.client.login(username="alice@example.org", password="password") + + form = {"revoke_api_keys": "1"} + r = self.client.post(self.url, form) + assert r.status_code == 200 + + self.project.refresh_from_db() + self.assertEqual(self.project.api_key, "") + self.assertEqual(self.project.api_key_readonly, "") + + def test_it_adds_team_member(self): + self.client.login(username="alice@example.org", password="password") + + form = {"invite_team_member": "1", "email": "frank@example.org"} + r = self.client.post(self.url, form) + self.assertEqual(r.status_code, 200) + + members = self.project.member_set.all() + self.assertEqual(members.count(), 2) + + member = Member.objects.get(project=self.project, + user__email="frank@example.org") + + profile = member.user.profile + self.assertEqual(profile.current_project, self.project) + + # And an email should have been sent + subj = ('You have been invited to join' + ' alice@example.org on %s' % settings.SITE_NAME) + self.assertEqual(mail.outbox[0].subject, subj) + + def test_it_checks_team_size(self): + self.profile.team_limit = 0 + self.profile.save() + + self.client.login(username="alice@example.org", password="password") + + form = {"invite_team_member": "1", "email": "frank@example.org"} + r = self.client.post(self.url, form) + self.assertEqual(r.status_code, 403) + + def test_it_removes_team_member(self): + self.client.login(username="alice@example.org", password="password") + + form = {"remove_team_member": "1", "email": "bob@example.org"} + r = self.client.post(self.url, form) + self.assertEqual(r.status_code, 200) + + self.assertEqual(Member.objects.count(), 0) + + self.bobs_profile.refresh_from_db() + self.assertEqual(self.bobs_profile.current_project, None) + + def test_it_sets_project_name(self): + self.client.login(username="alice@example.org", password="password") + + form = {"set_project_name": "1", "name": "Alpha Team"} + r = self.client.post(self.url, form) + self.assertEqual(r.status_code, 200) + + self.project.refresh_from_db() + self.assertEqual(self.project.name, "Alpha Team") diff --git a/hc/accounts/views.py b/hc/accounts/views.py index a69bac07..af2a1406 100644 --- a/hc/accounts/views.py +++ b/hc/accounts/views.py @@ -19,7 +19,7 @@ from django.views.decorators.http import require_POST from hc.accounts.forms import (ChangeEmailForm, EmailPasswordForm, InviteTeamMemberForm, RemoveTeamMemberForm, ReportSettingsForm, SetPasswordForm, - TeamNameForm, AvailableEmailForm, + ProjectNameForm, AvailableEmailForm, ExistingEmailForm) from hc.accounts.models import Profile, Project, Member from hc.api.models import Channel, Check @@ -194,10 +194,7 @@ def profile(request): ctx = { "page": "profile", "profile": profile, - "project": project, - "show_api_keys": False, - "api_status": "default", - "team_status": "default" + "project": project } if request.method == "POST": @@ -207,7 +204,27 @@ def profile(request): elif "set_password" in request.POST: profile.send_set_password_link() return redirect("hc-link-sent") - elif "create_api_keys" in request.POST: + + return render(request, "accounts/profile.html", ctx) + + +@login_required +def project(request, code): + project = Project.objects.get(code=code, owner_id=request.user.id) + profile = project.owner_profile + + ctx = { + "page": "profile", + "project": project, + "profile": profile, + "show_api_keys": False, + "project_name_status": "default", + "api_status": "default", + "team_status": "default" + } + + if request.method == "POST": + if "create_api_keys" in request.POST: project.set_api_keys() project.save() @@ -224,7 +241,7 @@ def profile(request): elif "show_api_keys" in request.POST: ctx["show_api_keys"] = True elif "invite_team_member" in request.POST: - if not profile.can_invite(): + if not project.can_invite(): return HttpResponseForbidden() form = InviteTeamMemberForm(request.POST) @@ -236,7 +253,7 @@ def profile(request): except User.DoesNotExist: user = _make_user(email) - profile.invite(user) + project.invite(user) ctx["team_member_invited"] = email ctx["team_status"] = "success" @@ -249,21 +266,27 @@ def profile(request): farewell_user.profile.current_project = None farewell_user.profile.save() - Member.objects.filter(project=request.project, + Member.objects.filter(project=project, user=farewell_user).delete() ctx["team_member_removed"] = email ctx["team_status"] = "info" - elif "set_team_name" in request.POST: - form = TeamNameForm(request.POST) + elif "set_project_name" in request.POST: + form = ProjectNameForm(request.POST) if form.is_valid(): - request.project.name = form.cleaned_data["team_name"] - request.project.save() + project.name = form.cleaned_data["name"] + project.save() - ctx["team_name_updated"] = True - ctx["team_status"] = "success" + if request.project.id == project.id: + request.project = project - return render(request, "accounts/profile.html", ctx) + ctx["project_name_updated"] = True + 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) @login_required diff --git a/hc/payments/tests/test_pricing.py b/hc/payments/tests/test_pricing.py index 24728afa..d1cd9cc6 100644 --- a/hc/payments/tests/test_pricing.py +++ b/hc/payments/tests/test_pricing.py @@ -34,7 +34,7 @@ class PricingTestCase(BaseTestCase): self.client.login(username="bob@example.org", password="password") r = self.client.get("/pricing/") - self.assertContains(r, "To manage this team") + self.assertContains(r, "To manage billing for this project") def test_it_shows_active_plan(self): self.sub = Subscription(user=self.alice) diff --git a/hc/payments/views.py b/hc/payments/views.py index 88472dc1..9dd6ce28 100644 --- a/hc/payments/views.py +++ b/hc/payments/views.py @@ -60,8 +60,6 @@ def billing(request): "profile": request.profile, "sub": sub, "num_checks": Check.objects.filter(project__owner=request.user).count(), - "team_size": request.profile.member_count() + 1, - "team_max": request.profile.team_limit + 1, "send_invoices_status": send_invoices_status, "set_plan_status": "default", "address_status": "default", diff --git a/hc/urls.py b/hc/urls.py index 1075ebc6..219600e6 100644 --- a/hc/urls.py +++ b/hc/urls.py @@ -1,12 +1,13 @@ from django.contrib import admin from django.urls import include, path -from hc.accounts.views import login as hc_login +from hc.accounts import views as accounts_views urlpatterns = [ - path('admin/login/', hc_login), + path('admin/login/', accounts_views.login), path('admin/', admin.site.urls), path('accounts/', include('hc.accounts.urls')), + path('projects//settings/', accounts_views.project, name="hc-project-settings"), path('', include('hc.api.urls')), path('', include('hc.front.urls')), path('', include('hc.payments.urls')) diff --git a/static/js/profile.js b/static/js/project.js similarity index 100% rename from static/js/profile.js rename to static/js/project.js diff --git a/templates/accounts/billing.html b/templates/accounts/billing.html index fa026f72..f49c293b 100644 --- a/templates/accounts/billing.html +++ b/templates/accounts/billing.html @@ -58,19 +58,6 @@ {{ num_checks }} of {{ profile.check_limit }} - - Team Size - = profile.team_limit %} class="at-limit" {% endif %}> - - {{ team_size }} of - {% if profile.team_limit == 500 %} - unlimited - {% else %} - {{ team_max }} - {% endif %} - - - - - {% else %} -
- - API access is enabled. - {% csrf_token %} - - -
- {% endif %} - {% else %} - - API access is disabled. -
- {% csrf_token %} - -
- {% endif %} - - - {% if api_keys_created %} - - {% endif %} - - {% if api_keys_revoked %} - - {% endif %} - - -
-
-

Team Access

- {% if profile.member_count %} - - - - - - - {% for member in profile.members %} - - - - - - {% endfor %} -
{{ profile.user.email }}Owner
{{ member.user.email }} Member - Remove -
- {% else %} -

- Invite team members to your account. -

-

- Share access to your checks and configured integrations - without having to share a login. -

- {% endif %} - -
- - {% if not profile.can_invite %} -
- Team size limit reached. - To invite more members to your team, please - upgrade your account! -
- {% endif %} - - Set Team Name - - {% if profile.can_invite %} - Invite a Team Member - {% endif %} -
- - {% if team_member_invited %} - - {% endif %} - - {% if team_member_removed %} - - {% endif %} - - {% if team_name_updated %} - - {% endif %} -
-
{% csrf_token %} @@ -210,136 +78,6 @@
- - - - - - - - {% endblock %} - -{% block scripts %} -{% compress js %} - - - -{% endcompress %} -{% endblock %} diff --git a/templates/accounts/project.html b/templates/accounts/project.html new file mode 100644 index 00000000..917011e9 --- /dev/null +++ b/templates/accounts/project.html @@ -0,0 +1,292 @@ +{% extends "base.html" %} +{% load compress static hc_extras %} + +{% block title %}Project Settings - {{ project }}{% endblock %} + + +{% block content %} +
+
+

Project Settings

+ {% for message in messages %} +

{{ message }}

+ {% endfor %} + +
+
+

Project Name

+ {{ project }} + Change Project Name +
+ + {% if project_name_updated %} + + {% endif %} +
+ +
+
+

API Access

+ {% if project.api_key %} + {% if show_api_keys %} +

+ API key:
+ {{ project.api_key }} +

+ {% if project.api_key_readonly %} +

+ API key (read-only):
+ {{ project.api_key_readonly }} +

+ {% endif %} + + + {% else %} + + + API access is enabled. + {% csrf_token %} + + + + {% endif %} + {% else %} + + API access is disabled. +
+ {% csrf_token %} + +
+ {% endif %} +
+ + {% if api_keys_created %} + + {% endif %} + + {% if api_keys_revoked %} + + {% endif %} +
+ +
+
+

Team Access

+ {% if num_members %} + + + + + + + {% for member in project.member_set.all %} + + + + + + {% endfor %} +
{{ project.owner.email }}Owner
{{ member.user.email }} Member + Remove +
+ {% else %} +

+ Invite team members to your project. + Share access to your checks and configured integrations + without having to share login details. +

+ {% endif %} + +
+ + {% if project.can_invite %} + Invite a Team Member + {% else %} +
+ Team size limit reached. + To invite more members, please + upgrade your account! +
+ {% endif %} +
+ + {% if team_member_invited %} + + {% endif %} + + {% if team_member_removed %} + + {% endif %} +
+ +
+
+ + + + + + + + +{% endblock %} + +{% block scripts %} +{% compress js %} + + + +{% endcompress %} +{% endblock %} diff --git a/templates/base.html b/templates/base.html index 4996f564..5fc6ca35 100644 --- a/templates/base.html +++ b/templates/base.html @@ -125,28 +125,20 @@ diff --git a/templates/emails/login-body-html.html b/templates/emails/login-body-html.html index 3109d404..2c71739a 100644 --- a/templates/emails/login-body-html.html +++ b/templates/emails/login-body-html.html @@ -5,9 +5,15 @@ Hello,
-{% if inviting_profile %} - {{ inviting_profile.user.email }} invites you to their - {% site_name %} account. +{% if inviting_project %} + {% if inviting_project.name %} + {{ inviting_project.owner.email }} invites you to their + {% site_name %} + project {{ inviting_project }}. + {% else %} + {{ inviting_project.owner.email }} invites you to their + {% site_name %} account. + {% endif %}

You will be able to manage their diff --git a/templates/emails/login-body-text.html b/templates/emails/login-body-text.html index 9b238720..f17e23e1 100644 --- a/templates/emails/login-body-text.html +++ b/templates/emails/login-body-text.html @@ -1,7 +1,7 @@ {% load hc_extras %} {% block content %}Hello, -{% if inviting_profile %} -{{ inviting_profile.user.email }} invites you to their {% site_name %} account. +{% if inviting_project %} +{{ inviting_project.owner.email }} invites you to their {% site_name %} account. You will be able to manage their existing monitoring checks and set up new ones. If you already have your own account on {% site_name %}, you will diff --git a/templates/emails/login-subject.html b/templates/emails/login-subject.html index 9e1d40d7..f07586ee 100644 --- a/templates/emails/login-subject.html +++ b/templates/emails/login-subject.html @@ -1,6 +1,6 @@ {% load hc_extras %} -{% if inviting_profile %} - You have been invited to join {{ inviting_profile.user.email }} on {% site_name %} +{% if inviting_project %} + You have been invited to join {{ inviting_project }} on {% site_name %} {% else %} Log in to {% site_name %} {% endif %} diff --git a/templates/payments/pricing_not_owner.html b/templates/payments/pricing_not_owner.html index d489f2c3..56f53476 100644 --- a/templates/payments/pricing_not_owner.html +++ b/templates/payments/pricing_not_owner.html @@ -14,16 +14,12 @@ You are currently viewing project {{ request.project }}.

- To manage this team, please log in as {{ request.project.owner.email }}. + To manage billing for this project, please log in as {{ request.project.owner.email }}.


- - Switch to {{ request.profile }} - Log Out