Browse Source

New feature: Project Settings > Transfer Ownership (WIP, missing tests)

pull/359/head
Pēteris Caune 5 years ago
parent
commit
f42b2b144a
No known key found for this signature in database GPG Key ID: E28D7679E9A9EDE2
11 changed files with 290 additions and 17 deletions
  1. +4
    -0
      hc/accounts/forms.py
  2. +18
    -0
      hc/accounts/migrations/0030_member_transfer_request_date.py
  3. +22
    -4
      hc/accounts/models.py
  4. +65
    -5
      hc/accounts/views.py
  5. +18
    -0
      hc/api/migrations/0070_auto_20200411_1310.py
  6. +0
    -2
      hc/payments/views.py
  7. +8
    -0
      static/css/projects.css
  8. +6
    -0
      static/js/project.js
  9. +2
    -0
      templates/accounts/billing.html
  10. +141
    -5
      templates/accounts/project.html
  11. +6
    -1
      templates/front/my_checks.html

+ 4
- 0
hc/accounts/forms.py View File

@ -104,3 +104,7 @@ class RemoveTeamMemberForm(forms.Form):
class ProjectNameForm(forms.Form): class ProjectNameForm(forms.Form):
name = forms.CharField(max_length=200, required=True) name = forms.CharField(max_length=200, required=True)
class TransferForm(forms.Form):
email = LowercaseEmailField()

+ 18
- 0
hc/accounts/migrations/0030_member_transfer_request_date.py View File

@ -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),
),
]

+ 22
- 4
hc/accounts/models.py View File

@ -216,6 +216,17 @@ class Profile(models.Model):
self.save() self.save()
return True 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): class Project(models.Model):
code = models.UUIDField(default=uuid.uuid4, unique=True) code = models.UUIDField(default=uuid.uuid4, unique=True)
@ -232,11 +243,11 @@ class Project(models.Model):
def owner_profile(self): def owner_profile(self):
return Profile.objects.for_user(self.owner) 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): def set_api_keys(self):
self.api_key = token_urlsafe(nbytes=24) 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 # It's a problem if any integration has a logged error
return True if max(errors) else False 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): class Member(models.Model):
user = models.ForeignKey(User, models.CASCADE, related_name="memberships") user = models.ForeignKey(User, models.CASCADE, related_name="memberships")
project = models.ForeignKey(Project, models.CASCADE) 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)

+ 65
- 5
hc/accounts/views.py View File

@ -30,6 +30,7 @@ from hc.accounts.forms import (
ProjectNameForm, ProjectNameForm,
AvailableEmailForm, AvailableEmailForm,
EmailLoginForm, EmailLoginForm,
TransferForm,
) )
from hc.accounts.models import Profile, Project, Member from hc.accounts.models import Profile, Project, Member
from hc.api.models import Channel, Check, TokenBucket from hc.api.models import Channel, Check, TokenBucket
@ -265,16 +266,11 @@ def project(request, code):
return HttpResponseNotFound() return HttpResponseNotFound()
is_owner = project.owner_id == request.user.id is_owner = project.owner_id == request.user.id
invite_suggestions = project.invite_suggestions()
ctx = { ctx = {
"page": "project", "page": "project",
"project": project, "project": project,
"is_owner": is_owner, "is_owner": is_owner,
"show_api_keys": "show_api_keys" in request.GET, "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": if request.method == "POST":
@ -302,6 +298,7 @@ def project(request, code):
if form.is_valid(): if form.is_valid():
email = form.cleaned_data["email"] email = form.cleaned_data["email"]
invite_suggestions = project.invite_suggestions()
if not invite_suggestions.filter(email=email).exists(): if not invite_suggestions.filter(email=email).exists():
# We're inviting a new user. Are we within team size limit? # We're inviting a new user. Are we within team size limit?
if not project.can_invite_new_users(): if not project.can_invite_new_users():
@ -346,6 +343,69 @@ def project(request, code):
ctx["project_name_updated"] = True ctx["project_name_updated"] = True
ctx["project_name_status"] = "success" 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) return render(request, "accounts/project.html", ctx)


+ 18
- 0
hc/api/migrations/0070_auto_20200411_1310.py View File

@ -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),
),
]

+ 0
- 2
hc/payments/views.py View File

