"""`python manage.py cleanup_stale` -- implements plan.md §7.6. Permanently deletes submissions that have been stuck in `identifying` (guest awaiting email confirmation) or `rejected` (auto- or operator- rejected) for longer than the TTL window (default 24 h). Both the DB row and any uploaded `.stl` file under `MEDIA_ROOT` are removed -- the file-on-disk side is handled by the `post_delete` signal in `apps/submissions/signals.py`. Designed to be invoked on a loop from a sidecar service (plan.md §10): while true; do python manage.py cleanup_stale; sleep 300; done """ from __future__ import annotations from datetime import timedelta from django.core.management.base import BaseCommand from django.db.models import Q from django.utils import timezone from apps.submissions.models import Submission class Command(BaseCommand): help = ( "Delete submissions stuck in `identifying` or `rejected` for more " "than --ttl-hours. Removes both the DB row and any uploaded STL." ) def add_arguments(self, parser) -> None: parser.add_argument( "--ttl-hours", type=int, default=24, help="How long a row can sit in `identifying` / `rejected` before " "being deleted. Default: 24, matching plan.md §7.6.", ) parser.add_argument( "--dry-run", action="store_true", help="Report what would be deleted but make no changes.", ) def handle(self, *args, ttl_hours: int, dry_run: bool, **opts) -> None: cutoff = timezone.now() - timedelta(hours=ttl_hours) # `identifying` rows are aged off `created_at` (the row hasn't moved # state since it was created). `rejected` rows are aged off # `closed_at` (the moment the operator or auto-validator closed them). stale = Submission.objects.filter( Q(status=Submission.Status.IDENTIFYING, created_at__lt=cutoff) | Q(status=Submission.Status.REJECTED, closed_at__lt=cutoff) ) if dry_run: count = stale.count() self.stdout.write( f"DRY RUN: would delete {count} stale submission(s) older than " f"{ttl_hours} h." ) for sub in stale: self.stdout.write(f" - {sub.slug} ({sub.status})") return # `QuerySet.delete()` fires `post_delete` per row, which is how the # uploaded `.stl` file gets unlinked from MEDIA_ROOT (see signals.py). total, _by_model = stale.delete() if total: self.stdout.write( self.style.SUCCESS( f"Deleted {total} stale submission(s) older than {ttl_hours} h." ) )