mirror of
https://github.com/boostorg/website-v2.git
synced 2026-01-19 04:42:17 +00:00
10-day grace period for user deletion (#1445)
This commit is contained in:
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
20
templates/users/cancel_deletion.html
Normal file
20
templates/users/cancel_deletion.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
21
templates/users/delete_immediately.html
Normal file
21
templates/users/delete_immediately.html
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
18
users/migrations/0015_user_delete_permanently_at.py
Normal file
18
users/migrations/0015_user_delete_permanently_at.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user