81 lines
3.1 KiB
Python
81 lines
3.1 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_status_update_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_status_update_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}")
|