Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions home/templates/console/merge_organisation_confirm.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
{% extends "base_console.html" %}
{% block title %}| Admin — Merge organisation{% endblock %}

{% block content %}
<div class="container py-4">

<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'admin_organisations' %}">Organisations</a></li>
<li class="breadcrumb-item"><a href="{% url 'admin_organisation_detail' source.pk %}">{{ source.name }}</a></li>
<li class="breadcrumb-item active" aria-current="page">Merge</li>
</ol>
</nav>

<div class="row justify-content-center">
<div class="col-md-8">
<div class="card border-danger shadow-sm">
<div class="card-header bg-white text-danger">
<h1 class="h5 mb-0">Merge organisation</h1>
</div>
<div class="card-body">
<form method="get" class="mb-4">
<label for="target-select" class="form-label small">Merge <strong>{{ source.name }}</strong> into:</label>
<div class="d-flex gap-2">
<select name="target" id="target-select" class="form-select">
<option value="">Choose an organisation…</option>
{% for organisation in organisations %}
<option value="{{ organisation.pk }}" {% if target.pk == organisation.pk %}selected{% endif %}>
{{ organisation.name }}
</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-outline-secondary">Preview</button>
</div>
</form>

{% if target and plan %}
<div class="border rounded p-3 mb-3">
<p class="mb-2">
Merging <strong>{{ source.name }}</strong> into <strong>{{ target.name }}</strong> will:
</p>
<ul class="small">
<li>
Move {{ plan.projects|length }} project{{ plan.projects|length|pluralize }}
{% for project in plan.projects %}
{% if not forloop.first %}, {% endif %}<em>{{ project.name }}</em>
{% endfor %}
</li>
<li>
Transfer {{ plan.memberships_to_move|length }} membership{{ plan.memberships_to_move|length|pluralize }}
{% for membership in plan.memberships_to_move %}
{% if not forloop.first %}, {% endif %}{{ membership.user.email }} ({{ membership.get_role_display }})
{% endfor %}
</li>
<li>
Drop {{ plan.memberships_to_drop|length }} duplicate membership{{ plan.memberships_to_drop|length|pluralize }} (target's existing role wins)
{% for membership in plan.memberships_to_drop %}
{% if not forloop.first %}, {% endif %}{{ membership.user.email }}
{% endfor %}
</li>
</ul>
<p class="text-danger small mb-0">
<strong>{{ source.name }}</strong> will be deleted after the merge. This cannot be undone.
</p>
</div>

<div class="mt-3">
<label for="confirm-input" class="form-label small">
To confirm, type <strong>{{ source.name }}</strong> below:
</label>
<input type="text" id="confirm-input" class="form-control form-control-sm" autocomplete="off">
</div>
{% endif %}
</div>
<div class="card-footer bg-white d-flex gap-2 justify-content-end">
<a href="{% url 'admin_organisation_detail' source.pk %}" class="btn btn-outline-secondary">Cancel</a>
{% if target and plan %}
<form method="post">
{% csrf_token %}
<input type="hidden" name="target_id" value="{{ target.pk }}">
<button type="submit" id="confirm-submit" class="btn btn-danger" disabled>Merge organisation</button>
</form>
{% endif %}
</div>
{% if target and plan %}
<script>
document.getElementById('confirm-input').addEventListener('input', function () {
document.getElementById('confirm-submit').disabled =
this.value !== '{{ source.name|escapejs }}';
});
</script>
{% endif %}
</div>
</div>
</div>

</div>
{% endblock %}
3 changes: 3 additions & 0 deletions home/templates/console/organisation_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ <h2 class="h6 mb-0">Danger zone</h2>
</div>
<div class="card-body">
<p class="small text-muted mb-2">These actions are irreversible. Proceed with care.</p>
<a href="{% url 'admin_merge_organisation' organisation.pk %}" class="btn btn-outline-danger btn-sm w-100 mb-2">
<i class="bx bx-merge"></i> Merge into another organisation
</a>
<button class="btn btn-danger btn-sm w-100" disabled>
<i class="bx bx-trash"></i> Delete organisation
</button>
Expand Down
106 changes: 106 additions & 0 deletions home/tests/test_console_merge_organisation_view.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
from http import HTTPStatus

import SORT.test.test_case
from SORT.test.model_factory import (
OrganisationFactory,
OrganisationMembershipFactory,
ProjectFactory,
UserFactory,
)
from SORT.test.model_factory.user.constants import PASSWORD

from home.models import Organisation


class ConsoleMergeOrganisationViewTestCase(SORT.test.test_case.ViewTestCase):

def setUp(self):
super().setUp()
self.staff_user = UserFactory(is_staff=True)
self.source = OrganisationFactory()
self.target = OrganisationFactory()

def login_staff(self):
self.assertTrue(
self.client.login(username=self.staff_user.email, password=PASSWORD),
"Staff authentication failed",
)

def test_get_redirects_anonymous(self):
response = self.client.get(f"/console/organisations/{self.source.pk}/merge/")
self.assertEqual(response.status_code, HTTPStatus.FOUND)

def test_get_forbidden_for_regular_users(self):
self.login()
response = self.client.get(f"/console/organisations/{self.source.pk}/merge/")
self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN)

