Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions readthedocs/core/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,25 @@
log = structlog.get_logger(__name__)


def set_change_reason(instance, reason):
def set_change_reason(instance, reason, user=None):
"""
Set the change reason for the historical record created from the instance.

This method should be called before calling ``save()`` or ``delete``.
It sets `reason` to the `_change_reason` attribute of the instance,
that's used to create the historical record on the save/delete signals.

https://django-simple-history.readthedocs.io/en/latest/historical_model.html#change-reason # noqa
`user` is useful to track who made the change, this is only needed
if this method is called outside of a request context,
as the middleware already sets the user from the request.

See:
- https://django-simple-history.readthedocs.io/en/latest/historical_model.html#change-reason
- https://django-simple-history.readthedocs.io/en/latest/user_tracking.html
"""
instance._change_reason = reason
if user:
instance._history_user = user


def safe_update_change_reason(instance, reason):
Expand Down
18 changes: 18 additions & 0 deletions readthedocs/core/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponseRedirect
from vanilla import DeleteView
from vanilla import ListView

from readthedocs.core.tasks import delete_object
from readthedocs.proxito.cache import cache_response
from readthedocs.proxito.cache import private_response

Expand Down Expand Up @@ -82,3 +84,19 @@ def post(self, request, *args, **kwargs):
if resp.status_code == 302 and self.success_message:
messages.success(self.request, self.success_message)
return resp


class AsyncDeleteViewWithMessage(DeleteView):
"""Delete view that shows a message after queuing an object for deletion."""

success_message = None

def post(self, request, *args, **kwargs):
self.object = self.get_object()
delete_object.delay(
model_name=self.object._meta.label,
pk=self.object.pk,
user_id=request.user.pk,
)
messages.success(request, self.success_message)
return HttpResponseRedirect(self.get_success_url())
39 changes: 39 additions & 0 deletions readthedocs/core/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@

import redis
import structlog
from django.apps import apps
from django.conf import settings
from django.contrib.auth.models import User
from django.core.mail import EmailMultiAlternatives

from readthedocs.builds.utils import memcache_lock
from readthedocs.core.history import set_change_reason
from readthedocs.worker import app


Expand Down Expand Up @@ -71,3 +75,38 @@ def cleanup_pidbox_keys():
client.delete(key)

log.info("Redis pidbox objects.", memory=total_memory, keys=len(keys))


@app.task(queue="web", bind=True)
def delete_object(self, model_name: str, pk: int, user_id: int | None = None):
"""
Delete an object from the database asynchronously.

This is useful for deleting large objects that may take time
to delete, without timing out the request.

:param model_name: The model name in the format 'app_label.ModelName'.
:param pk: The primary key of the object to delete.
:param user_id: The ID of the user performing the deletion.
Just for logging purposes.
"""
task_log = log.bind(model_name=model_name, object_pk=pk, user_id=user_id)
lock_id = f"{self.name}-{model_name}-{pk}-lock"
lock_expire = 60 * 60 * 2 # 2 hours
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe one hour is fine? or less...

with memcache_lock(
lock_id=lock_id, lock_expire=lock_expire, app_identifier=self.app.oid
) as acquired:
if not acquired:
task_log.info("Object is already being deleted.")
return

user = User.objects.filter(pk=user_id).first() if user_id else None
Model = apps.get_model(model_name)
obj = Model.objects.filter(pk=pk).first()
if obj:
task_log.info("Deleting object.")
set_change_reason(obj, reason="Object deleted asynchronously", user=user)
obj.delete()
task_log.info("Object deleted.")
else:
task_log.info("Object does not exist.")
5 changes: 3 additions & 2 deletions readthedocs/organizations/views/private.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from readthedocs.audit.models import AuditLog
from readthedocs.core.filters import FilterContextMixin
from readthedocs.core.history import UpdateChangeReasonPostView
from readthedocs.core.mixins import AsyncDeleteViewWithMessage
from readthedocs.core.mixins import DeleteViewWithMessage
from readthedocs.core.mixins import PrivateViewMixin
from readthedocs.invitations.models import Invitation
Expand Down Expand Up @@ -119,10 +120,10 @@ class DeleteOrganization(
PrivateViewMixin,
UpdateChangeReasonPostView,
OrganizationView,
DeleteViewWithMessage,
AsyncDeleteViewWithMessage,
):
http_method_names = ["post"]
success_message = _("Organization deleted")
success_message = _("Organization queued for deletion")

def get_success_url(self):
return reverse_lazy("organization_list")
Expand Down
5 changes: 3 additions & 2 deletions readthedocs/projects/views/private.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from readthedocs.builds.models import VersionAutomationRule
from readthedocs.core.filters import FilterContextMixin
from readthedocs.core.history import UpdateChangeReasonPostView
from readthedocs.core.mixins import AsyncDeleteViewWithMessage
from readthedocs.core.mixins import DeleteViewWithMessage
from readthedocs.core.mixins import ListViewWithForm
from readthedocs.core.mixins import PrivateViewMixin
Expand Down Expand Up @@ -191,8 +192,8 @@ def get_form(self, data=None, files=None, **kwargs):
return super().get_form(data, files, **kwargs)


class ProjectDelete(UpdateChangeReasonPostView, ProjectMixin, DeleteViewWithMessage):
success_message = _("Project deleted")
class ProjectDelete(UpdateChangeReasonPostView, ProjectMixin, AsyncDeleteViewWithMessage):
success_message = _("Project queued for deletion")
template_name = "projects/project_delete.html"

def get_context_data(self, **kwargs):
Expand Down