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/
-
Team Size
- = profile.team_limit %} class="at-limit" {% endif %}>
-
- {{ team_size }} of
- {% if profile.team_limit == 500 %}
- unlimited
- {% else %}
- {{ team_max }}
- {% endif %}
-
-
-
- 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 }}.