Skip to content

Commit

Permalink
10-day grace period for user deletion (#1445)
Browse files Browse the repository at this point in the history
  • Loading branch information
gavinwahl authored Nov 15, 2024
1 parent cff965a commit 527a5cf
Show file tree
Hide file tree
Showing 12 changed files with 199 additions and 11 deletions.
7 changes: 7 additions & 0 deletions config/celery.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import datetime

from celery import Celery
from celery.schedules import crontab
Expand Down Expand Up @@ -43,3 +44,9 @@ def setup_periodic_tasks(sender, **kwargs):
crontab(hour=3, minute=7),
app.signature("slack.tasks.fetch_slack_activity"),
)

# delete users scheduled for deletion, arbitrarily every 61 minutes
sender.add_periodic_task(
datetime.timedelta(minutes=61),
app.signature("users.tasks.do_scheduled_user_deletions"),
)
2 changes: 2 additions & 0 deletions config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -563,3 +563,5 @@
X_FRAME_OPTIONS = "SAMEORIGIN"

SLACK_BOT_TOKEN = env("SLACK_BOT_TOKEN", default="")

ACCOUNT_DELETION_GRACE_PERIOD_DAYS = 10
12 changes: 12 additions & 0 deletions config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@
UserViewSet,
UserAvatar,
DeleteUserView,
CancelDeletionView,
DeleteImmediatelyView,
)
from versions.api import ImportVersionsView, VersionViewSet
from versions.feeds import AtomVersionFeed, RSSVersionFeed
Expand Down Expand Up @@ -105,6 +107,16 @@
path("accounts/", include("allauth.urls")),
path("users/me/", CurrentUserProfileView.as_view(), name="profile-account"),
path("users/me/delete/", DeleteUserView.as_view(), name="profile-delete"),
path(
"users/me/cancel-delete/",
CancelDeletionView.as_view(),
name="profile-cancel-delete",
),
path(
"users/me/delete-immediately/",
DeleteImmediatelyView.as_view(),
name="profile-delete-immediately",
),
path("users/<int:pk>/", ProfileView.as_view(), name="profile-user"),
path("users/avatar/", UserAvatar.as_view(), name="user-avatar"),
path("api/v1/users/me/", CurrentUserAPIView.as_view(), name="current-user"),
Expand Down
18 changes: 18 additions & 0 deletions templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,24 @@
<div class="w-full">
{% block messages %}{% include "partials/messages.html" %}{% endblock messages %}
</div>
{% if request.user.is_authenticated and request.user.delete_permanently_at %}
<div id="messages" class="w-full text-center transition-opacity" x-data="{show: true}">
<div x-show="show" class="w-2/3 mx-auto text-left items-center text-slate dark:text-white rounded text-base px-3 py-2 bg-red-500 fade show">
<button type="button"
class="float-right"
data-dismiss="alert"
aria-hidden="true"
x-on:click="show = ! show"
><i class="fas fa-times-circle"></i></button>
<p>
Your account is scheduled for deletion at
{{ request.user.delete_permanently_at|date:'N j, Y, P e' }}
</p>
<p><a href="{% url "profile-cancel-delete" %}">Cancel deletion</a></p>
<p><a href="{% url "profile-delete-immediately" %}">Delete now</a></p>
</div>
</div>
{% endif %}

<div class="md:px-0 min-vh-110">
{% block content_wrapper %}
Expand Down
20 changes: 20 additions & 0 deletions templates/users/cancel_deletion.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{% extends "users/profile_base.html" %}

{% load i18n %}

{% block content %}
<div class="container">
<h2>{% trans "Cancel scheduled account deletion" %}</h2>
<form method="POST">
{% csrf_token %}
{% for field in form %}
<div>
{% include "includes/_form_input.html" %}
</div>
{% endfor %}
<button class="py-2 px-3 text-sm text-white rounded bg-orange" type="submit">
{% trans 'Confirm' %}
</button>
</form>
</div>
{% endblock content %}
13 changes: 11 additions & 2 deletions templates/users/delete.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,27 @@

{% block content %}
<div class="container">
<h1>{% trans "Delete Account" %}</h3>
<h2>{% trans "Delete Account" %}</h2>
<form method="POST">
{% csrf_token %}
{% for field in form %}
<p>
{% blocktrans count days=ACCOUNT_DELETION_GRACE_PERIOD_DAYS trimmed %}
Your account will be scheduled for deletion in {{ ACCOUNT_DELETION_GRACE_PERIOD_DAYS }} day.
You can cancel the deletion before then.
{% plural %}
Your account will be scheduled for deletion in {{ ACCOUNT_DELETION_GRACE_PERIOD_DAYS }} days.
You can cancel the deletion before then.
{% endblocktrans %}
</p>
<div>
{% include "includes/_form_input.html" %}
</div>
{% endfor %}
<button class="py-2 px-3 text-sm text-white rounded bg-orange" type="submit">
{% trans 'Confirm' %}
</button>
<a href="{% url 'profile-account' %}">Cancel</a>
<a href="{% url 'profile-account' %}">{% trans 'Cancel' %}</a>
</form>
</div>
{% endblock content %}
21 changes: 21 additions & 0 deletions templates/users/delete_immediately.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{% extends "users/profile_base.html" %}

{% load i18n %}