def test_get_without_target_shows_picker_only(self):
self.login_staff()
response = self.client.get(f"/console/organisations/{self.source.pk}/merge/")
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertNotIn("plan", response.context)

def test_get_with_valid_target_shows_preview(self):
project = ProjectFactory(organisation=self.source)
membership = OrganisationMembershipFactory(organisation=self.source)
self.login_staff()
response = self.client.get(
f"/console/organisations/{self.source.pk}/merge/",
{"target": self.target.pk},
)
self.assertEqual(response.status_code, HTTPStatus.OK)
plan = response.context["plan"]
self.assertIn(project, plan.projects)
self.assertIn(membership, plan.memberships_to_move)

def test_get_with_self_target_shows_no_preview(self):
self.login_staff()
response = self.client.get(
f"/console/organisations/{self.source.pk}/merge/",
{"target": self.source.pk},
)
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertNotIn("plan", response.context)

def test_get_with_invalid_target_shows_no_preview(self):
self.login_staff()
response = self.client.get(
f"/console/organisations/{self.source.pk}/merge/",
{"target": 999999},
)
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertNotIn("plan", response.context)

def test_post_merges_organisations(self):
project = ProjectFactory(organisation=self.source)
membership = OrganisationMembershipFactory(organisation=self.source)
source_pk = self.source.pk
target_pk = self.target.pk
self.login_staff()

response = self.client.post(
f"/console/organisations/{source_pk}/merge/",
{"target_id": target_pk},
)

self.assertRedirects(response, f"/console/organisations/{target_pk}/")
self.assertFalse(Organisation.objects.filter(pk=source_pk).exists())
project.refresh_from_db()
self.assertEqual(project.organisation_id, target_pk)
membership.refresh_from_db()
self.assertEqual(membership.organisation_id, target_pk)

def test_post_rejects_self_merge_without_deleting(self):
source_pk = self.source.pk
self.login_staff()

response = self.client.post(
f"/console/organisations/{source_pk}/merge/",
{"target_id": source_pk},
)

self.assertRedirects(
response, f"/console/organisations/{source_pk}/merge/"
)
self.assertTrue(Organisation.objects.filter(pk=source_pk).exists())
5 changes: 5 additions & 0 deletions home/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,11 @@
views.ConsoleRemoveMemberView.as_view(),
name="admin_remove_member",
),
path(
"console/organisations/<int:pk>/merge/",
views.ConsoleMergeOrganisationView.as_view(),
name="admin_merge_organisation",
),
path(
"console/projects/",
views.ConsoleProjectListView.as_view(),
Expand Down
2 changes: 2 additions & 0 deletions home/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
ConsoleDeleteUserView,
ConsoleEditUserView,
ConsoleExportUserDataView,
ConsoleMergeOrganisationView,
ConsoleOrganisationDetailView,
ConsoleOrganisationListView,
ConsoleProjectDetailView,
Expand Down Expand Up @@ -112,6 +113,7 @@
"ConsoleDeleteUserView",
"ConsoleEditUserView",
"ConsoleExportUserDataView",
"ConsoleMergeOrganisationView",
"ConsoleOrganisationDetailView",
"ConsoleOrganisationListView",
"ConsoleProjectDetailView",
Expand Down
54 changes: 52 additions & 2 deletions home/views/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@
Project,
User,
)
from home.services import data_protection_service, user_service
from home.services.organisation import remove_membership_and_record_event
from home.services import data_protection_service, organisation_service, user_service
from home.services.organisation import (
plan_organisation_merge,
remove_membership_and_record_event,
)
from survey.models import Survey, SurveyResponse


Expand Down Expand Up @@ -317,6 +320,53 @@ def post(self, request, org_pk, membership_pk):
return redirect("admin_organisation_detail", pk=org_pk)


class ConsoleMergeOrganisationView(StaffRequiredMixin, TemplateResponseMixin, View):
"""
Merge one organisation into another via the console, wrapping
organisation_service.merge_organisations() (previously only reachable
via the merge_organisations management command).
"""

template_name = "console/merge_organisation_confirm.html"

def _get_source(self, pk):
return get_object_or_404(Organisation, pk=pk)

def get(self, request, pk):
source = self._get_source(pk)
context = {
"source": source,
"organisations": Organisation.objects.exclude(pk=pk).order_by("name"),
}

target_id = request.GET.get("target")
if target_id:
target = Organisation.objects.filter(pk=target_id).first()
if target is not None:
try:
context["target"] = target
context["plan"] = plan_organisation_merge(source, target)
except ValueError:
messages.error(request, "An organisation cannot be merged into itself.")

return self.render_to_response(context)

def post(self, request, pk):
source = self._get_source(pk)
target = get_object_or_404(Organisation, pk=request.POST.get("target_id"))
source_name = source.name
target_name = target.name

try:
organisation_service.merge_organisations(request.user, source, target)
except (ValueError, PermissionDenied) as exc:
messages.error(request, str(exc))
return redirect("admin_merge_organisation", pk=source.pk)

messages.success(request, f"'{source_name}' merged into '{target_name}'.")
return redirect("admin_organisation_detail", pk=target.pk)


class ConsoleSuspendUserView(StaffRequiredMixin, TemplateResponseMixin, View):
"""
Suspend a user account (UK GDPR Article 18, Right to Restriction).
Expand Down
Loading