diff --git a/hc/accounts/forms.py b/hc/accounts/forms.py index c62e3246..3f426650 100644 --- a/hc/accounts/forms.py +++ b/hc/accounts/forms.py @@ -104,3 +104,7 @@ class RemoveTeamMemberForm(forms.Form): class ProjectNameForm(forms.Form): name = forms.CharField(max_length=200, required=True) + + +class TransferForm(forms.Form): + email = LowercaseEmailField() diff --git a/hc/accounts/migrations/0030_member_transfer_request_date.py b/hc/accounts/migrations/0030_member_transfer_request_date.py new file mode 100644 index 00000000..d0e831c5 --- /dev/null +++ b/hc/accounts/migrations/0030_member_transfer_request_date.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.4 on 2020-04-11 13:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0029_remove_profile_current_project'), + ] + + operations = [ + migrations.AddField( + model_name='member', + name='transfer_request_date', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/hc/accounts/models.py b/hc/accounts/models.py index ce3c71a5..afdc13df 100644 --- a/hc/accounts/models.py +++ b/hc/accounts/models.py @@ -216,6 +216,17 @@ class Profile(models.Model): self.save() return True + def num_checks_used(self): + from hc.api.models import Check + + return Check.objects.filter(project__owner_id=self.user_id).count() + + def num_checks_available(self): + return self.check_limit - self.num_checks_used() + + def can_accept(self, project): + return project.num_checks() <= self.num_checks_available() + class Project(models.Model): code = models.UUIDField(default=uuid.uuid4, unique=True) @@ -232,11 +243,11 @@ class Project(models.Model): def owner_profile(self): return Profile.objects.for_user(self.owner) - def num_checks_available(self): - from hc.api.models import Check + def num_checks(self): + return self.check_set.count() - num_used = Check.objects.filter(project__owner=self.owner).count() - return self.owner_profile.check_limit - num_used + def num_checks_available(self): + return self.owner_profile.num_checks_available() def set_api_keys(self): self.api_key = token_urlsafe(nbytes=24) @@ -294,7 +305,14 @@ class Project(models.Model): # It's a problem if any integration has a logged error return True if max(errors) else False + def transfer_request(self): + return self.member_set.filter(transfer_request_date__isnull=False).first() + class Member(models.Model): user = models.ForeignKey(User, models.CASCADE, related_name="memberships") project = models.ForeignKey(Project, models.CASCADE) + transfer_request_date = models.DateTimeField(null=True, blank=True) + + def can_accept(self): + return self.user.profile.can_accept(self.project) diff --git a/hc/accounts/views.py b/hc/accounts/views.py index 2b3ffe89..01565498 100644 --- a/hc/accounts/views.py +++ b/hc/accounts/views.py @@ -30,6 +30,7 @@ from hc.accounts.forms import ( ProjectNameForm, AvailableEmailForm, EmailLoginForm, + TransferForm, ) from hc.accounts.models import Profile, Project, Member from hc.api.models import Channel, Check, TokenBucket @@ -265,16 +266,11 @@ def project(request, code): return HttpResponseNotFound() is_owner = project.owner_id == request.user.id - invite_suggestions = project.invite_suggestions() ctx = { "page": "project", "project": project, "is_owner": is_owner, "show_api_keys": "show_api_keys" in request.GET, - "project_name_status": "default", - "api_status": "default", - "team_status": "default", - "invite_suggestions": invite_suggestions, } if request.method == "POST": @@ -302,6 +298,7 @@ def project(request, code): if form.is_valid(): email = form.cleaned_data["email"] + invite_suggestions = project.invite_suggestions() 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(): @@ -346,6 +343,69 @@ def project(request, code): ctx["project_name_updated"] = True ctx["project_name_status"] = "success" + elif "transfer_project" in request.POST: + if not is_owner: + return HttpResponseForbidden() + + form = TransferForm(request.POST) + if form.is_valid(): + email = form.cleaned_data["email"] + + # Revoke any previous transfer requests + project.member_set.update(transfer_request_date=None) + + # Initiate the new request + q = project.member_set.filter(user__email=email) + q.update(transfer_request_date=now()) + + ctx["transfer_initiated"] = True + ctx["transfer_status"] = "success" + + # FIXME send email + + elif "cancel_transfer" in request.POST: + if not is_owner: + return HttpResponseForbidden() + + project.member_set.update(transfer_request_date=None) + ctx["transfer_cancelled"] = True + ctx["transfer_status"] = "success" + + elif "accept_transfer" in request.POST: + if not project.transfer_request: + return HttpResponseForbidden() + + tr = project.transfer_request() + if not tr or tr.user != request.user: + return HttpResponseForbidden() + + if not tr.can_accept(): + return HttpResponseBadRequest() + + # 1. Remove user's membership + tr.delete() + + # 2. Invite the current owner as a member + Member.objects.create(user=project.owner, project=project) + + # 3. Change project's owner + project.owner = request.user + project.save() + + ctx["is_owner"] = True + messages.success(request, "You are now the owner of this project!") + + elif "reject_transfer" in request.POST: + if not project.transfer_request: + return HttpResponseForbidden() + + tr = project.transfer_request() + if not tr or tr.user != request.user: + return HttpResponseForbidden() + + tr.transfer_request_date = None + tr.save() + return render(request, "accounts/project.html", ctx) diff --git a/hc/api/migrations/0070_auto_20200411_1310.py b/hc/api/migrations/0070_auto_20200411_1310.py new file mode 100644 index 00000000..9b6db8e8 --- /dev/null +++ b/hc/api/migrations/0070_auto_20200411_1310.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.4 on 2020-04-11 13:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0069_auto_20200117_1227'), + ] + + operations = [ + migrations.AlterField( + model_name='channel', + name='kind', + field=models.CharField(choices=[('email', 'Email'), ('webhook', 'Webhook'), ('hipchat', 'HipChat'), ('slack', 'Slack'), ('pd', 'PagerDuty'), ('pagertree', 'PagerTree'), ('pagerteam', 'Pager Team'), ('po', 'Pushover'), ('pushbullet', 'Pushbullet'), ('opsgenie', 'OpsGenie'), ('victorops', 'VictorOps'), ('discord', 'Discord'), ('telegram', 'Telegram'), ('sms', 'SMS'), ('zendesk', 'Zendesk'), ('trello', 'Trello'), ('matrix', 'Matrix'), ('whatsapp', 'WhatsApp'), ('apprise', 'Apprise'), ('mattermost', 'Mattermost'), ('msteams', 'Microsoft Teams'), ('shell', 'Shell Command'), ('zulip', 'Zulip')], max_length=20), + ), + ] diff --git a/hc/payments/views.py b/hc/payments/views.py index 6c17743f..9d9e5224 100644 --- a/hc/payments/views.py +++ b/hc/payments/views.py @@ -3,7 +3,6 @@ from django.contrib.auth.decorators import login_required from django.http import Http404, HttpResponseBadRequest, JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.views.decorators.http import require_POST -from hc.api.models import Check from hc.front.views import _get_project_for_user from hc.payments.forms import InvoiceEmailingForm from hc.payments.models import Subscription @@ -56,7 +55,6 @@ def billing(request): "page": "billing", "profile": request.profile, "sub": sub, - "num_checks": Check.objects.filter(project__owner=request.user).count(), "send_invoices_status": send_invoices_status, "set_plan_status": "default", "address_status": "default", diff --git a/static/css/projects.css b/static/css/projects.css index 72b0b4a5..cf7b8110 100644 --- a/static/css/projects.css +++ b/static/css/projects.css @@ -61,4 +61,12 @@ #team-table th { border-top: 0; +} + +#transfer-request { + border: 5px solid #ffdc3e; +} + +#transfer-request .settings-block { + padding: 20px; } \ No newline at end of file diff --git a/static/js/project.js b/static/js/project.js index 702f39e2..bb4fbe33 100644 --- a/static/js/project.js +++ b/static/js/project.js @@ -24,4 +24,10 @@ $(function() { return false; }); + // Enable the submit button in transfer form when user selects + // the target owner: + $("#new-owner").on("change", function() { + $("#transfer-confirm").prop("disabled", !this.value); + }); + }); diff --git a/templates/accounts/billing.html b/templates/accounts/billing.html index 233c3053..672d79d2 100644 --- a/templates/accounts/billing.html +++ b/templates/accounts/billing.html @@ -56,9 +56,11 @@ {% endif %} Checks Used + {% with num_checks=profile.num_checks_used %} = profile.check_limit %} class="at-limit" {% endif %}> {{ num_checks }} of {{ profile.check_limit }} + {% endwith %} diff --git a/templates/accounts/project.html b/templates/accounts/project.html index 1eaa99df..cd6010e4 100644 --- a/templates/accounts/project.html +++ b/templates/accounts/project.html @@ -5,13 +5,57 @@ {% block content %} +{% with project.transfer_request as transfer_request %}
{% for message in messages %}

