forked from GithubBackups/healthchecks
New feature: Project Settings > Transfer Ownership (WIP, missing tests)
This commit is contained in:
parent
cb19bac70f
commit
f42b2b144a
@ -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()
|
||||
|
18
hc/accounts/migrations/0030_member_transfer_request_date.py
Normal file
18
hc/accounts/migrations/0030_member_transfer_request_date.py
Normal 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),
|
||||
),
|
||||
]
|
@ -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)
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
18
hc/api/migrations/0070_auto_20200411_1310.py
Normal file
18
hc/api/migrations/0070_auto_20200411_1310.py
Normal 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),
|
||||
),
|
||||
]
|
@ -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",
|
||||
|
@ -61,4 +61,12 @@
|
||||
|
||||
#team-table th {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
#transfer-request {
|
||||
border: 5px solid #ffdc3e;
|
||||
}
|
||||
|
||||
#transfer-request .settings-block {
|
||||
padding: 20px;
|
||||
}
|
@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -56,9 +56,11 @@
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td>Checks Used</td>
|
||||
{% with num_checks=profile.num_checks_used %}
|
||||
<td {% if num_checks >= profile.check_limit %} class="at-limit" {% endif %}>
|
||||
<span>{{ num_checks }} of {{ profile.check_limit }}</span>
|
||||
</td>
|
||||
{% endwith %}
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
@ -5,13 +5,57 @@
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% with project.transfer_request as transfer_request %}
|
||||
<div class="row">
|
||||
<div class="col-sm-9 col-md-6">
|
||||
{% for message in messages %}
|
||||
<p class="alert alert-{{ message.tags }}">{{ message }}</p>
|
||||
{% 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">
|
||||
<h2>Project Name</h2>
|
||||
{{ project }}
|
||||
@ -29,7 +73,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="panel panel-{{ api_status }}">
|
||||
<div class="panel panel-{{ api_status|default:'default' }}">
|
||||
<div class="panel-body settings-block">
|
||||
<h2>API Access</h2>
|
||||
{% if project.api_key %}
|
||||
@ -91,7 +135,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="panel panel-{{ team_status }}">
|
||||
<div class="panel panel-{{ team_status|default:'default' }}">
|
||||
<div class="panel-body settings-block">
|
||||
<h2>Team Access</h2>
|
||||
{% if project.team.exists or invite_suggestions %}
|
||||
@ -121,14 +165,16 @@
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
{% if is_owner and invite_suggestions %}
|
||||
{% if is_owner %}
|
||||
{% with invite_suggestions=project.invite_suggestions %}
|
||||
{% if invite_suggestions %}
|
||||
<tr id="suggestions-row">
|
||||
<td colspan="3">
|
||||
Add Users from Other Teams
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{% for user in project.invite_suggestions %}
|
||||
{% for user in invite_suggestions %}
|
||||
<tr class="invite-suggestion">
|
||||
<td>{{ user.email }} </td>
|
||||
<td></td>
|
||||
@ -141,6 +187,8 @@
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
</table>
|
||||
{% else %}
|
||||
<p>
|
||||
@ -182,6 +230,46 @@
|
||||
{% endif %}
|
||||
</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…</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 %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-body settings-block">
|
||||
@ -359,12 +447,60 @@
|
||||
</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">×</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 %}
|
||||
|
||||
{% block scripts %}
|
||||
{% compress js %}
|
||||
<script src="{% static 'js/jquery-2.1.4.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>
|
||||
{% endcompress %}
|
||||
{% endblock %}
|
||||
|
@ -4,6 +4,7 @@
|
||||
{% block title %}{{ num_down|num_down_title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% if checks %}
|
||||
<div class="row">
|
||||
<div id="my-checks-tags" class="col-sm-9">
|
||||
@ -46,8 +47,12 @@
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
<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>
|
||||
{% else %}
|
||||
ask <strong>{{ project.owner.email }}</strong> to upgrade their account!
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
Loading…
x
Reference in New Issue
Block a user