From 06a2d7c49d4b0d682f75458651c0954520d065eb Mon Sep 17 00:00:00 2001 From: Joe Heffer Date: Wed, 8 Jul 2026 15:14:22 +0100 Subject: [PATCH 1/2] feat: add subject access request (SAR) data export for staff console Wires up the reserved DataProtectionEvent.EventType.EXPORT flow (UK GDPR Art. 15). Staff can generate a JSON export of a user's own profile, organisation memberships, projects created, and data protection history from a new console view, without including other users' personal data. Closes #582. Co-Authored-By: Claude Sonnet 5 --- docs/data-protection.md | 2 +- home/services/user.py | 40 +++++++++- .../console/export_user_data_confirm.html | 44 +++++++++++ home/templates/console/user_detail.html | 3 + home/tests/test_console_views.py | 76 +++++++++++++++++++ home/urls.py | 5 ++ home/views/__init__.py | 2 + home/views/console.py | 45 +++++++++++ 8 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 home/templates/console/export_user_data_confirm.html diff --git a/docs/data-protection.md b/docs/data-protection.md index df75f28b..11a10c46 100644 --- a/docs/data-protection.md +++ b/docs/data-protection.md @@ -48,7 +48,7 @@ reserved for their corresponding flows as they are built. |-------|-------|----------------| | `membership_removed` | Removed from organisation | ✅ Yes — by `ConsoleRemoveMemberView`. | | `erasure` | Erasure request actioned | Reserved — call `record_event` from the erasure flow. | -| `export` | Subject access export generated | Reserved — call `record_event` from the export flow. | +| `export` | Subject access export generated | ✅ Yes — by `ConsoleExportUserDataView`. | | `restriction` | Account restricted / suspended | Reserved. | | `unrestriction` | Account restriction lifted | Reserved. | | `consent_withdrawal` | Consent withdrawn | Reserved. | diff --git a/home/services/user.py b/home/services/user.py index 55a5e6ff..b5262a83 100644 --- a/home/services/user.py +++ b/home/services/user.py @@ -1,7 +1,7 @@ import uuid from ..constants import DELETED_EMAIL_DOMAIN -from ..models import OrganisationMembership, User +from ..models import OrganisationMembership, Project, User class UserService: @@ -14,5 +14,43 @@ def anonymise(self, user: User) -> None: user.save() OrganisationMembership.objects.filter(user=user).delete() + def export_personal_data(self, user: User) -> dict: + """ + Build a UK GDPR Art. 15 subject access export of ``user``'s personal data. + + Only records belonging to ``user`` are included — e.g. other members + of a shared organisation are deliberately excluded. + """ + memberships = OrganisationMembership.objects.filter(user=user).select_related("organisation") + projects = Project.objects.filter(created_by=user).select_related("organisation") + return { + "profile": { + "id": user.pk, + "first_name": user.first_name, + "last_name": user.last_name, + "email": user.email, + "date_joined": user.date_joined, + "last_login": user.last_login, + "is_active": user.is_active, + }, + "organisation_memberships": [ + { + "organisation": membership.organisation.name, + "role": membership.role, + "joined_at": membership.joined_at, + } + for membership in memberships + ], + "projects_created": [ + { + "organisation": project.organisation.name, + "name": project.name, + "description": project.description, + "created_at": project.created_at, + } + for project in projects + ], + } + user_service = UserService() diff --git a/home/templates/console/export_user_data_confirm.html b/home/templates/console/export_user_data_confirm.html new file mode 100644 index 00000000..e9697d4d --- /dev/null +++ b/home/templates/console/export_user_data_confirm.html @@ -0,0 +1,44 @@ +{% extends "base_console.html" %} +{% block title %}| Admin — Export user data{% endblock %} + +{% block content %} +
+ + + +
+
+
+
+

Export personal data

+
+
+

Generate a UK GDPR Article 15 subject access export for {{ viewed_user }} ({{ viewed_user.email }}).

+

The export (JSON) includes:

+
    +
  • Profile details (name, email, account dates)
  • +
  • Organisation memberships and roles
  • +
  • Projects created by this user
  • +
  • Data protection history for this account
  • +
+

This action is recorded in the data protection log. Data belonging to other users (e.g. fellow organisation members) is not included.

