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 %}
+ {{ 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 %} + +@@ -182,6 +230,46 @@ {% endif %}