Files
hamprint/apps/submissions/management/commands/process_submissions.py

83 lines
3.2 KiB
Python

"""`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_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).
# `Submission.save()` detects the `processing -> rejected`
# transition and queues `send_rejection_email()` via
# transaction.on_commit -- no explicit email call needed.
sub.save()
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}")