from django.db import models from django.utils.translation import gettext_lazy as _ import uuid from accounts.models import CustomUser from events.models import Event from events.scoping import EventScopedManager from games.models import EventGame class OCRStatus(models.TextChoices): TO_PROCESS = "to_process", _("To Process") PROCESSING = "processing", _("Processing") PROCESSED = "processed", _("Processed") ERROR = "error", _("Error processing") def submission_file_upload_path(instance: "SubmissionFile", filename: str): ext = filename.split(".")[-1] if "." in filename else "" new_filename = f"{instance.id}_{filename}" if ext else str(instance.id) user = instance.submission.user.username game_path = instance.submission.scope.event_game.game.path event_path = instance.submission.scope.event_game.event.path mid = ( instance.submission.scope.external_id if instance.submission.scope.external_id else instance.submission.scope.name ) return f"{event_path.lstrip('/')}/{game_path.lstrip('/')}/{mid}/{user}/{instance.submission.id}/{new_filename}" class BaseModel(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) # Timestamps created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: abstract = True class Scope(BaseModel): """A "match" that submissions can be scored against. Scopes are either created manually (admin/CLI) or auto-synced from the legacy PolyLAN AAndT API (see submissions.services.aandt_sync), in which case `external_id` holds the remote match id for idempotent upserts. """ event_game = models.ForeignKey( EventGame, on_delete=models.CASCADE, related_name="scopes", ) external_id = models.PositiveIntegerField( null=True, blank=True, help_text="Legacy AAndT match id, set when synced automatically.", ) name = models.CharField(max_length=255) assigned_users = models.ManyToManyField( CustomUser, blank=True, related_name="scopes", help_text="Users allowed to submit scores for this scope.", ) is_active = models.BooleanField( default=True, help_text="Whether submissions are currently accepted for this scope.", ) synced_at = models.DateTimeField( null=True, blank=True, help_text="Last time this scope was synced from the AAndT API.", ) class Meta: ordering = ["-created_at"] constraints = [ models.UniqueConstraint( fields=["event_game", "external_id"], condition=models.Q(external_id__isnull=False), name="unique_scope_external_id_per_event_game", ), ] def __str__(self) -> str: return f"{self.name} ({self.event_game})" class Submission(BaseModel): event = models.ForeignKey( Event, on_delete=models.CASCADE, related_name="submissions", ) scope = models.ForeignKey( Scope, on_delete=models.CASCADE, related_name="submissions", help_text="The match this submission's score is for.", ) user = models.ForeignKey( CustomUser, on_delete=models.CASCADE, null=True, blank=True, help_text="User who made the submission (null for anonymous)", related_name="user_submissions", ) # Submission metadata notes = models.TextField( null=True, blank=True, help_text="Optional notes about the submission", ) objects = models.Manager() for_event = EventScopedManager() class SubmissionFile(BaseModel): submission = models.ForeignKey( Submission, on_delete=models.CASCADE, related_name="files", ) file = models.FileField( upload_to=submission_file_upload_path, help_text=_("Uploaded file"), ) original_filename = models.CharField( max_length=255, help_text=_("Original filename as uploaded by user"), ) file_size = models.PositiveIntegerField(help_text=_("File size in bytes")) content_type = models.CharField( max_length=100, help_text=_("MIME type of the file"), ) # OCR metadata ocr_status = models.CharField( max_length=20, choices=OCRStatus.choices, default=OCRStatus.TO_PROCESS, help_text=_("OCR processing status for the entire file"), ) ocr_raw_data = models.TextField( blank=True, help_text=_("Raw OCR data for the entire file"), ) class Meta: ordering = ["file", "created_at"] verbose_name = "Submission File" verbose_name_plural = "Submission Files" @property def file_url(self) -> str | None: if self.file: return self.file.url def save(self, *args, **kwargs): if self.file and not self.file_size: self.file_size = self.file.size super().save(*args, **kwargs) class ImageCrop(BaseModel): submission_file = models.ForeignKey( SubmissionFile, on_delete=models.CASCADE, related_name="crops", ) # Crop coordinates (pixels): x1, y1, x2, y2 x1 = models.PositiveIntegerField(help_text=_("Top-left x coordinate")) y1 = models.PositiveIntegerField(help_text=_("Top-left y coordinate")) x2 = models.PositiveIntegerField(help_text=_("Bottom-right x coordinate")) y2 = models.PositiveIntegerField(help_text=_("Bottom-right y coordinate")) # Label/name for the crop label = models.CharField( max_length=255, blank=True, help_text=_("Optional label for this crop region"), ) # OCR results for this crop ocr_status = models.CharField( max_length=20, choices=OCRStatus.choices, default=OCRStatus.TO_PROCESS, help_text=_("OCR processing status for this crop"), ) ocr_raw_data = models.TextField( blank=True, help_text=_("Raw OCR data for this crop"), ) class Meta: ordering = ["created_at"] verbose_name = "Image Crop" verbose_name_plural = "Image Crops" def __str__(self): return f"{self.label or f'Crop ({self.x1}, {self.y1}, {self.x2}, {self.y2})'}"