@ -3,7 +3,6 @@ from django.contrib.auth.decorators import login_required
from django.http import Http404, HttpResponseBadRequest, JsonResponse from django.http import Http404, HttpResponseBadRequest, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_POST 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.front.views import _get_project_for_user
from hc.payments.forms import InvoiceEmailingForm from hc.payments.forms import InvoiceEmailingForm
from hc.payments.models import Subscription from hc.payments.models import Subscription
@ -56,7 +55,6 @@ def billing(request):
"page": "billing", "page": "billing",
"profile": request.profile, "profile": request.profile,
"sub": sub, "sub": sub,
"num_checks": Check.objects.filter(project__owner=request.user).count(),
"send_invoices_status": send_invoices_status, "send_invoices_status": send_invoices_status,
"set_plan_status": "default", "set_plan_status": "default",
"address_status": "default", "address_status": "default",


+ 8
- 0
static/css/projects.css View File

@ -61,4 +61,12 @@
#team-table th { #team-table th {
border-top: 0; border-top: 0;
}
#transfer-request {
border: 5px solid #ffdc3e;
}
#transfer-request .settings-block {
padding: 20px;
} }

+ 6
- 0
static/js/project.js View File

@ -24,4 +24,10 @@ $(function() {
return false; 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);
});
}); });

+ 2
- 0
templates/accounts/billing.html View File

@ -56,9 +56,11 @@
{% endif %} {% endif %}
<tr> <tr>
<td>Checks Used</td> <td>Checks Used</td>
{% with num_checks=profile.num_checks_used %}
<td {% if num_checks >= profile.check_limit %} class="at-limit" {% endif %}> <td {% if num_checks >= profile.check_limit %} class="at-limit" {% endif %}>
<span>{{ num_checks }} of {{ profile.check_limit }}</span> <span>{{ num_checks }} of {{ profile.check_limit }}</span>
</td> </td>
{% endwith %}
</tr> </tr>
</table> </table>


+ 141
- 5
templates/accounts/project.html View File

