10-day grace period for user deletion (#1445)

This commit is contained in:
Gavin Wahl
2024-11-15 06:27:18 -07:00
committed by GitHub
parent cff965a29f
commit 527a5cf83e
12 changed files with 200 additions and 12 deletions

View File

@@ -1,4 +1,5 @@
import os
import datetime
from celery import Celery
from celery.schedules import crontab
@@ -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"),
)

View File

@@ -563,3 +563,5 @@ OAUTH_APP_NAME = (
X_FRAME_OPTIONS = "SAMEORIGIN"
SLACK_BOT_TOKEN = env("SLACK_BOT_TOKEN", default="")
ACCOUNT_DELETION_GRACE_PERIOD_DAYS = 10

View File

@@ -67,6 +67,8 @@ from users.views import (
UserViewSet,
UserAvatar,
DeleteUserView,
CancelDeletionView,
DeleteImmediatelyView,
)
from versions.api import ImportVersionsView, VersionViewSet
from versions.feeds import AtomVersionFeed, RSSVersionFeed
@@ -105,6 +107,16 @@ urlpatterns = (
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"),

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -4,10 +4,19 @@
{% 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>
@@ -15,7 +24,7 @@
<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 %}

View File

@@ -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 %}

View File

@@ -114,7 +114,17 @@
{% 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>

View File

@@ -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),
),
]

View File

@@ -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)
@@ -295,8 +298,13 @@ class User(BaseUser):
@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"
@@ -307,6 +315,7 @@ class User(BaseUser):
transaction.on_commit(lambda: image.delete())
self.image = None
self.image_thumbnail = None
self.delete_permanently_at = None
self.save()

View File

@@ -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
@@ -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],
)

View File

@@ -1,3 +1,5 @@
import datetime
from allauth.account import app_settings
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
@@ -5,8 +7,11 @@ 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
@@ -302,20 +307,57 @@ class UserAvatar(TemplateView):
return context
class DeleteUserView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
class DeleteUserView(LoginRequiredMixin, FormView):
template_name = "users/delete.html"
success_url = "/"
success_message = "Your profile was successfully deleted."
success_url = reverse_lazy("profile-account")
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_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."
def get_object(self):
return self.request.user
def form_valid(self, form):
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)