"""`python manage.py process_submissions` -- implements plan.md §7.5. Drains one batch of submissions stuck in `processing`: validates each row's STL (uploads) or URL (printables / makerworld / thingiverse) and transitions to `verifying` on success or `rejected` on failure. Runs on a 30-second loop from the `web` container's entrypoint (plan.md §10) -- one invocation of this command per tick. Concurrency: `select_for_update(skip_locked=True)` keeps replicas / stray cron ticks / a re-entrant restart from grabbing the same row. On SQLite the locks are no-ops (dev only); Postgres in prod gets the non-blocking lock semantics the design assumes. """ from __future__ import annotations from django.core.management.base import BaseCommand from django.db import transaction from django.utils import timezone from apps.submissions.emails import send_rejection_email, send_verifying_email from apps.submissions.models import Submission from apps.submissions.validation import ( ValidationError, validate_external_url, validate_stl_file, ) BATCH = 50 class Command(BaseCommand): help = ( "Drain submissions stuck in `processing` -- one batch per invocation. " "Designed to be called on a loop from the web container entrypoint; " "safe to also call ad-hoc for debugging." ) def add_arguments(self, parser) -> None: parser.add_argument( "--batch", type=int, default=BATCH, help=f"Max rows to process per invocation (default: {BATCH}).", ) def handle(self, *args, batch: int, **opts) -> None: with transaction.atomic(): # `list(...)` materialises the slice inside the transaction so # subsequent `.save()` calls don't invalidate the queryset. queue = list( Submission.objects .select_for_update(skip_locked=True) .filter(status=Submission.Status.PROCESSING) .order_by("updated_at")[:batch] ) if not queue: return for sub in queue: try: if sub.source_type == Submission.SourceType.UPLOAD: validate_stl_file(sub.stl_file.path) else: validate_external_url(sub.source_url, sub.source_type) except ValidationError as exc: sub.status = Submission.Status.REJECTED sub.operator_notes = f"Automatic rejection: {exc}" sub.closed_at = timezone.now() # closed_by stays NULL -- the validator did the rejecting, # not an operator (plan.md §5 / §7.3). sub.save() send_rejection_email(sub, previous_status="processing") self.stdout.write(f"rejected {sub.slug}: {exc}") else: sub.status = Submission.Status.VERIFYING sub.save() send_verifying_email(sub) self.stdout.write(f"verifying {sub.slug}")