@ -5,13 +5,57 @@
{% block content %} {% block content %}
{% with project.transfer_request as transfer_request %}
<div class="row"> <div class="row">
<div class="col-sm-9 col-md-6"> <div class="col-sm-9 col-md-6">
{% for message in messages %} {% for message in messages %}
<p class="alert alert-{{ message.tags }}">{{ message }}</p> <p class="alert alert-{{ message.tags }}">{{ message }}</p>
{% endfor %} {% endfor %}
<div class="panel panel-{{ project_name_status }}">
{% if transfer_request and transfer_request.user == request.user %}
{% with can_accept=transfer_request.can_accept %}
<div id="transfer-request" class="panel">
<div class="panel-body settings-block">
<h2>Ownership Transfer Request</h2>
<p>
<strong>{{ project.owner.email }}</strong> would like to transfer
the ownership of this project to you.
</p>
{% if not can_accept %}
{% with num_checks=project.num_checks num_available=request.profile.num_checks_available %}
<p>
This project has
<strong>{{ num_checks }} check{{ num_checks|pluralize}}</strong>,
but your account only has space for
<strong>{{ num_available }} additional check{{ num_available|pluralize }}</strong>.
To accept the transfer, please
<a href="{% url 'hc-billing' %}">upgrade your account first!</a>
</p>
{% endwith%}
{% endif %}
<div class="pull-right">
<form method="post">
{% csrf_token %}
<button
type="submit"
name="reject_transfer"
class="btn btn-default">Reject</button>
<button
type="submit"
name="accept_transfer"
{% if not can_accept %}disabled{% endif %}
class="btn btn-primary">Accept</button>
</form>
</div>
</div>
</div>
{% endwith %}
{% endif %}
<div class="panel panel-{{ project_name_status|default:'default' }}">
<div class="panel-body settings-block"> <div class="panel-body settings-block">
<h2>Project Name</h2> <h2>Project Name</h2>
{{ project }} {{ project }}
@ -29,7 +73,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="panel panel-{{ api_status }}">
<div class="panel panel-{{ api_status|default:'default' }}">
<div class="panel-body settings-block"> <div class="panel-body settings-block">
<h2>API Access</h2> <h2>API Access</h2>
{% if project.api_key %} {% if project.api_key %}
@ -91,7 +135,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="panel panel-{{ team_status }}">
<div class="panel panel-{{ team_status|default:'default' }}">
<div class="panel-body settings-block"> <div class="panel-body settings-block">
<h2>Team Access</h2> <h2>Team Access</h2>
{% if project.team.exists or invite_suggestions %} {% if project.team.exists or invite_suggestions %}
@ -121,14 +165,16 @@
</tr> </tr>
{% endfor %} {% endfor %}
{% if is_owner and invite_suggestions %}
{% if is_owner %}
{% with invite_suggestions=project.invite_suggestions %}
{% if invite_suggestions %}
<tr id="suggestions-row"> <tr id="suggestions-row">
<td colspan="3"> <td colspan="3">
Add Users from Other Teams Add Users from Other Teams
</td> </td>
</tr> </tr>
{% for user in project.invite_suggestions %}
{% for user in invite_suggestions %}
<tr class="invite-suggestion"> <tr class="invite-suggestion">
<td>{{ user.email }} </td> <td>{{ user.email }} </td>
<td></td> <td></td>
@ -141,6 +187,8 @@
</tr> </tr>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% endwith %}
{% endif %}
</table> </table>
{% else %} {% else %}
<p> <p>
@ -182,6 +230,46 @@
{% endif %} {% endif %}
</div> </div>
{% if is_owner %}
<div class="panel panel-{{ transfer_status|default:'default' }}"">
<div class="panel-body settings-block">
<h2>Transfer Ownership</h2>
{% if transfer_request %}
<form method="post">
{% csrf_token %}
<button
type="submit"
name="cancel_transfer"
class="btn btn-default pull-right">Cancel Transfer</button>
</form>
Transfer initiated, awaiting confirmation from
<strong>{{ transfer_request.user.email }}</strong>.
{% else %}
<a href="#"
class="btn btn-default pull-right"
data-toggle="modal"
data-target="#transfer-modal">Transfer Project&hellip;</a>
Transfer this project to a team member.
{% endif %}
</div>
{% if transfer_initiated %}
<div class="panel-footer">
Transfer initiated!
</div>
{% endif %}
{% if transfer_cancelled %}
<div class="panel-footer">
Transfer cancelled!
</div>
{% endif %}
</div>
{% endif %}
{% if is_owner %} {% if is_owner %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-body settings-block"> <div class="panel-body settings-block">
@ -359,12 +447,60 @@
</div> </div>
</div> </div>
{% if not transfer_request %}
<div id="transfer-modal" class="modal">
<div class="modal-dialog">
<form
class="form-horizontal"
method="post">
{% csrf_token %}
<input type="hidden" name="transfer_project" value="1" />
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">&times;</button>
<h4>Transfer Ownership</h4>
</div>
<div class="modal-body">
<div class="form-group">
<label for="update-name-input" class="col-sm-4 control-label">
Choose owner
</label>
<div class="col-sm-7">
<select
id="new-owner"
name="email"
title="Select..."
class="form-control selectpicker">
{% for user in project.team %}
<option>{{ user.email }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button
id="transfer-confirm"
disabled
type="submit"
class="btn btn-primary">Initiate Transfer</button>
</div>
</div>
</form>
</div>
</div>
{% endif %}
{% endwith %}
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
{% compress js %} {% compress js %}
<script src="{% static 'js/jquery-2.1.4.min.js' %}"></script> <script src="{% static 'js/jquery-2.1.4.min.js' %}"></script>
<script src="{% static 'js/bootstrap.min.js' %}"></script> <script src="{% static 'js/bootstrap.min.js' %}"></script>
<script src="{% static 'js/bootstrap-select.min.js' %}"></script>
<script src="{% static 'js/project.js' %}"></script> <script src="{% static 'js/project.js' %}"></script>
{% endcompress %} {% endcompress %}
{% endblock %} {% endblock %}

+ 6
- 1
templates/front/my_checks.html View File

@ -4,6 +4,7 @@
{% block title %}{{ num_down|num_down_title }}{% endblock %} {% block title %}{{ num_down|num_down_title }}{% endblock %}
{% block content %} {% block content %}
{% if checks %} {% if checks %}
<div class="row"> <div class="row">
<div id="my-checks-tags" class="col-sm-9"> <div id="my-checks-tags" class="col-sm-9">
@ -46,8 +47,12 @@
{% else %} {% else %}
<div class="alert alert-info"> <div class="alert alert-info">
<strong>Check limit reached.</strong> <strong>Check limit reached.</strong>
To add more checks, please
To add more checks in this project, please
{% if request.user == project.owner %}
<a href="{% url 'hc-billing' %}">upgrade your account!</a> <a href="{% url 'hc-billing' %}">upgrade your account!</a>
{% else %}
ask <strong>{{ project.owner.email }}</strong> to upgrade their account!
{% endif %}
</div> </div>
{% endif %} {% endif %}
</div> </div>


Loading…
Cancel
Save