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
56 changes: 56 additions & 0 deletions home/migrations/0016_erasurerequest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Generated by Django 5.1.15 on 2026-07-31 11:06

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("home", "0015_add_rectification_event_type"),
]

operations = [
migrations.CreateModel(
name="ErasureRequest",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
(
"status",
models.CharField(
choices=[("pending", "Pending"), ("completed", "Completed")], default="pending", max_length=20
),
),
("requested_at", models.DateTimeField(auto_now_add=True)),
("completed_at", models.DateTimeField(blank=True, null=True)),
(
"completed_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="erasure_requests_completed",
to=settings.AUTH_USER_MODEL,
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="erasure_requests",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"constraints": [
models.UniqueConstraint(
condition=models.Q(("status", "pending")),
fields=("user",),
name="unique_pending_erasure_request_per_user",
)
],
},
),
]
45 changes: 45 additions & 0 deletions home/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from datetime import timedelta

from django.contrib.auth.models import (
AbstractBaseUser,
BaseUserManager,
Expand All @@ -7,6 +9,7 @@
from django.db.models.signals import pre_delete
from django.dispatch import receiver
from django.urls import reverse
from django.utils import timezone

from .constants import DELETED_ACCOUNT_EMAIL_DOMAIN, ROLE_ADMIN, ROLE_PROJECT_MANAGER, ROLES

Expand Down Expand Up @@ -236,3 +239,45 @@ def __str__(self):
@receiver(pre_delete, sender=DataProtectionEvent)
def _prevent_dp_event_delete(sender, instance, **kwargs):
raise ValueError("DataProtectionEvent is append-only and cannot be deleted")


class ErasureRequest(models.Model):
"""
A pending self-service GDPR erasure request that could not be actioned
immediately (see UserService.request_self_erasure) — e.g. the requester
is the sole admin of an organisation with other members. Staff complete
it via the existing ConsoleDeleteUserView.
"""

class Status(models.TextChoices):
PENDING = "pending", "Pending"
COMPLETED = "completed", "Completed"

user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="erasure_requests")
status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING)
requested_at = models.DateTimeField(auto_now_add=True)
completed_at = models.DateTimeField(null=True, blank=True)
completed_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="erasure_requests_completed",
)

class Meta:
constraints = [
models.UniqueConstraint(
fields=["user"],
condition=models.Q(status="pending"),
name="unique_pending_erasure_request_per_user",
),
]

@property
def is_overdue(self) -> bool:
"""UK GDPR Art. 12(3): erasure requests must be completed within one month."""
return self.status == self.Status.PENDING and timezone.now() - self.requested_at > timedelta(days=30)

def __str__(self):
return f"Erasure request for {self.user} ({self.get_status_display()})"
21 changes: 21 additions & 0 deletions home/services/organisation.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,5 +262,26 @@ def get_organisation_members(
organisation=organisation
).select_related("user")

def get_sole_admin_orgs_with_other_members(self, user: User) -> QuerySet[Organisation]:
"""
Organisations where `user` is the only ADMIN and other members
exist. Erasing `user` immediately would strand these orgs with
nobody able to manage them, so self-service erasure defers to staff
instead (see UserService.request_self_erasure).
"""
admin_org_ids = OrganisationMembership.objects.filter(
user=user, role=ROLE_ADMIN
).values_list("organisation_id", flat=True)

blocking_ids = [
org_id
for org_id in admin_org_ids
if not OrganisationMembership.objects.filter(organisation_id=org_id, role=ROLE_ADMIN)
.exclude(user=user)
.exists()
and OrganisationMembership.objects.filter(organisation_id=org_id).exclude(user=user).exists()
]
return Organisation.objects.filter(pk__in=blocking_ids)


organisation_service = OrganisationService()
60 changes: 58 additions & 2 deletions home/services/user.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,74 @@
import uuid

import django.core.mail
from django.conf import settings

from ..constants import DELETED_ACCOUNT_EMAIL_DOMAIN
from ..models import OrganisationMembership, Project, User
from ..models import DataProtectionEvent, ErasureRequest, OrganisationMembership, Project, User
from .data_protection import data_protection_service
from .organisation import organisation_service


def notify_staff_of_pending_erasure(user: User) -> None:
"""
Alert staff that a self-service erasure request needs manual action
(see UserService.request_self_erasure). Follows the same plain
``send_mail`` pattern used for survey invitations (survey/views.py) —
there's no shared notification service yet.
"""
staff_emails = list(
User.objects.filter(is_staff=True, is_active=True).values_list("email", flat=True)
)
if not staff_emails:
return

django.core.mail.send_mail(
subject="SORT: account erasure request needs action",
message=(
f"{user} ({user.email}) has requested account erasure but is the "
"sole admin of an organisation with other members, so it could not "
"be completed automatically.\n\n"
"UK GDPR Art. 12(3) requires this to be actioned within one month "
"of the request. Please review and complete it via the console: "
f"/console/users/{user.pk}/"
),
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=staff_emails,
fail_silently=False,
)


class UserService:
def anonymise(self, user: User) -> None:
def anonymise(self, user: User, *, requested_by: User, actioned_by: User) -> None:
user.first_name = "Deleted"
user.last_name = "User"
user.email = f"deleted-{uuid.uuid4().hex}@{DELETED_ACCOUNT_EMAIL_DOMAIN}"
user.is_active = False
user.set_unusable_password()
user.save()
OrganisationMembership.objects.filter(user=user).delete()
data_protection_service.record_event(
event_type=DataProtectionEvent.EventType.ERASURE,
subject_user=user,
requested_by=requested_by,
actioned_by=actioned_by,
)

def request_self_erasure(self, user: User) -> bool:
"""
Self-service GDPR erasure/consent-withdrawal (UK GDPR Art. 7(3),
17(1)(b)). Returns True if the account was erased immediately, or
False if it was deferred to staff because `user` is the sole admin
of an organisation with other members — erasing them immediately
would leave that organisation unmanageable.
"""
if organisation_service.get_sole_admin_orgs_with_other_members(user).exists():
ErasureRequest.objects.get_or_create(user=user, status=ErasureRequest.Status.PENDING)
notify_staff_of_pending_erasure(user)
return False

self.anonymise(user, requested_by=user, actioned_by=user)
return True

def update_user(self, user: User, *, first_name: str, last_name: str, email: str) -> User:
"""
Expand Down
12 changes: 12 additions & 0 deletions home/templates/console/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,18 @@ <h1 class="h3 mb-1">Overview</h1>
</div>
</div>
</div>
<div class="col-6 col-md-4 col-lg-2">
<a href="{% url 'admin_pending_erasure_requests' %}" class="text-decoration-none">
<div class="card text-center h-100 border-0 shadow-sm {% if stats.pending_erasure_requests %}border-warning{% endif %}">
<div class="card-body">
<div class="fs-2 fw-bold {% if stats.pending_erasure_requests %}text-warning{% else %}text-primary{% endif %}">
<i class="bx bxs-user-minus"></i> {{ stats.pending_erasure_requests }}
</div>
<div class="text-muted small">Pending erasure requests</div>
</div>
</div>
</a>
</div>
</div>

{# --- Recent activity --- #}
Expand Down
52 changes: 52 additions & 0 deletions home/templates/console/pending_erasure_requests.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{% extends "base_console.html" %}
{% block title %}| Admin — Pending erasure requests{% endblock %}

{% block content %}
<div class="container py-4">
<h1 class="h3 mb-1">Pending erasure requests</h1>
<p class="text-muted mb-4">
Self-service account deletion requests that couldn't be completed
automatically — usually because the requester is the sole admin
of an organisation with other members. UK GDPR Art. 12(3)
requires these to be actioned within one month. Use each user's
existing "Delete user" action in the console to complete a request.
</p>

<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>User</th>
<th>Email</th>
<th>Requested</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for erasure_request in requests %}
<tr>
<td><a href="{% url 'admin_user_detail' erasure_request.user.pk %}">{{ erasure_request.user }}</a></td>
<td>{{ erasure_request.user.email }}</td>
<td>{{ erasure_request.requested_at|date:"d M Y" }}</td>
<td>
{% if erasure_request.is_overdue %}
<span class="badge bg-danger">Overdue</span>
{% else %}
<span class="badge bg-warning text-dark">Pending</span>
{% endif %}
</td>
<td>
<a href="{% url 'admin_delete_user' erasure_request.user.pk %}" class="btn btn-sm btn-danger">Complete erasure</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-muted">No pending erasure requests.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
5 changes: 5 additions & 0 deletions home/templates/console/user_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ <h1 class="h3 mb-1">{{ viewed_user }}</h1>
{% elif not viewed_user.is_active %}
<span class="badge bg-secondary">Suspended</span>
{% endif %}
{% if pending_erasure_request %}
<span class="badge {% if pending_erasure_request.is_overdue %}bg-danger{% else %}bg-warning text-dark{% endif %}">
Erasure requested {{ pending_erasure_request.requested_at|date:"d M Y" }}
</span>
{% endif %}
{% if viewed_user.is_deleted %}
{% elif not viewed_user.is_active %}
<form method="post" action="{% url 'admin_unsuspend_user' viewed_user.pk %}">
Expand Down
13 changes: 13 additions & 0 deletions home/templates/home/account_deleted.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{% extends "login_base.html" %}

{% block title %}Account deleted{% endblock %}

{% block content %}
<h3 class="text-center mb-4">Account deleted</h3>

<p>Your account has been deleted and your personal data has been erased. You have been logged out and can no longer sign in with this account.</p>

<div class="text-center mt-4">
<a href="{% url 'landing' %}" class="btn btn-primary w-100">Return to homepage</a>
</div>
{% endblock %}
49 changes: 49 additions & 0 deletions home/templates/home/delete_account_confirm.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{% extends "base_manager.html" %}
{% block title %}Delete my account{% endblock %}

{% block content %}
<div class="container mx-auto px-4 py-8 mt-4">

<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'profile' %}">Profile</a></li>
<li class="breadcrumb-item active" aria-current="page">Delete my account</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">Delete my account</h1>
</div>
<div class="card-body">
<p>This withdraws your consent to SORT processing your personal data and requests its erasure, as described in our <a href="{% url 'privacy' %}">privacy notice</a>.</p>
<p>In most cases this happens immediately and will:</p>
<ul>
<li>Wipe your personal data (name, email, password)</li>
<li>Remove you from all organisations</li>
<li>Deactivate your account so you can no longer log in</li>
</ul>
<p class="text-muted small">
If you're the only admin of an organisation that has other
members, we can't erase your account automatically without
leaving that organisation unmanaged. In that case, your
request will be sent to our staff to complete within 30
days, and your account will remain active until then.
</p>
<p class="mb-0">This action cannot be undone.</p>
</div>
<div class="card-footer bg-white d-flex gap-2 justify-content-end">
<a href="{% url 'profile' %}" class="btn btn-outline-secondary">Cancel</a>
<form method="post">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Delete my account</button>
</form>
</div>
</div>
</div>
</div>

</div>
{% endblock %}
10 changes: 10 additions & 0 deletions home/templates/home/profile.html
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,15 @@ <h2>Security</h2>
</p>
</div>
</div>
<div class="card custom-card border-danger shadow-sm my-4">
<div class="card-body">
<h2>Danger zone</h2>
<p>Withdraw your consent and permanently delete your SORT account and personal data.</p>
<a href="{% url 'delete_account' %}" class="btn btn-outline-danger">
<i class="bx bxs-trash"></i>
Delete my account
</a>
</div>
</div>
</div>
{% endblock %}
Loading
Loading