Skip to content

Commit 73e73c9

Browse files
committed
Fixed #1782 -- Added page to delete one's user account
1 parent f4234de commit 73e73c9

File tree

7 files changed

+213
-2
lines changed

7 files changed

+213
-2
lines changed

accounts/forms.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
from django import forms
2+
from django.db import transaction
3+
from django.db.models import ProtectedError
24
from django.utils.translation import gettext_lazy as _
35

46
from .models import Profile
@@ -36,3 +38,52 @@ def save(self, commit=True):
3638
if commit:
3739
instance.user.save()
3840
return instance
41+
42+
43+
class DeleteProfileForm(forms.Form):
44+
"""
45+
A form for delete the request's user and their associated data.
46+
47+
This form has no fields, it's used as a container for validation and deltion
48+
logic.
49+
"""
50+
51+
class InvalidFormError(Exception):
52+
pass
53+
54+
def __init__(self, *args, user=None, **kwargs):
55+
if user.is_anonymous:
56+
raise TypeError("DeleteProfileForm only accepts actual User instances")
57+
self.user = user
58+
super().__init__(*args, **kwargs)
59+
60+
def clean(self):
61+
cleaned_data = super().clean()
62+
if self.user.is_staff:
63+
# Prevent potentially deleting some important history (admin.LogEntry)
64+
raise forms.ValidationError(_("Staff users cannot be deleted"))
65+
return cleaned_data
66+
67+
def add_errors_from_protectederror(self, exception):
68+
"""
69+
Convert the given ProtectedError exception object into validation
70+
errors on the instance.
71+
"""
72+
self.add_error(None, _("User has protected data and cannot be deleted"))
73+
74+
@transaction.atomic()
75+
def delete(self):
76+
"""
77+
Delete the form's user (self.instance).
78+
"""
79+
if not self.is_valid():
80+
raise self.InvalidFormError(
81+
"DeleteProfileForm.delete() can only be called on valid forms"
82+
)
83+
84+
try:
85+
self.user.delete()
86+
except ProtectedError as e:
87+
self.add_errors_from_protectederror(e)
88+
return None
89+
return self.user

accounts/tests.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
from django.contrib.auth.models import User
1+
from django.contrib.auth.models import AnonymousUser, User
22
from django.test import TestCase, override_settings
33
from django_hosts.resolvers import reverse
44

5+
from accounts.forms import DeleteProfileForm
6+
from foundation import models as foundationmodels
57
from tracdb.models import Revision, Ticket, TicketChange
68
from tracdb.testutils import TracDBCreateDatabaseMixin
79