{{ message }}

{% endfor %} -
+ {% if transfer_request and transfer_request.user == request.user %} + {% with can_accept=transfer_request.can_accept %} +
+
+

Ownership Transfer Request

+

+ {{ project.owner.email }} would like to transfer + the ownership of this project to you. +

+ + {% if not can_accept %} + {% with num_checks=project.num_checks num_available=request.profile.num_checks_available %} +

+ This project has + {{ num_checks }} check{{ num_checks|pluralize}}, + but your account only has space for + {{ num_available }} additional check{{ num_available|pluralize }}. + To accept the transfer, please + upgrade your account first! +

+ {% endwith%} + {% endif %} + +
+
+ {% csrf_token %} + + +
+
+ +
+
+ {% endwith %} + {% endif %} + +

Project Name

{{ project }} @@ -29,7 +73,7 @@ {% endif %}
-
+

API Access

{% if project.api_key %} @@ -91,7 +135,7 @@ {% endif %}
-
+

Team Access

{% if project.team.exists or invite_suggestions %} @@ -121,14 +165,16 @@ {% endfor %} - {% if is_owner and invite_suggestions %} + {% if is_owner %} + {% with invite_suggestions=project.invite_suggestions %} + {% if invite_suggestions %} Add Users from Other Teams - {% for user in project.invite_suggestions %} + {% for user in invite_suggestions %} {{ user.email }} @@ -141,6 +187,8 @@ {% endfor %} {% endif %} + {% endwith %} + {% endif %} {% else %}