+
+ +
+
+
+ +
+{% endblock %} diff --git a/home/templates/console/user_detail.html b/home/templates/console/user_detail.html index f2d8237a..94e7483f 100644 --- a/home/templates/console/user_detail.html +++ b/home/templates/console/user_detail.html @@ -26,6 +26,9 @@

{{ viewed_user }}

{% if not viewed_user.is_active %} Inactive {% endif %} + {% if not viewed_user.is_deleted %} + Export data (SAR) + {% endif %} {% if viewed_user.is_active and not viewed_user.is_staff and not viewed_user.is_superuser and viewed_user != request.user %} Delete user {% endif %} diff --git a/home/tests/test_console_views.py b/home/tests/test_console_views.py index b779e331..22fed05f 100644 --- a/home/tests/test_console_views.py +++ b/home/tests/test_console_views.py @@ -1,3 +1,4 @@ +import json from http import HTTPStatus import SORT.test.test_case @@ -5,6 +6,8 @@ UserFactory from SORT.test.model_factory.user.constants import PASSWORD +from home.models import DataProtectionEvent + class ConsoleViewTestCase(SORT.test.test_case.ViewTestCase): @@ -311,3 +314,76 @@ def test_delete_user_redirects_anonymous(self): target = UserFactory() response = self.client.get(f"/console/users/{target.pk}/delete/") self.assertEqual(response.status_code, HTTPStatus.FOUND) + + def test_export_user_data_get_shows_confirmation(self): + """Staff users see the export confirmation page for a user.""" + target = UserFactory() + self.login_staff() + response = self.client.get(f"/console/users/{target.pk}/export/") + self.assertEqual(response.status_code, HTTPStatus.OK) + + def test_export_user_data_post_returns_json_attachment(self): + """POST returns a downloadable JSON export of the user's own data.""" + target = UserFactory() + org = OrganisationFactory() + OrganisationMembershipFactory(user=target, organisation=org) + project = ProjectFactory(organisation=org, created_by=target) + self.login_staff() + response = self.client.post(f"/console/users/{target.pk}/export/") + + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertEqual(response["Content-Type"], "application/json") + self.assertIn("attachment", response["Content-Disposition"]) + + data = json.loads(response.content) + self.assertEqual(data["profile"]["id"], target.pk) + self.assertEqual(data["profile"]["email"], target.email) + self.assertEqual(len(data["organisation_memberships"]), 1) + self.assertEqual(data["organisation_memberships"][0]["organisation"], org.name) + self.assertEqual(len(data["projects_created"]), 1) + self.assertEqual(data["projects_created"][0]["name"], project.name) + + def test_export_user_data_excludes_other_members_personal_data(self): + """The export must not leak data belonging to fellow organisation members.""" + target = UserFactory() + other_member = UserFactory() + org = OrganisationFactory() + OrganisationMembershipFactory(user=target, organisation=org) + OrganisationMembershipFactory(user=other_member, organisation=org) + self.login_staff() + response = self.client.post(f"/console/users/{target.pk}/export/") + + self.assertEqual(len(json.loads(response.content)["organisation_memberships"]), 1) + self.assertNotContains(response, other_member.email) + self.assertNotContains(response, other_member.first_name) + + def test_export_user_data_records_data_protection_event(self): + """POST records an EXPORT event in the data protection audit log.""" + target = UserFactory() + self.login_staff() + self.client.post(f"/console/users/{target.pk}/export/") + + event = DataProtectionEvent.objects.get( + event_type=DataProtectionEvent.EventType.EXPORT, subject_user_id=target.pk + ) + self.assertEqual(event.actioned_by, self.staff_user) + + def test_export_user_data_forbidden_for_deleted_user(self): + """A previously erased (anonymised) account has nothing to export.""" + target = UserFactory(is_active=False, first_name="Deleted", last_name="User", email="deleted-abc@deleted.invalid") + self.login_staff() + response = self.client.get(f"/console/users/{target.pk}/export/") + self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) + + def test_export_user_data_forbidden_for_regular_users(self): + """Regular users cannot access the export view.""" + target = UserFactory() + self.login() + response = self.client.get(f"/console/users/{target.pk}/export/") + self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) + + def test_export_user_data_redirects_anonymous(self): + """Anonymous users are redirected away from the export view.""" + target = UserFactory() + response = self.client.get(f"/console/users/{target.pk}/export/") + self.assertEqual(response.status_code, HTTPStatus.FOUND) diff --git a/home/urls.py b/home/urls.py index 7086b35d..f3b22186 100644 --- a/home/urls.py +++ b/home/urls.py @@ -160,6 +160,11 @@ path("console/users/", views.ConsoleUserListView.as_view(), name="admin_users"), path("console/users//", views.ConsoleUserDetailView.as_view(), name="admin_user_detail"), path("console/users//delete/", views.ConsoleDeleteUserView.as_view(), name="admin_delete_user"), + path( + "console/users//export/", + views.ConsoleExportUserDataView.as_view(), + name="admin_export_user_data", + ), path( "console/surveys/", views.ConsoleSurveyListView.as_view(), name="admin_surveys" ), diff --git a/home/views/__init__.py b/home/views/__init__.py index 687f30cd..0ed47b41 100644 --- a/home/views/__init__.py +++ b/home/views/__init__.py @@ -36,6 +36,7 @@ ConsoleSurveyListView, ConsoleView, ConsoleDeleteUserView, + ConsoleExportUserDataView, ConsoleOrganisationDetailView, ConsoleOrganisationListView, ConsoleProjectDetailView, @@ -86,6 +87,7 @@ "ConsoleSurveyListView", "ConsoleView", "ConsoleDeleteUserView", + "ConsoleExportUserDataView", "ConsoleOrganisationDetailView", "ConsoleOrganisationListView", "ConsoleProjectDetailView", diff --git a/home/views/console.py b/home/views/console.py index 6aa7ad87..e88d2b94 100644 --- a/home/views/console.py +++ b/home/views/console.py @@ -8,6 +8,7 @@ from django.core.exceptions import PermissionDenied from django.core.paginator import Paginator from django.db.models import Count +from django.http import JsonResponse from django.shortcuts import get_object_or_404, redirect from django.views.generic import TemplateView, View from django.views.generic.base import TemplateResponseMixin @@ -229,6 +230,50 @@ def post(self, request, pk): return redirect("admin_users") +class ConsoleExportUserDataView(StaffRequiredMixin, TemplateResponseMixin, View): + """ + Generate a UK GDPR Article 15 subject access export for a user (issue #582). + """ + + template_name = "console/export_user_data_confirm.html" + + def _get_exportable_user(self, pk): + user = get_object_or_404(User, pk=pk) + if user.is_deleted: + raise PermissionDenied("This account has been erased and has no personal data to export.") + return user + + def get(self, request, pk): + return self.render_to_response({"viewed_user": self._get_exportable_user(pk)}) + + def post(self, request, pk): + target_user = self._get_exportable_user(pk) + + data = user_service.export_personal_data(target_user) + data["data_protection_history"] = [ + { + "event_type": event.get_event_type_display(), + "notes": event.notes, + "actioned_at": event.actioned_at, + "requested_at": event.requested_at, + } + for event in data_protection_service.list_events( + request.user, subject_user_id=target_user.pk + ) + ] + + data_protection_service.record_event( + event_type=DataProtectionEvent.EventType.EXPORT, + subject_user=target_user, + actioned_by=request.user, + notes="Subject access data exported via staff console", + ) + + response = JsonResponse(data, json_dumps_params={"indent": 2}) + response["Content-Disposition"] = f'attachment; filename="sar-export-user-{target_user.pk}.json"' + return response + + class ConsoleRemoveMemberView(StaffRequiredMixin, TemplateResponseMixin, View): template_name = "console/remove_member_confirm.html" From f95f4012b421da81428c9e495e8f73af47e49daf Mon Sep 17 00:00:00 2001 From: Joe Heffer Date: Wed, 8 Jul 2026 15:44:11 +0100 Subject: [PATCH 2/2] fix: wrap long UserFactory call to satisfy flake8 line length --- home/tests/test_console_views.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/home/tests/test_console_views.py b/home/tests/test_console_views.py index 22fed05f..831d913e 100644 --- a/home/tests/test_console_views.py +++ b/home/tests/test_console_views.py @@ -370,7 +370,9 @@ def test_export_user_data_records_data_protection_event(self): def test_export_user_data_forbidden_for_deleted_user(self): """A previously erased (anonymised) account has nothing to export.""" - target = UserFactory(is_active=False, first_name="Deleted", last_name="User", email="deleted-abc@deleted.invalid") + target = UserFactory( + is_active=False, first_name="Deleted", last_name="User", email="deleted-abc@deleted.invalid" + ) self.login_staff() response = self.client.get(f"/console/users/{target.pk}/export/") self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN)