summaryrefslogtreecommitdiff
path: root/accounts
diff options
context:
space:
mode:
authorBaptiste Mispelon <bmispelon@gmail.com>2024-11-29 13:43:59 +0100
committerSaptak Sengupta <saptak013@gmail.com>2025-07-19 13:46:08 +0530
commit977c2352456c5c15f5629b1632693ead0e8e410f (patch)
treeff27f205d19a31fbda65bc8b3b7c8c5e54c20296 /accounts
parentaa7792ab7510ed1d7cd268a4e46b587ea54a75fe (diff)
Fixed #1782 -- Added page to delete one's user account
Diffstat (limited to 'accounts')
-rw-r--r--accounts/forms.py51
-rw-r--r--accounts/tests.py51
-rw-r--r--accounts/urls.py10
-rw-r--r--accounts/views.py40
4 files changed, 150 insertions, 2 deletions
diff --git a/accounts/forms.py b/accounts/forms.py
index 39547d3b..a63c3116 100644
--- a/accounts/forms.py
+++ b/accounts/forms.py
@@ -1,4 +1,6 @@
from django import forms
+from django.db import transaction
+from django.db.models import ProtectedError
from django.utils.translation import gettext_lazy as _
from .models import Profile
@@ -36,3 +38,52 @@ class ProfileForm(forms.ModelForm):
if commit:
instance.user.save()
return instance
+
+
+class DeleteProfileForm(forms.Form):
+ """
+ A form for delete the request's user and their associated data.
+
+ This form has no fields, it's used as a container for validation and deltion
+ logic.
+ """
+
+ class InvalidFormError(Exception):
+ pass
+
+ def __init__(self, *args, user=None, **kwargs):
+ if user.is_anonymous:
+ raise TypeError("DeleteProfileForm only accepts actual User instances")
+ self.user = user
+ super().__init__(*args, **kwargs)
+
+ def clean(self):
+ cleaned_data = super().clean()
+ if self.user.is_staff:
+ # Prevent potentially deleting some important history (admin.LogEntry)
+ raise forms.ValidationError(_("Staff users cannot be deleted"))
+ return cleaned_data
+
+ def add_errors_from_protectederror(self, exception):
+ """
+ Convert the given ProtectedError exception object into validation
+ errors on the instance.
+ """
+ self.add_error(None, _("User has protected data and cannot be deleted"))
+
+ @transaction.atomic()
+ def delete(self):
+ """
+ Delete the form's user (self.instance).
+ """
+ if not self.is_valid():
+ raise self.InvalidFormError(
+ "DeleteProfileForm.delete() can only be called on valid forms"
+ )
+
+ try:
+ self.user.delete()
+ except ProtectedError as e:
+ self.add_errors_from_protectederror(e)
+ return None
+ return self.user
diff --git a/accounts/tests.py b/accounts/tests.py
index 65af63cf..dbbd97dd 100644
--- a/accounts/tests.py
+++ b/accounts/tests.py
@@ -1,10 +1,12 @@
import hashlib
-from django.contrib.auth.models import User
+from django.contrib.auth.models import AnonymousUser, User
from django.core.cache import cache
from django.test import TestCase, override_settings
from django_hosts.resolvers import reverse
+from accounts.forms import DeleteProfileForm
+from foundation import models as foundationmodels
from tracdb.models import Revision, Ticket, TicketChange
from tracdb.testutils import TracDBCreateDatabaseMixin
@@ -189,3 +191,50 @@ class ViewsTests(TestCase):
"""
for username in ["asdf", "@asdf", "asd-f", "as.df", "as+df"]:
reverse("user_profile", host="www", args=[username])
+
+
+class UserDeletionTestCase(TestCase):
+ def create_user_and_form(self, bound=True, **userkwargs):
+ userkwargs.setdefault("username", "test")
+ userkwargs.setdefault("email", "test@example.com")
+ userkwargs.setdefault("password", "password")
+
+ formkwargs = {"user": User.objects.create_user(**userkwargs)}
+ if bound:
+ formkwargs["data"] = {}
+
+ return DeleteProfileForm(**formkwargs)
+
+ def test_deletion(self):
+ form = self.create_user_and_form()
+ self.assertFormError(form, None, [])
+ form.delete()
+ self.assertQuerySetEqual(User.objects.all(), [])
+
+ def test_anonymous_user_error(self):
+ self.assertRaises(TypeError, DeleteProfileForm, user=AnonymousUser)
+
+ def test_deletion_staff_forbidden(self):
+ form = self.create_user_and_form(is_staff=True)
+ self.assertFormError(form, None, ["Staff users cannot be deleted"])
+
+ def test_user_with_protected_data(self):
+ form = self.create_user_and_form()
+ form.user.boardmember_set.create(
+ office=foundationmodels.Office.objects.create(name="test"),
+ term=foundationmodels.Term.objects.create(year=2000),
+ )
+ form.delete()
+ self.assertFormError(
+ form, None, ["User has protected data and cannot be deleted"]
+ )
+
+ def test_form_delete_method_requires_valid_form(self):
+ form = self.create_user_and_form(is_staff=True)
+ self.assertRaises(form.InvalidFormError, form.delete)
+
+ def test_view_deletion_also_logs_out(self):
+ user = self.create_user_and_form().user
+ self.client.force_login(user)
+ self.client.post(reverse("delete_profile"))
+ self.assertEqual(self.client.cookies["sessionid"].value, "")
diff --git a/accounts/urls.py b/accounts/urls.py
index 5558d130..0e535c75 100644
--- a/accounts/urls.py
+++ b/accounts/urls.py
@@ -15,6 +15,16 @@ urlpatterns = [
account_views.edit_profile,
name="edit_profile",
),
+ path(
+ "delete/",
+ account_views.delete_profile,
+ name="delete_profile",
+ ),
+ path(
+ "delete/success/",
+ account_views.delete_profile_success,
+ name="delete_profile_success",
+ ),
path("", include("django.contrib.auth.urls")),
path("", include("registration.backends.default.urls")),
]
diff --git a/accounts/views.py b/accounts/views.py
index 80cfffce..98121f3c 100644
--- a/accounts/views.py
+++ b/accounts/views.py
@@ -1,5 +1,7 @@
import hashlib
+from urllib.parse import urlencode
+from django.contrib.auth import logout
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.core.cache import cache
@@ -7,7 +9,7 @@ from django.shortcuts import get_object_or_404, redirect, render
from tracdb import stats as trac_stats
-from .forms import ProfileForm
+from .forms import DeleteProfileForm, ProfileForm
from .models import Profile
@@ -34,6 +36,42 @@ def edit_profile(request):
return render(request, "accounts/edit_profile.html", {"form": form})
+@login_required
+def delete_profile(request):
+ if request.method == "POST":
+ form = DeleteProfileForm(data=request.POST, user=request.user)
+ if form.is_valid():
+ if form.delete():
+ logout(request)
+ return redirect("delete_profile_success")
+ else:
+ form = DeleteProfileForm(user=request.user)
+
+ context = {
+ "form": form,
+ # Strings are left translated on purpose (ops prefer english :D)
+ "OPS_EMAIL_PRESETS": urlencode(
+ {
+ "subject": "[djangoproject.com] Manual account deletion",
+ "body": (
+ "Hello lovely Django Ops,\n\n"
+ "I would like to delete my djangoproject.com user account ("
+ f"username {request.user.username}) but the system is not letting "
+ "me do it myself. Could you help me out please?\n\n"
+ "Thanks in advance,\n"
+ "You're amazing\n"
+ f"{request.user.get_full_name() or request.user.username}"
+ ),
+ }
+ ),
+ }
+ return render(request, "accounts/delete_profile.html", context)
+
+
+def delete_profile_success(request):
+ return render(request, "accounts/delete_profile_success.html")
+
+
def get_user_stats(user):
username = user.username.encode("ascii", "ignore")
key = "user_vital_status:%s" % hashlib.md5(username).hexdigest()