{% block content %}
<div class="container">
<h2>{% trans "Delete Account Immediately" %}</h2>
<form method="POST">
{% csrf_token %}
{% for field in form %}
<div>
{% include "includes/_form_input.html" %}
</div>
{% endfor %}
<button class="py-2 px-3 text-sm text-white rounded bg-orange" type="submit">
{% trans 'Confirm' %}
</button>
<a href="{% url 'profile-account' %}">{% trans 'Cancel' %}</a>
</form>
</div>
{% endblock content %}
10 changes: 10 additions & 0 deletions templates/users/profile.html
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,17 @@ <h3>{% trans "Account Connections" %}</h3>
{% endif %}
<div class="rounded bg-white dark:bg-charcoal p-4">
<h3>{% trans "Delete Account" %}</h3>
{% if user.delete_permanently_at %}
<p>
{% blocktrans with at=request.user.delete_permanently_at|date:'N j, Y, P e' trimmed %}
Your account is scheduled for deletion at {{ at }}
{% endblocktrans %}
</p>
<p><a href="{% url "profile-cancel-delete" %}">{% trans 'Cancel deletion' %}</a></p>
<p><a href="{% url "profile-delete-immediately" %}">{% trans 'Delete now' %}</a></p>
{% else %}
<a href="{% url "profile-delete" %}">{% trans 'Delete account' %}</a>
{% endif %}
</div>
</div>
</div>
Expand Down
18 changes: 18 additions & 0 deletions users/migrations/0015_user_delete_permanently_at.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.16 on 2024-11-14 02:52

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("users", "0014_populate_tou_notification_preference"),
]

operations = [
migrations.AddField(
model_name="user",
name="delete_permanently_at",
field=models.DateTimeField(editable=False, null=True),
),
]
9 changes: 9 additions & 0 deletions users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,9 @@ class User(BaseUser):
default=False,
help_text="Indicate on the login page the last login method used.",
)
# If non-null, the user has requested deletion but the grace period has not
# elapsed.
delete_permanently_at = models.DateTimeField(null=True, editable=False)

def save_image_from_github(self, avatar_url):
response = requests.get(avatar_url)
Expand Down Expand Up @@ -295,8 +298,13 @@ def github_profile_url(self):

@transaction.atomic
def delete_account(self):
from . import tasks

email = self.email
transaction.on_commit(lambda: tasks.send_account_deleted_email.delay(email))
self.socialaccount_set.all().delete()
self.preferences.delete()
self.emailaddress_set.all().delete()
self.is_active = False
self.set_unusable_password()
self.display_name = "John Doe"
Expand All @@ -307,6 +315,7 @@ def delete_account(self):
transaction.on_commit(lambda: image.delete())
self.image = None
self.image_thumbnail = None
self.delete_permanently_at = None
self.save()


Expand Down
20 changes: 20 additions & 0 deletions users/tasks.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import structlog

from django.contrib.auth import get_user_model
from django.core.mail import send_mail
from django.utils import timezone
from django.conf import settings

from celery import shared_task
from oauth2_provider.models import clear_expired
Expand Down Expand Up @@ -43,3 +46,20 @@ def update_user_github_photo(user_pk):
def clear_tokens():
"""Clears all expired tokens"""
clear_expired()


@shared_task
def do_scheduled_user_deletions():
users = User.objects.filter(delete_permanently_at__lte=timezone.now())
for user in users:
user.delete_account()


@shared_task
def send_account_deleted_email(email):
send_mail(
"Your boost.io account has been deleted",
"Your account on boost.io has been deleted.",
settings.DEFAULT_FROM_EMAIL,
[email],
)
60 changes: 51 additions & 9 deletions users/views.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import datetime

from allauth.account import app_settings
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib import auth
from django.contrib.messages.views import SuccessMessageMixin
from django.http import HttpResponseRedirect
from django.urls import reverse_lazy
from django.views.generic import DetailView, DeleteView
from django.views.generic import DetailView, FormView
from django.views.generic.base import TemplateView
from django.utils import timezone
from django.conf import settings
from django import forms

from allauth.account.forms import ChangePasswordForm, ResetPasswordForm
from allauth.account.views import LoginView, SignupView, EmailVerificationSentView
Expand Down Expand Up @@ -302,20 +307,57 @@ def get_context_data(self, **kwargs):
return context


class DeleteUserView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
class DeleteUserView(LoginRequiredMixin, FormView):
template_name = "users/delete.html"
success_url = reverse_lazy("profile-account")
form_class = DeleteAccountForm

def get_object(self):
return self.request.user

def form_valid(self, form):
user = self.get_object()
user.delete_permanently_at = timezone.now() + datetime.timedelta(
days=settings.ACCOUNT_DELETION_GRACE_PERIOD_DAYS
)
user.save()
return super().form_valid(form)

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context[
"ACCOUNT_DELETION_GRACE_PERIOD_DAYS"
] = settings.ACCOUNT_DELETION_GRACE_PERIOD_DAYS
return context


class CancelDeletionView(LoginRequiredMixin, SuccessMessageMixin, FormView):
form_class = forms.Form
success_url = reverse_lazy("profile-account")
template_name = "users/cancel_deletion.html"
success_message = "Your account is no longer scheduled for deletion."

def get_object(self):
return self.request.user

def form_valid(self, form):
user = self.get_object()
user.delete_permanently_at = None
user.save()
return super().form_valid(form)


class DeleteImmediatelyView(LoginRequiredMixin, SuccessMessageMixin, FormView):
form_class = DeleteAccountForm
template_name = "users/delete_immediately.html"
success_url = "/"
success_message = "Your profile was successfully deleted."
form_class = DeleteAccountForm

def get_object(self):
return self.request.user

def form_valid(self, form):
success_url = self.get_success_url()
user = self.get_object()
user.delete_account()
auth.logout(self.request)
self.object.delete_account()
success_message = self.get_success_message(form.cleaned_data)
if success_message:
messages.success(self.request, success_message)
return HttpResponseRedirect(success_url)
return super().form_valid(form)

0 comments on commit 527a5cf

Please sign in to comment.