Skip to content

Commit

Permalink
Fixed #1782 -- Added page to delete one's user account
Browse files Browse the repository at this point in the history
  • Loading branch information
bmispelon committed Dec 1, 2024
1 parent f4234de commit 73e73c9
Show file tree
Hide file tree
Showing 7 changed files with 213 additions and 2 deletions.
51 changes: 51 additions & 0 deletions accounts/forms.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -36,3 +38,52 @@ def save(self, commit=True):
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
51 changes: 50 additions & 1 deletion accounts/tests.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from django.contrib.auth.models import User
from django.contrib.auth.models import AnonymousUser, User
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

Expand Down Expand Up @@ -169,3 +171,50 @@ def test_profile_view_reversal(self):
"""
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", "[email protected]")
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, "")
10 changes: 10 additions & 0 deletions accounts/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@
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")),
]
40 changes: 39 additions & 1 deletion accounts/views.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
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 caches
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


Expand All @@ -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):
c = caches["default"]
username = user.username.encode("ascii", "ignore")
Expand Down
40 changes: 40 additions & 0 deletions djangoproject/templates/accounts/delete_profile.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% load i18n %}

{% block title %}{% translate "Confirmation: delete your profile" %}{% endblock %}

{% block content %}
{% if form.errors %}
<h2>{% translate "Could not delete account" %}</h2>

<p>{% blocktranslate trimmed %}
Sorry, something went wrong when trying to delete your account.
That means there's probably some protected data still associated
with your account.
Please contact
<a href="mailto:[email protected]?{{ OPS_EMAIL_PRESETS }}">the operations team</a>
and we'll sort it out for you.
{% endblocktranslate %}</p>
{% else %}
<h2>{% translate "Are you sure?" %}</h2>

<p>{% blocktranslate trimmed with username=request.user.username %}
⚠️ You are about to delete all data associated with the username
<strong>{{ username}}</strong>.
{% endblocktranslate %}</p>

<p>{% blocktranslate trimmed %}
Deleting your account is permanent and <strong>cannot be reversed</strong>.
Are you sure you want to continue?
{% endblocktranslate %}</p>
<form method="post">
{% csrf_token %}
<div class="submit">
<button type="submit">{% translate "Yes, delete account" %}</button>
<a href="{% url 'edit_profile' %}">
{% translate "No, cancel and go back" %}
</a>
</div>
</form>
{% endif %}
{% endblock %}
17 changes: 17 additions & 0 deletions djangoproject/templates/accounts/delete_profile_success.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{% extends "base.html" %}

{% load i18n %}

{% block content %}
<h2>{% translate "Account deleted" %}</h2>
<p>
{% translate "Your account and its data was successfully deleted and you've been logged out." %}
</p>
<p>
{% url "community-index" as community_index_url %}
{% blocktranslate trimmed %}
Thanks for spending your time with us, we hope we'll still see you
around on our <a href="{{ community_index_url }}">various community spaces, online and off.
{% endblocktranslate %}
</p>
{% endblock %}
6 changes: 6 additions & 0 deletions djangoproject/templates/accounts/edit_profile.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,11 @@ <h2>{% translate "Help" %}</h2>
emails. We'll also use this email to try to fetch a <a
href="https://en.gravatar.com/">Gravatar</a>. You can change the image for this
email at <a href="https://en.gravatar.com/">Gravatar</a>.{% endblocktranslate %}</p>

<p>
<a href="{% url 'delete_profile' %}">
{% translate "Want to delete your account?" %}
</a>
</p>
</div>
{% endblock %}

0 comments on commit 73e73c9

Please sign in to comment.