from typing import Self from django.db import models from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError import uuid from django.db.models.expressions import Window from django.db.models.functions import Cast, Rank, RowNumber from django.db.models.query import F User = get_user_model() class JsonIndex(models.Func): function = "" template = "%(json_field)s -> (%(index)s::int)" def __init__(self, json_field, index_expression, **extra): super().__init__(json_field, index_expression, **extra) self.output_field = models.IntegerField() def resolve_expression( self, query, allow_joins=True, reuse=None, summarize=False, for_save=False ): # Resolve both expressions in the query context to ensure joins are set up correctly clone = self.copy() clone.source_expressions = [ expr.resolve_expression(query, allow_joins, reuse, summarize, for_save) for expr in self.source_expressions ] return clone def as_sql(self, compiler, connection): json_sql, json_params = compiler.compile(self.source_expressions[0]) idx_sql, idx_params = compiler.compile(self.source_expressions[1]) sql = self.template % {"json_field": json_sql, "index": idx_sql} params = json_params + idx_params return sql, params class SteamAPIKey(models.Model): """Model to store Steam API key configuration - Admin only""" name = models.CharField( max_length=100, unique=True, help_text="Descriptive name for this API key (e.g., 'Production Key', 'Development Key')", ) api_key = models.CharField( max_length=64, help_text="Steam Web API key from https://steamcommunity.com/dev/apikey", ) is_active = models.BooleanField( default=True, help_text="Whether this API key should be used" ) description = models.TextField( blank=True, help_text="Optional description or notes about this API key" ) # Metadata created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) last_used = models.DateTimeField( null=True, blank=True, help_text="When this API key was last used" ) class Meta: verbose_name = "Steam API Key" verbose_name_plural = "Steam API Keys" ordering = ["-is_active", "name"] def __str__(self): status = "Active" if self.is_active else "Inactive" return f"{self.name} ({status})" def clean(self): """Validate the API key format""" if self.api_key: # Steam API keys are typically 32 characters of hexadecimal if len(self.api_key) != 32: raise ValidationError("Steam API key should be 32 characters long") # Check if it's hexadecimal try: int(self.api_key, 16) except ValueError: raise ValidationError( "Steam API key should contain only hexadecimal characters (0-9, A-F)" ) def save(self, *args, **kwargs): self.full_clean() super().save(*args, **kwargs) @classmethod def get_active_key(cls): """Get the currently active API key""" return cls.objects.filter(is_active=True).first() @property def masked_key(self): """Return a masked version of the API key for display""" if not self.api_key: return "" return f"{self.api_key[:8]}{'*' * 16}{self.api_key[-8:]}" class SteamCollection(models.Model): """Model representing a Steam Workshop collection""" # Basic collection info steam_id = models.CharField( max_length=50, unique=True, help_text="Steam collection ID from URL" ) url = models.URLField(help_text="Full Steam Workshop collection URL") title = models.CharField(max_length=255, blank=True, help_text="Collection title") description = models.TextField(blank=True, help_text="Collection description") # Author information author_name = models.CharField( max_length=100, blank=True, help_text="Steam username of collection creator" ) author_steam_id = models.CharField( max_length=50, blank=True, help_text="Steam ID of collection creator" ) # Collection metadata total_items = models.PositiveIntegerField( default=0, help_text="Number of items in collection" ) unique_visitors = models.PositiveIntegerField( default=0, help_text="Number of unique visitors" ) current_favorites = models.PositiveIntegerField( default=0, help_text="Current number of favorites" ) total_favorites = models.PositiveIntegerField( default=0, help_text="Total unique favorites" ) # Timestamps steam_created_date = models.DateTimeField( null=True, blank=True, help_text="When collection was created on Steam" ) steam_updated_date = models.DateTimeField( null=True, blank=True, help_text="When collection was last updated on Steam" ) # Local tracking created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) last_fetched = models.DateTimeField( null=True, blank=True, help_text="When data was last fetched from Steam" ) # Status is_active = models.BooleanField( default=True, help_text="Whether this collection is actively tracked" ) fetch_error = models.TextField( blank=True, help_text="Last error encountered when fetching data" ) class Meta: ordering = ["-created_at"] verbose_name = "Steam Collection" verbose_name_plural = "Steam Collections" def __str__(self): return f"{self.title or f'Collection {self.steam_id}'}" @property def steam_url(self): """Generate the Steam Workshop URL from steam_id""" return f"https://steamcommunity.com/workshop/filedetails/?id={self.steam_id}" class SteamCollectionItem(models.Model): """Model representing individual items within a Steam collection""" # Relationships collection = models.ForeignKey( SteamCollection, on_delete=models.CASCADE, related_name="items" ) # Item identification steam_item_id = models.CharField(max_length=50, help_text="Steam Workshop item ID") title = models.CharField(max_length=255, blank=True, help_text="Item title") # Author information author_name = models.CharField( max_length=100, blank=True, help_text="Steam username of item creator" ) author_steam_id = models.CharField( max_length=50, blank=True, help_text="Steam ID of item creator" ) # Item metadata description = models.TextField(blank=True, help_text="Item description") tags = models.JSONField( default=list, blank=True, help_text="Item tags as JSON array" ) # Position in collection order_index = models.PositiveIntegerField( default=0, help_text="Order of item in collection" ) # Timestamps created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) # Puzzle points points_factor = models.ForeignKey( "animations.PuzzlePointsFactor", null=True, on_delete=models.SET_NULL, ) points_value = models.ForeignKey( "animations.PuzzlePointsValue", null=True, on_delete=models.SET_NULL, ) class Meta: ordering = ["collection", "order_index"] unique_together = ["collection", "steam_item_id"] verbose_name = "Steam Collection Item" verbose_name_plural = "Steam Collection Items" def __str__(self): return f"{self.title or f'Item {self.steam_item_id}'} (in {self.collection})" @property def steam_url(self): """Generate the Steam Workshop URL for this item""" return ( f"https://steamcommunity.com/workshop/filedetails/?id={self.steam_item_id}" ) def submission_file_upload_path(instance, filename): """Generate upload path for submission files""" # Create path: submissions/{submission_id}/{uuid}_{filename} ext = filename.split(".")[-1] if "." in filename else "" new_filename = f"{uuid.uuid4()}_{filename}" if ext else str(uuid.uuid4()) return f"submissions/{instance.response.submission.id}/{new_filename}" class Submission(models.Model): """Model representing a submission containing multiple puzzle responses""" # Identification id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) # User information (optional for anonymous submissions) user = models.ForeignKey( User, on_delete=models.CASCADE, null=True, blank=True, help_text="User who made the submission (null for anonymous)", ) # Submission metadata notes = models.TextField( null=True, blank=True, help_text="Optional notes about the submission", ) # Status tracking is_validated = models.BooleanField( default=False, help_text="Whether this submission has been manually validated" ) validated_by = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, blank=True, related_name="validated_submissions", help_text="Admin user who validated this submission", ) validated_at = models.DateTimeField( null=True, blank=True, help_text="When this submission was validated" ) # Manual validation request manual_validation_requested = models.BooleanField( default=False, help_text="Whether the user specifically requested manual validation", ) # Timestamps created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["-created_at"] verbose_name = "Submission" verbose_name_plural = "Submissions" def __str__(self): user_info = f"by {self.user.username}" if self.user else "anonymous" return f"Submission {self.id} {user_info}" @property def total_responses(self): """Get total number of puzzle responses in this submission""" return self.responses.count() @property def needs_validation(self): """Check if any response needs manual validation""" return self.responses.filter(needs_manual_validation=True).exists() class PuzzleResponseQuerySet(models.QuerySet): def annotate_rank_points(self) -> Self: return ( self.annotate( points=F("puzzle__points_factor__cost") * F("validated_cost") + F("puzzle__points_factor__cycles") * F("validated_cycles") + F("puzzle__points_factor__area") * F("validated_area") ) .annotate( user_response_rank=Window( expression=RowNumber(), partition_by=[F("puzzle"), F("submission__user")], order_by=F("points").asc(), ) ) # .filter(user_response_rank=1) .annotate( puzzle_user_rank=Window( expression=Rank(), partition_by=[F("puzzle")], order_by=F("points").asc(), ) ) .annotate( rank_points=Cast( JsonIndex( F("puzzle__points_value__points"), Cast(F("puzzle_user_rank") - 1, models.IntegerField()), ), models.IntegerField(), ) ) ) def filter_user_best_response(self) -> Self: return self.annotate_rank_points().filter(user_response_rank=1) class PuzzleResponseManager(models.Manager.from_queryset(PuzzleResponseQuerySet)): pass class PuzzleResponse(models.Model): """Model representing a response/solution for a specific puzzle""" # Relationships submission = models.ForeignKey( Submission, on_delete=models.CASCADE, related_name="responses" ) puzzle = models.ForeignKey( SteamCollectionItem, on_delete=models.CASCADE, related_name="responses", help_text="The puzzle this response is for", ) # OCR extracted data puzzle_name = models.CharField( max_length=255, help_text="Puzzle name as detected by OCR" ) cost = models.IntegerField(blank=True, help_text="Cost value from OCR") cycles = models.IntegerField(blank=True, help_text="Cycles value from OCR") area = models.IntegerField(blank=True, help_text="Area value from OCR") # Validation flags needs_manual_validation = models.BooleanField( default=False, help_text="Whether OCR failed and manual validation is needed" ) ocr_confidence_cost = models.FloatField( null=True, blank=True, help_text="OCR confidence score for cost (0.0 to 1.0)" ) ocr_confidence_cycles = models.FloatField( null=True, blank=True, help_text="OCR confidence score for cycles (0.0 to 1.0)" ) ocr_confidence_area = models.FloatField( null=True, blank=True, help_text="OCR confidence score for area (0.0 to 1.0)" ) # Manual validation overrides validated_cost = models.IntegerField( null=True, blank=True, help_text="Manually validated cost value" ) validated_cycles = models.IntegerField( null=True, blank=True, help_text="Manually validated cycles value" ) validated_area = models.IntegerField( null=True, blank=True, help_text="Manually validated area value" ) # Timestamps created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) objects = PuzzleResponseManager() class Meta: ordering = ["submission", "puzzle__order_index"] verbose_name = "Puzzle Response" verbose_name_plural = "Puzzle Responses" _base_manager_name = "objects" def __str__(self): return f"Response for {self.puzzle_name} in {self.submission}" @property def final_cost(self): """Get the final cost value (validated if available, otherwise OCR)""" return self.validated_cost or self.cost @property def final_cycles(self): """Get the final cycles value (validated if available, otherwise OCR)""" return self.validated_cycles or self.cycles @property def final_area(self): """Get the final area value (validated if available, otherwise OCR)""" return self.validated_area or self.area def mark_for_validation(self, reason="OCR failed"): """Mark this response as needing manual validation""" self.needs_manual_validation = True self.save(update_fields=["needs_manual_validation"]) class SubmissionFile(models.Model): """Model representing files uploaded with a puzzle response""" # Relationships response = models.ForeignKey( PuzzleResponse, on_delete=models.CASCADE, related_name="files" ) # File information file = models.FileField( upload_to=submission_file_upload_path, help_text="Uploaded file (image/gif)" ) 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_processed = models.BooleanField( default=False, help_text="Whether OCR has been processed for this file" ) ocr_raw_data = models.JSONField( null=True, blank=True, help_text="Raw OCR data as JSON" ) ocr_error = models.TextField(blank=True, help_text="OCR processing error message") # Timestamps created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["response", "created_at"] verbose_name = "Submission File" verbose_name_plural = "Submission Files" def __str__(self): return f"{self.original_filename} for {self.response}" @property def file_url(self): """Get the URL for the uploaded file""" if self.file: return self.file.url return None def save(self, *args, **kwargs): # Set file metadata on save if self.file and not self.file_size: self.file_size = self.file.size super().save(*args, **kwargs)