diff --git a/config/celery.py b/config/celery.py index 54b411de..73f48620 100644 --- a/config/celery.py +++ b/config/celery.py @@ -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"), + ) diff --git a/config/settings.py b/config/settings.py index 9178c2c1..82217966 100755 --- a/config/settings.py +++ b/config/settings.py @@ -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 diff --git a/config/urls.py b/config/urls.py index 327aa591..2150b8f8 100755 --- a/config/urls.py +++ b/config/urls.py @@ -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//", 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"), diff --git a/templates/base.html b/templates/base.html index 2ef415b5..38deec21 100644 --- a/templates/base.html +++ b/templates/base.html @@ -59,6 +59,24 @@
{% block messages %}{% include "partials/messages.html" %}{% endblock messages %}
+ {% if request.user.is_authenticated and request.user.delete_permanently_at %} +
+
+ +

+ Your account is scheduled for deletion at + {{ request.user.delete_permanently_at|date:'N j, Y, P e' }} +

+

Cancel deletion

+

Delete now

+
+
+ {% endif %}
{% block content_wrapper %} diff --git a/templates/users/cancel_deletion.html b/templates/users/cancel_deletion.html new file mode 100644 index 00000000..b3549f96 --- /dev/null +++ b/templates/users/cancel_deletion.html @@ -0,0 +1,20 @@ +{% extends "users/profile_base.html" %} + +{% load i18n %} + +{% block content %} +
+

{% trans "Cancel scheduled account deletion" %}

+
+ {% csrf_token %} + {% for field in form %} +
+ {% include "includes/_form_input.html" %} +
+ {% endfor %} + +
+
+{% endblock content %} diff --git a/templates/users/delete.html b/templates/users/delete.html index 2a020653..403afef8 100644 --- a/templates/users/delete.html +++ b/templates/users/delete.html @@ -4,10 +4,19 @@ {% block content %}
-

{% trans "Delete Account" %}

+

{% trans "Delete Account" %}

{% csrf_token %} {% for field in form %} +

+ {% 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 %} +

{% include "includes/_form_input.html" %}
@@ -15,7 +24,7 @@ - Cancel + {% trans 'Cancel' %}
{% endblock content %} diff --git a/templates/users/delete_immediately.html b/templates/users/delete_immediately.html new file mode 100644 index 00000000..c5d8b20a --- /dev/null +++ b/templates/users/delete_immediately.html @@ -0,0 +1,21 @@ +{% extends "users/profile_base.html" %} + +{% load i18n %} + +{% block content %} +
+

{% trans "Delete Account Immediately" %}

+
+ {% csrf_token %} + {% for field in form %} +
+ {% include "includes/_form_input.html" %} +
+ {% endfor %} + + {% trans 'Cancel' %} +
+
+{% endblock content %} diff --git a/templates/users/profile.html b/templates/users/profile.html index d247b48c..9ab1b540 100644 --- a/templates/users/profile.html +++ b/templates/users/profile.html @@ -114,7 +114,17 @@ {% endif %}

{% trans "Delete Account" %}

+ {% if user.delete_permanently_at %} +

+ {% blocktrans with at=request.user.delete_permanently_at|date:'N j, Y, P e' trimmed %} + Your account is scheduled for deletion at {{ at }} + {% endblocktrans %} +

+

{% trans 'Cancel deletion' %}

+

{% trans 'Delete now' %}

+ {% else %} {% trans 'Delete account' %} + {% endif %}
diff --git a/users/migrations/0015_user_delete_permanently_at.py b/users/migrations/0015_user_delete_permanently_at.py new file mode 100644 index 00000000..9166df48 --- /dev/null +++ b/users/migrations/0015_user_delete_permanently_at.py @@ -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), + ), + ] diff --git a/users/models.py b/users/models.py index 14885d30..4c51d410 100644 --- a/users/models.py +++ b/users/models.py @@ -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() diff --git a/users/tasks.py b/users/tasks.py index 0adb9416..47b5c68c 100644 --- a/users/tasks.py +++ b/users/tasks.py @@ -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], + ) diff --git a/users/views.py b/users/views.py index 16340ca6..e804f133 100644 --- a/users/views.py +++ b/users/views.py @@ -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)