Skip to content

Commit f787d93

Browse files
stsewdCopilot
andauthored
Delete project and organization objects asynchronously (#12541)
Closes #10040 --------- Co-authored-by: Copilot <[email protected]>
1 parent 927a661 commit f787d93

File tree

5 files changed

+73
-6
lines changed

5 files changed

+73
-6
lines changed

readthedocs/core/history.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,25 @@
1212
log = structlog.get_logger(__name__)
1313

1414

15-
def set_change_reason(instance, reason):
15+
def set_change_reason(instance, reason, user=None):
1616
"""
1717
Set the change reason for the historical record created from the instance.
1818
1919
This method should be called before calling ``save()`` or ``delete``.
2020
It sets `reason` to the `_change_reason` attribute of the instance,
2121
that's used to create the historical record on the save/delete signals.
2222
23-
https://django-simple-history.readthedocs.io/en/latest/historical_model.html#change-reason # noqa
23+
`user` is useful to track who made the change, this is only needed
24+
if this method is called outside of a request context,
25+
as the middleware already sets the user from the request.
26+
27+
See:
28+
- https://django-simple-history.readthedocs.io/en/latest/historical_model.html#change-reason
29+
- https://django-simple-history.readthedocs.io/en/latest/user_tracking.html
2430
"""
2531
instance._change_reason = reason
32+
if user:
33+
instance._history_user = user
2634

2735

2836
def safe_update_change_reason(instance, reason):

readthedocs/core/mixins.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

33
from django.contrib import messages
44
from django.contrib.auth.mixins import LoginRequiredMixin
5+
from django.http import HttpResponseRedirect
56
from vanilla import DeleteView
67
from vanilla import ListView
78

9+
from readthedocs.core.tasks import delete_object
810
from readthedocs.proxito.cache import cache_response
911
from readthedocs.proxito.cache import private_response
1012

@@ -82,3 +84,19 @@ def post(self, request, *args, **kwargs):
8284
if resp.status_code == 302 and self.success_message:
8385
messages.success(self.request, self.success_message)
8486
return resp
87+
88+
89+
class AsyncDeleteViewWithMessage(DeleteView):
90+
"""Delete view that shows a message after queuing an object for deletion."""
91+
92+
success_message = None
93+
94+
def post(self, request, *args, **kwargs):
95+
self.object = self.get_object()
96+
delete_object.delay(
97+
model_name=self.object._meta.label,
98+
pk=self.object.pk,
99+
user_id=request.user.pk,
100+
)
101+
messages.success(request, self.success_message)
102+
return HttpResponseRedirect(self.get_success_url())

readthedocs/core/tasks.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,13 @@
44

55
import redis
66
import structlog
7+
from django.apps import apps
78
from django.conf import settings
9+
from django.contrib.auth.models import User
810
from django.core.mail import EmailMultiAlternatives
911

12+
from readthedocs.builds.utils import memcache_lock
13+
from readthedocs.core.history import set_change_reason
1014
from readthedocs.worker import app
1115

1216

@@ -71,3 +75,38 @@ def cleanup_pidbox_keys():
7175
client.delete(key)
7276

7377
log.info("Redis pidbox objects.", memory=total_memory, keys=len(keys))
78+
79+
80+
@app.task(queue="web", bind=True)
81+
def delete_object(self, model_name: str, pk: int, user_id: int | None = None):
82+
"""
83+
Delete an object from the database asynchronously.
84+
85+
This is useful for deleting large objects that may take time
86+
to delete, without timing out the request.
87+
88+
:param model_name: The model name in the format 'app_label.ModelName'.
89+
:param pk: The primary key of the object to delete.
90+
:param user_id: The ID of the user performing the deletion.
91+
Just for logging purposes.
92+
"""
93+
task_log = log.bind(model_name=model_name, object_pk=pk, user_id=user_id)
94+
lock_id = f"{self.name}-{model_name}-{pk}-lock"
95+
lock_expire = 60 * 60 * 2 # 2 hours
96+
with memcache_lock(
97+
lock_id=lock_id, lock_expire=lock_expire, app_identifier=self.app.oid
98+
) as acquired:
99+
if not acquired:
100+
task_log.info("Object is already being deleted.")
101+
return
102+
103+
user = User.objects.filter(pk=user_id).first() if user_id else None
104+
Model = apps.get_model(model_name)
105+
obj = Model.objects.filter(pk=pk).first()
106+
if obj:
107+
task_log.info("Deleting object.")
108+
set_change_reason(obj, reason="Object deleted asynchronously", user=user)
109+
obj.delete()
110+
task_log.info("Object deleted.")
111+
else:
112+
task_log.info("Object does not exist.")

readthedocs/organizations/views/private.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from readthedocs.audit.models import AuditLog
2020
from readthedocs.core.filters import FilterContextMixin
2121
from readthedocs.core.history import UpdateChangeReasonPostView
22+
from readthedocs.core.mixins import AsyncDeleteViewWithMessage
2223
from readthedocs.core.mixins import DeleteViewWithMessage
2324
from readthedocs.core.mixins import PrivateViewMixin
2425
from readthedocs.invitations.models import Invitation
@@ -119,10 +120,10 @@ class DeleteOrganization(
119120
PrivateViewMixin,
120121
UpdateChangeReasonPostView,
121122
OrganizationView,
122-
DeleteViewWithMessage,
123+
AsyncDeleteViewWithMessage,
123124
):
124125
http_method_names = ["post"]
125-
success_message = _("Organization deleted")
126+
success_message = _("Organization queued for deletion")
126127

127128
def get_success_url(self):
128129
return reverse_lazy("organization_list")

readthedocs/projects/views/private.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from readthedocs.builds.models import VersionAutomationRule
3838
from readthedocs.core.filters import FilterContextMixin
3939
from readthedocs.core.history import UpdateChangeReasonPostView
40+
from readthedocs.core.mixins import AsyncDeleteViewWithMessage
4041
from readthedocs.core.mixins import DeleteViewWithMessage
4142
from readthedocs.core.mixins import ListViewWithForm
4243
from readthedocs.core.mixins import PrivateViewMixin
@@ -192,8 +193,8 @@ def get_form(self, data=None, files=None, **kwargs):
192193
return super().get_form(data, files, **kwargs)
193194

194195

195-
class ProjectDelete(UpdateChangeReasonPostView, ProjectMixin, DeleteViewWithMessage):
196-
success_message = _("Project deleted")
196+
class ProjectDelete(UpdateChangeReasonPostView, ProjectMixin, AsyncDeleteViewWithMessage):
197+
success_message = _("Project queued for deletion")
197198
template_name = "projects/project_delete.html"
198199

199200
def get_context_data(self, **kwargs):

0 commit comments

Comments
 (0)