@@ -169,3 +171,50 @@ def test_profile_view_reversal(self):
169171
"""
170172
for username in ["asdf", "@asdf", "asd-f", "as.df", "as+df"]:
171173
reverse("user_profile", host="www", args=[username])
174+
175+
176+
class UserDeletionTestCase(TestCase):
177+
def create_user_and_form(self, bound=True, **userkwargs):
178+
userkwargs.setdefault("username", "test")
179+
userkwargs.setdefault("email", "[email protected]")
180+
userkwargs.setdefault("password", "password")
181+
182+
formkwargs = {"user": User.objects.create_user(**userkwargs)}
183+
if bound:
184+
formkwargs["data"] = {}
185+
186+
return DeleteProfileForm(**formkwargs)
187+
188+
def test_deletion(self):
189+
form = self.create_user_and_form()
190+
self.assertFormError(form, None, [])
191+
form.delete()
192+
self.assertQuerySetEqual(User.objects.all(), [])
193+
194+
def test_anonymous_user_error(self):
195+
self.assertRaises(TypeError, DeleteProfileForm, user=AnonymousUser)
196+
197+
def test_deletion_staff_forbidden(self):
198+
form = self.create_user_and_form(is_staff=True)
199+
self.assertFormError(form, None, ["Staff users cannot be deleted"])
200+
201+
def test_user_with_protected_data(self):
202+
form = self.create_user_and_form()
203+
form.user.boardmember_set.create(
204+
office=foundationmodels.Office.objects.create(name="test"),
205+
term=foundationmodels.Term.objects.create(year=2000),
206+
)
207+
form.delete()
208+
self.assertFormError(
209+
form, None, ["User has protected data and cannot be deleted"]
210+
)
211+
212+
def test_form_delete_method_requires_valid_form(self):
213+
form = self.create_user_and_form(is_staff=True)
214+
self.assertRaises(form.InvalidFormError, form.delete)
215+
216+
def test_view_deletion_also_logs_out(self):
217+
user = self.create_user_and_form().user
218+
self.client.force_login(user)
219+
self.client.post(reverse("delete_profile"))
220+
self.assertEqual(self.client.cookies["sessionid"].value, "")

accounts/urls.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,16 @@
1515
account_views.edit_profile,
1616
name="edit_profile",
1717
),
18+
path(
19+
"delete/",
20+
account_views.delete_profile,
21+
name="delete_profile",
22+
),
23+
path(
24+
"delete/success/",
25+
account_views.delete_profile_success,
26+
name="delete_profile_success",
27+
),
1828
path("", include("django.contrib.auth.urls")),
1929
path("", include("registration.backends.default.urls")),
2030
]

accounts/views.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import hashlib
2+
from urllib.parse import urlencode
23

4+
from django.contrib.auth import logout
35
from django.contrib.auth.decorators import login_required
46
from django.contrib.auth.models import User
57
from django.core.cache import caches
68
from django.shortcuts import get_object_or_404, redirect, render
79

810
from tracdb import stats as trac_stats
911

10-
from .forms import ProfileForm
12+
from .forms import DeleteProfileForm, ProfileForm
1113
from .models import Profile
1214

1315

@@ -34,6 +36,42 @@ def edit_profile(request):
3436
return render(request, "accounts/edit_profile.html", {"form": form})
3537

3638

39+
@login_required
40+
def delete_profile(request):
41+
if request.method == "POST":
42+
form = DeleteProfileForm(data=request.POST, user=request.user)
43+
if form.is_valid():
44+
if form.delete():
45+
logout(request)
46+
return redirect("delete_profile_success")
47+
else:
48+
form = DeleteProfileForm(user=request.user)
49+
50+
context = {
51+
"form": form,
52+
# Strings are left translated on purpose (ops prefer english :D)
53+
"OPS_EMAIL_PRESETS": urlencode(
54+
{
55+
"subject": "[djangoproject.com] Manual account deletion",
56+
"body": (
57+
"Hello lovely Django Ops,\n\n"
58+
"I would like to delete my djangoproject.com user account ("
59+
f"username {request.user.username}) but the system is not letting "
60+
"me do it myself. Could you help me out please?\n\n"
61+
"Thanks in advance,\n"
62+
"You're amazing\n"
63+
f"{request.user.get_full_name() or request.user.username}"
64+
),
65+
}
66+
),
67+
}
68+
return render(request, "accounts/delete_profile.html", context)
69+
70+
71+
def delete_profile_success(request):
72+
return render(request, "accounts/delete_profile_success.html")
73+
74+
3775
def get_user_stats(user):
3876
c = caches["default"]
3977
username = user.username.encode("ascii", "ignore")
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{% extends "base.html" %}
2+
{% load i18n %}
3+
4+
{% block title %}{% translate "Confirmation: delete your profile" %}{% endblock %}
5+
6+
{% block content %}
7+
{% if form.errors %}
8+
<h2>{% translate "Could not delete account" %}</h2>
9+
10+
<p>{% blocktranslate trimmed %}
11+
Sorry, something went wrong when trying to delete your account.
12+
That means there's probably some protected data still associated
13+
with your account.
14+
Please contact
15+
<a href="mailto:[email protected]?{{ OPS_EMAIL_PRESETS }}">the operations team</a>
16+
and we'll sort it out for you.
17+
{% endblocktranslate %}</p>
18+
{% else %}
19+
<h2>{% translate "Are you sure?" %}</h2>
20+
21+
<p>{% blocktranslate trimmed with username=request.user.username %}
22+
⚠️ You are about to delete all data associated with the username
23+
<strong>{{ username}}</strong>.
24+
{% endblocktranslate %}</p>
25+
26+
<p>{% blocktranslate trimmed %}
27+
Deleting your account is permanent and <strong>cannot be reversed</strong>.
28+
Are you sure you want to continue?
29+
{% endblocktranslate %}</p>
30+
<form method="post">
31+
{% csrf_token %}
32+
<div class="submit">
33+
<button type="submit">{% translate "Yes, delete account" %}</button>
34+
<a href="{% url 'edit_profile' %}">
35+
{% translate "No, cancel and go back" %}
36+
</a>
37+
</div>
38+
</form>
39+
{% endif %}
40+
{% endblock %}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{% extends "base.html" %}
2+
3+
{% load i18n %}
4+
5+
{% block content %}
6+
<h2>{% translate "Account deleted" %}</h2>
7+
<p>
8+
{% translate "Your account and its data was successfully deleted and you've been logged out." %}
9+
</p>
10+
<p>
11+
{% url "community-index" as community_index_url %}
12+
{% blocktranslate trimmed %}
13+
Thanks for spending your time with us, we hope we'll still see you
14+
around on our <a href="{{ community_index_url }}">various community spaces, online and off.
15+
{% endblocktranslate %}
16+
</p>
17+
{% endblock %}

djangoproject/templates/accounts/edit_profile.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,5 +50,11 @@ <h2>{% translate "Help" %}</h2>
5050
emails. We'll also use this email to try to fetch a <a
5151
href="https://en.gravatar.com/">Gravatar</a>. You can change the image for this
5252
email at <a href="https://en.gravatar.com/">Gravatar</a>.{% endblocktranslate %}</p>
53+
54+
<p>
55+
<a href="{% url 'delete_profile' %}">
56+
{% translate "Want to delete your account?" %}
57+
</a>
58+
</p>
5359
</div>
5460
{% endblock %}

0 commit comments

Comments
 (0)