Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add view to delete current logged in user #1786

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
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
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 %}
4 changes: 3 additions & 1 deletion djangoproject/templates/registration/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ <h1>{% translate "Log in" %}</h1>

<form method="post" action="" class="form-input">
{% csrf_token %}
<input type="hidden" name="next" value="{{ next|default:"/" }}">
{% if next %}
<input type="hidden" name="next" value="{{ next }}">
{% endif %}

<div>
<label for="id_username">{% translate "Username:" %}</label>
Expand Down
21 changes: 21 additions & 0 deletions foundation/migrations/0007_boardmember_account_protect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 5.0.9 on 2024-11-29 06:45

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


class Migration(migrations.Migration):

dependencies = [
('foundation', '0006_hardcode_currency_choices'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.AlterField(
model_name='boardmember',
name='account',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
),
]
2 changes: 1 addition & 1 deletion foundation/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ class BoardMember(models.Model):

"""

account = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
account = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
office = models.ForeignKey(Office, related_name="holders", on_delete=models.CASCADE)
term = models.ForeignKey(
Term, related_name="board_members", on_delete=models.CASCADE
Expand Down