@@ -182,6 +230,46 @@ {% endif %}

+ {% if is_owner %} +
+
+

Transfer Ownership

+ + {% if transfer_request %} +
+ {% csrf_token %} + +
+ + Transfer initiated, awaiting confirmation from + {{ transfer_request.user.email }}. + + {% else %} + Transfer Project… + Transfer this project to a team member. + {% endif %} +
+ + {% if transfer_initiated %} + + {% endif %} + + {% if transfer_cancelled %} + + {% endif %} +
+ {% endif %} + {% if is_owner %}
@@ -359,12 +447,60 @@
+{% if not transfer_request %} + +{% endif %} + +{% endwith %} {% endblock %} {% block scripts %} {% compress js %} + {% endcompress %} {% endblock %} diff --git a/templates/front/my_checks.html b/templates/front/my_checks.html index b4b130f9..ddd7a17c 100644 --- a/templates/front/my_checks.html +++ b/templates/front/my_checks.html @@ -4,6 +4,7 @@ {% block title %}{{ num_down|num_down_title }}{% endblock %} {% block content %} + {% if checks %}
@@ -46,8 +47,12 @@ {% else %}
Check limit reached. - To add more checks, please + To add more checks in this project, please + {% if request.user == project.owner %} upgrade your account! + {% else %} + ask {{ project.owner.email }} to upgrade their account! + {% endif %}
{% endif %}