diff --git a/opus_submitter/API_USAGE.md b/opus_submitter/API_USAGE.md
new file mode 100644
index 0000000..b86a949
--- /dev/null
+++ b/opus_submitter/API_USAGE.md
@@ -0,0 +1,231 @@
+# Opus Magnum Submission API Usage
+
+## Overview
+
+The API is built with Django Ninja and provides endpoints for managing puzzle submissions with OCR validation and S3 file storage.
+
+## Base URL
+- Development: `http://localhost:8000/api/`
+- API Documentation: `http://localhost:8000/api/docs/`
+
+## Authentication
+Most endpoints support both authenticated and anonymous submissions. Admin endpoints require staff permissions.
+
+## Environment Variables
+
+### Required for S3 Storage
+```bash
+USE_S3=true
+AWS_ACCESS_KEY_ID=your_access_key
+AWS_SECRET_ACCESS_KEY=your_secret_key
+AWS_STORAGE_BUCKET_NAME=your_bucket_name
+AWS_S3_REGION_NAME=us-east-1
+```
+
+### Optional
+```bash
+STEAM_API_KEY=your_steam_api_key
+```
+
+## API Endpoints
+
+### 1. Get Available Puzzles
+```http
+GET /api/submissions/puzzles
+```
+
+Response:
+```json
+[
+ {
+ "id": 1,
+ "steam_item_id": "3479143948",
+ "title": "P41-FLOC",
+ "author_name": "Flame Legrems",
+ "description": "A challenging puzzle...",
+ "tags": ["puzzle", "chemistry"],
+ "order_index": 0,
+ "steam_url": "https://steamcommunity.com/workshop/filedetails/?id=3479143948",
+ "created_at": "2025-05-29T11:19:24Z",
+ "updated_at": "2025-05-30T22:15:09Z"
+ }
+]
+```
+
+### 2. Create Submission
+```http
+POST /api/submissions/submissions
+Content-Type: multipart/form-data
+```
+
+Form Data:
+- `data`: JSON with submission data
+- `files`: Array of uploaded files
+
+Example data:
+```json
+{
+ "notes": "My best solutions so far",
+ "responses": [
+ {
+ "puzzle_id": 1,
+ "puzzle_name": "P41-FLOC",
+ "cost": "150",
+ "cycles": "89",
+ "area": "12",
+ "needs_manual_validation": false,
+ "ocr_confidence_score": 0.95
+ }
+ ]
+}
+```
+
+Response:
+```json
+{
+ "id": "123e4567-e89b-12d3-a456-426614174000",
+ "user": null,
+ "notes": "My best solutions so far",
+ "responses": [
+ {
+ "id": 1,
+ "puzzle": 1,
+ "puzzle_name": "P41-FLOC",
+ "cost": "150",
+ "cycles": "89",
+ "area": "12",
+ "needs_manual_validation": false,
+ "files": [
+ {
+ "id": 1,
+ "original_filename": "solution.gif",
+ "file_size": 1024000,
+ "content_type": "image/gif",
+ "file_url": "https://bucket.s3.amazonaws.com/media/submissions/123.../file.gif",
+ "ocr_processed": false,
+ "created_at": "2025-10-29T00:00:00Z"
+ }
+ ],
+ "final_cost": "150",
+ "final_cycles": "89",
+ "final_area": "12"
+ }
+ ],
+ "total_responses": 1,
+ "needs_validation": false,
+ "is_validated": false,
+ "created_at": "2025-10-29T00:00:00Z"
+}
+```
+
+### 3. List Submissions
+```http
+GET /api/submissions/submissions?limit=20&offset=0
+```
+
+### 4. Get Submission Details
+```http
+GET /api/submissions/submissions/{submission_id}
+```
+
+### 5. Admin: Validate Response (Staff Only)
+```http
+PUT /api/submissions/responses/{response_id}/validate
+Content-Type: application/json
+```
+
+Body:
+```json
+{
+ "validated_cost": "150",
+ "validated_cycles": "89",
+ "validated_area": "12"
+}
+```
+
+### 6. Admin: List Responses Needing Validation (Staff Only)
+```http
+GET /api/submissions/responses/needs-validation
+```
+
+### 7. Admin: Validate Entire Submission (Staff Only)
+```http
+POST /api/submissions/submissions/{submission_id}/validate
+```
+
+### 8. Get Statistics
+```http
+GET /api/submissions/stats
+```
+
+Response:
+```json
+{
+ "total_submissions": 150,
+ "total_responses": 300,
+ "needs_validation": 25,
+ "validated_submissions": 120,
+ "validation_rate": 0.8
+}
+```
+
+## OCR Validation Logic
+
+The system automatically flags responses for manual validation when:
+
+1. **Incomplete OCR Data**: Missing cost, cycles, or area values
+2. **Low Confidence**: OCR confidence score below threshold
+3. **Manual Flag**: Explicitly marked by frontend OCR processing
+
+### Manual Validation Workflow
+
+1. Admin views responses needing validation: `GET /responses/needs-validation`
+2. Admin reviews the uploaded files and OCR results
+3. Admin provides corrected values: `PUT /responses/{id}/validate`
+4. System updates `validated_*` fields and clears validation flag
+5. Optional: Mark entire submission as validated: `POST /submissions/{id}/validate`
+
+## File Storage
+
+- **Development**: Files stored locally in `media/submissions/`
+- **Production**: Files stored in S3 with path structure: `submissions/{submission_id}/{uuid}_{filename}`
+- **Supported Formats**: JPEG, PNG, GIF, MP4, WebM
+- **Size Limit**: 10MB per file
+
+## Error Handling
+
+The API returns standard HTTP status codes:
+
+- `200`: Success
+- `400`: Bad Request (validation errors)
+- `401`: Unauthorized
+- `403`: Forbidden (admin required)
+- `404`: Not Found
+- `500`: Internal Server Error
+
+Error Response Format:
+```json
+{
+ "detail": "Error message",
+ "code": "error_code"
+}
+```
+
+## Frontend Integration
+
+The Vue frontend should:
+
+1. Upload files with OCR data extracted client-side
+2. Group files by detected puzzle name
+3. Create one submission with multiple responses
+4. Handle validation flags and display admin feedback
+5. Show file upload progress and S3 URLs
+
+## Admin Interface
+
+Django Admin provides:
+
+- **Submission Management**: View, validate, and manage submissions
+- **Response Validation**: Bulk actions for validation workflow
+- **File Management**: View uploaded files and OCR data
+- **Statistics Dashboard**: Track validation rates and submission metrics
diff --git a/opus_submitter/opus_submitter/api.py b/opus_submitter/opus_submitter/api.py
new file mode 100644
index 0000000..56c2ec1
--- /dev/null
+++ b/opus_submitter/opus_submitter/api.py
@@ -0,0 +1,41 @@
+from ninja import NinjaAPI
+from ninja.security import django_auth
+from submissions.api import router as submissions_router
+
+# Create the main API instance
+api = NinjaAPI(
+ title="Opus Magnum Submission API",
+ version="1.0.0",
+ description="API for managing Opus Magnum puzzle submissions",
+)
+
+# Add authentication for protected endpoints
+# api.auth = django_auth # Uncomment if you want global auth
+
+# Include the submissions router
+api.add_router("/submissions/", submissions_router, tags=["submissions"])
+
+
+# Health check endpoint
+@api.get("/health")
+def health_check(request):
+ """Health check endpoint"""
+ return {"status": "healthy", "service": "opus-magnum-api"}
+
+
+# API info endpoint
+@api.get("/info")
+def api_info(request):
+ """Get API information"""
+ return {
+ "name": "Opus Magnum Submission API",
+ "version": "1.0.0",
+ "description": "API for managing puzzle submissions with OCR validation",
+ "features": [
+ "Multi-puzzle submissions",
+ "File upload to S3",
+ "OCR validation tracking",
+ "Manual validation workflow",
+ "Admin validation tools",
+ ],
+ }
diff --git a/opus_submitter/opus_submitter/settings.py b/opus_submitter/opus_submitter/settings.py
index 764adf7..8426990 100644
--- a/opus_submitter/opus_submitter/settings.py
+++ b/opus_submitter/opus_submitter/settings.py
@@ -39,6 +39,7 @@ INSTALLED_APPS = [
"django.contrib.messages",
"django.contrib.staticfiles",
"django_vite",
+ "storages",
"accounts",
"submissions",
]
@@ -135,6 +136,46 @@ CAS_SERVER_URL = "https://polylan.ch/cas/"
# Steam API Configuration
STEAM_API_KEY = os.environ.get('STEAM_API_KEY', None) # Set via environment variable
+# File Upload Settings
+MEDIA_URL = '/media/'
+MEDIA_ROOT = BASE_DIR / 'media'
+
+# AWS S3 Configuration
+USE_S3 = os.environ.get('USE_S3', 'False').lower() == 'true'
+
+if USE_S3:
+ # AWS S3 settings
+ AWS_ACCESS_KEY_ID = os.environ.get('AWS_ACCESS_KEY_ID')
+ AWS_SECRET_ACCESS_KEY = os.environ.get('AWS_SECRET_ACCESS_KEY')
+ AWS_STORAGE_BUCKET_NAME = os.environ.get('AWS_STORAGE_BUCKET_NAME')
+ AWS_S3_REGION_NAME = os.environ.get('AWS_S3_REGION_NAME', 'us-east-1')
+ AWS_S3_CUSTOM_DOMAIN = f'{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com'
+ AWS_DEFAULT_ACL = None
+ AWS_S3_OBJECT_PARAMETERS = {
+ 'CacheControl': 'max-age=86400',
+ }
+
+ # S3 Static and Media settings
+ DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
+ STATICFILES_STORAGE = 'storages.backends.s3boto3.S3StaticStorage'
+
+ # Override media URL to use S3
+ MEDIA_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/media/'
+
+# File Upload Limits
+FILE_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 # 10MB
+DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 # 10MB
+
+# Allowed file types for submissions
+ALLOWED_SUBMISSION_TYPES = [
+ 'image/jpeg',
+ 'image/jpg',
+ 'image/png',
+ 'image/gif',
+ 'video/mp4',
+ 'video/webm'
+]
+
# Authentication backends
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
diff --git a/opus_submitter/opus_submitter/urls.py b/opus_submitter/opus_submitter/urls.py
index 4b31b4e..4251ffc 100644
--- a/opus_submitter/opus_submitter/urls.py
+++ b/opus_submitter/opus_submitter/urls.py
@@ -20,7 +20,10 @@ from django.http import HttpRequest
from django.shortcuts import render
from django.urls import path
from django.contrib.auth.decorators import login_required
+from django.conf import settings
+from django.conf.urls.static import static
from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView
+from .api import api
@login_required
@@ -32,5 +35,10 @@ urlpatterns = [
path("admin/", admin.site.urls),
path("cas/login/", SimpleCASLoginView.as_view(), name="cas_ng_login"),
path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"),
+ path("api/", api.urls),
path("", home, name="home"),
]
+
+# Serve media files in development
+if settings.DEBUG:
+ urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
diff --git a/opus_submitter/src/App.vue b/opus_submitter/src/App.vue
index 00e56d4..f33e4d9 100644
--- a/opus_submitter/src/App.vue
+++ b/opus_submitter/src/App.vue
@@ -169,15 +169,6 @@ const findPuzzleByName = (ocrPuzzleName: string): SteamCollectionItem | null =>
Opus Magnum Puzzle Submitter
-
-
-
@@ -200,18 +191,13 @@ const findPuzzleByName = (ocrPuzzleName: string): SteamCollectionItem | null =>
{{ collections[0].title }}
{{ collections[0].description }}
-
-
Total Puzzles
-
{{ collections[0].total_items }}
-
-
-
Author
-
{{ collections[0].author_name }}
-
-
-
Visitors
-
{{ collections[0].unique_visitors }}
-
+
diff --git a/opus_submitter/submissions/admin.py b/opus_submitter/submissions/admin.py
index 1a44181..fe87f17 100644
--- a/opus_submitter/submissions/admin.py
+++ b/opus_submitter/submissions/admin.py
@@ -1,6 +1,10 @@
from django.contrib import admin
from django.utils.html import format_html
-from .models import SteamAPIKey, SteamCollection, SteamCollectionItem
+from django.utils import timezone
+from .models import (
+ SteamAPIKey, SteamCollection, SteamCollectionItem,
+ Submission, PuzzleResponse, SubmissionFile
+)
@admin.register(SteamAPIKey)
@@ -141,3 +145,167 @@ class SteamCollectionItemAdmin(admin.ModelAdmin):
("Metadata", {"fields": ("tags",)}),
("Timestamps", {"fields": ("created_at", "updated_at")}),
)
+
+
+class SubmissionFileInline(admin.TabularInline):
+ model = SubmissionFile
+ extra = 0
+ readonly_fields = ["file_size", "content_type", "ocr_processed", "created_at"]
+ fields = ["file", "original_filename", "file_size", "content_type", "ocr_processed", "ocr_error"]
+
+
+class PuzzleResponseInline(admin.TabularInline):
+ model = PuzzleResponse
+ extra = 0
+ readonly_fields = ["created_at", "updated_at"]
+ fields = [
+ "puzzle", "puzzle_name", "cost", "cycles", "area",
+ "needs_manual_validation", "ocr_confidence_score"
+ ]
+
+
+@admin.register(Submission)
+class SubmissionAdmin(admin.ModelAdmin):
+ list_display = [
+ "id", "user", "total_responses", "needs_validation",
+ "is_validated", "created_at"
+ ]
+ list_filter = [
+ "is_validated", "created_at", "updated_at"
+ ]
+ search_fields = ["id", "user__username", "notes"]
+ readonly_fields = ["id", "created_at", "updated_at", "total_responses", "needs_validation"]
+ inlines = [PuzzleResponseInline]
+
+ fieldsets = (
+ ("Basic Information", {
+ "fields": ("id", "user", "notes")
+ }),
+ ("Validation", {
+ "fields": ("is_validated", "validated_by", "validated_at")
+ }),
+ ("Statistics", {
+ "fields": ("total_responses", "needs_validation"),
+ "classes": ("collapse",)
+ }),
+ ("Timestamps", {
+ "fields": ("created_at", "updated_at"),
+ "classes": ("collapse",)
+ }),
+ )
+
+ actions = ["mark_as_validated"]
+
+ def mark_as_validated(self, request, queryset):
+ """Mark selected submissions as validated"""
+ updated = 0
+ for submission in queryset:
+ if not submission.is_validated:
+ submission.is_validated = True
+ submission.validated_by = request.user
+ submission.validated_at = timezone.now()
+ submission.save()
+ # Also mark all responses as not needing validation
+ submission.responses.update(needs_manual_validation=False)
+ updated += 1
+
+ self.message_user(request, f"{updated} submissions marked as validated.")
+
+ mark_as_validated.short_description = "Mark selected submissions as validated"
+
+
+@admin.register(PuzzleResponse)
+class PuzzleResponseAdmin(admin.ModelAdmin):
+ list_display = [
+ "puzzle_name", "submission", "puzzle", "cost", "cycles", "area",
+ "needs_manual_validation", "created_at"
+ ]
+ list_filter = [
+ "needs_manual_validation", "puzzle__collection", "created_at"
+ ]
+ search_fields = [
+ "puzzle_name", "submission__id", "puzzle__title",
+ "cost", "cycles", "area"
+ ]
+ readonly_fields = ["created_at", "updated_at"]
+ inlines = [SubmissionFileInline]
+
+ fieldsets = (
+ ("Basic Information", {
+ "fields": ("submission", "puzzle", "puzzle_name")
+ }),
+ ("OCR Data", {
+ "fields": ("cost", "cycles", "area", "ocr_confidence_score")
+ }),
+ ("Validation", {
+ "fields": (
+ "needs_manual_validation",
+ "validated_cost", "validated_cycles", "validated_area"
+ )
+ }),
+ ("Timestamps", {
+ "fields": ("created_at", "updated_at"),
+ "classes": ("collapse",)
+ }),
+ )
+
+ actions = ["mark_for_validation", "clear_validation_flag"]
+
+ def mark_for_validation(self, request, queryset):
+ """Mark selected responses as needing validation"""
+ updated = queryset.update(needs_manual_validation=True)
+ self.message_user(request, f"{updated} responses marked for validation.")
+
+ def clear_validation_flag(self, request, queryset):
+ """Clear validation flag for selected responses"""
+ updated = queryset.update(needs_manual_validation=False)
+ self.message_user(request, f"{updated} responses cleared from validation queue.")
+
+ mark_for_validation.short_description = "Mark as needing validation"
+ clear_validation_flag.short_description = "Clear validation flag"
+
+
+@admin.register(SubmissionFile)
+class SubmissionFileAdmin(admin.ModelAdmin):
+ list_display = [
+ "original_filename", "response", "file_size_display",
+ "content_type", "ocr_processed", "created_at"
+ ]
+ list_filter = [
+ "content_type", "ocr_processed", "created_at"
+ ]
+ search_fields = [
+ "original_filename", "response__puzzle_name",
+ "response__submission__id"
+ ]
+ readonly_fields = [
+ "file_size", "content_type", "ocr_processed",
+ "created_at", "updated_at", "file_url"
+ ]
+
+ fieldsets = (
+ ("File Information", {
+ "fields": ("file", "original_filename", "file_size", "content_type", "file_url")
+ }),
+ ("OCR Processing", {
+ "fields": ("ocr_processed", "ocr_raw_data", "ocr_error")
+ }),
+ ("Relationships", {
+ "fields": ("response",)
+ }),
+ ("Timestamps", {
+ "fields": ("created_at", "updated_at"),
+ "classes": ("collapse",)
+ }),
+ )
+
+ def file_size_display(self, obj):
+ """Display file size in human readable format"""
+ if obj.file_size < 1024:
+ return f"{obj.file_size} B"
+ elif obj.file_size < 1024 * 1024:
+ return f"{obj.file_size / 1024:.1f} KB"
+ else:
+ return f"{obj.file_size / (1024 * 1024):.1f} MB"
+
+ file_size_display.short_description = "File Size"
diff --git a/opus_submitter/submissions/api.py b/opus_submitter/submissions/api.py
new file mode 100644
index 0000000..112139a
--- /dev/null
+++ b/opus_submitter/submissions/api.py
@@ -0,0 +1,249 @@
+from ninja import Router, File
+from ninja.files import UploadedFile
+from ninja.pagination import paginate
+from django.db import transaction
+from django.core.files.base import ContentFile
+from django.http import Http404
+from django.utils import timezone
+from typing import List
+
+from .models import Submission, PuzzleResponse, SubmissionFile, SteamCollectionItem
+from .schemas import (
+ SubmissionIn,
+ SubmissionOut,
+ SubmissionListOut,
+ PuzzleResponseOut,
+ ValidationIn,
+ SteamCollectionItemOut,
+)
+
+router = Router()
+
+
+@router.get("/puzzles", response=List[SteamCollectionItemOut])
+def list_puzzles(request):
+ """Get list of available puzzles"""
+ return SteamCollectionItem.objects.select_related("collection").all()
+
+
+@router.get("/submissions", response=List[SubmissionListOut])
+@paginate
+def list_submissions(request):
+ """Get paginated list of submissions"""
+ return Submission.objects.prefetch_related("responses").all()
+
+
+@router.get("/submissions/{submission_id}", response=SubmissionOut)
+def get_submission(request, submission_id: str):
+ """Get detailed submission by ID"""
+ try:
+ submission = Submission.objects.prefetch_related(
+ "responses__files", "responses__puzzle"
+ ).get(id=submission_id)
+ return submission
+ except Submission.DoesNotExist:
+ raise Http404("Submission not found")
+
+
+@router.post("/submissions", response=SubmissionOut)
+def create_submission(
+ request, data: SubmissionIn, files: List[UploadedFile] = File(...)
+):
+ """Create a new submission with multiple puzzle responses"""
+
+ # Validate that we have files
+ if not files:
+ return 400, {"detail": "At least one file is required"}
+
+ # Group files by puzzle (based on filename or order)
+ # For now, we'll assume files are provided in the same order as responses
+ if len(files) < len(data.responses):
+ return 400, {"detail": "Not enough files for all responses"}
+
+ try:
+ with transaction.atomic():
+ # Create the submission
+ submission = Submission.objects.create(
+ user=request.user if request.user.is_authenticated else None,
+ notes=data.notes,
+ )
+
+ file_index = 0
+ for response_data in data.responses:
+ # Get the puzzle
+ try:
+ puzzle = SteamCollectionItem.objects.get(id=response_data.puzzle_id)
+ except SteamCollectionItem.DoesNotExist:
+ return 400, {
+ "detail": f"Puzzle with id {response_data.puzzle_id} not found"
+ }
+
+ # Create the puzzle response
+ response = PuzzleResponse.objects.create(
+ submission=submission,
+ puzzle=puzzle,
+ puzzle_name=response_data.puzzle_name,
+ cost=response_data.cost,
+ cycles=response_data.cycles,
+ area=response_data.area,
+ needs_manual_validation=response_data.needs_manual_validation,
+ ocr_confidence_score=response_data.ocr_confidence_score,
+ )
+
+ # Process files for this response
+ # For simplicity, we'll take one file per response
+ # In a real implementation, you'd need better file-to-response mapping
+ if file_index < len(files):
+ uploaded_file = files[file_index]
+
+ # Validate file type
+ if not uploaded_file.content_type.startswith(("image/", "video/")):
+ return 400, {
+ "detail": f"Invalid file type: {uploaded_file.content_type}"
+ }
+
+ # Validate file size (10MB limit)
+ if uploaded_file.size > 10 * 1024 * 1024:
+ return 400, {"detail": "File too large (max 10MB)"}
+
+ # Create submission file
+ submission_file = SubmissionFile.objects.create(
+ response=response,
+ original_filename=uploaded_file.name,
+ file_size=uploaded_file.size,
+ content_type=uploaded_file.content_type,
+ )
+
+ # Save the file
+ submission_file.file.save(
+ uploaded_file.name, ContentFile(uploaded_file.read()), save=True
+ )
+
+ file_index += 1
+
+ # Check if OCR validation is needed
+ if not all(
+ [response_data.cost, response_data.cycles, response_data.area]
+ ):
+ response.mark_for_validation("Incomplete OCR data")
+
+ # Reload with relations for response
+ submission = Submission.objects.prefetch_related(
+ "responses__files", "responses__puzzle"
+ ).get(id=submission.id)
+
+ return submission
+
+ except Exception as e:
+ return 500, {"detail": f"Error creating submission: {str(e)}"}
+
+
+@router.put("/responses/{response_id}/validate", response=PuzzleResponseOut)
+def validate_response(request, response_id: int, data: ValidationIn):
+ """Manually validate a puzzle response"""
+
+ if not request.user.is_authenticated or not request.user.is_staff:
+ return 403, {"detail": "Admin access required"}
+
+ try:
+ response = PuzzleResponse.objects.select_related("puzzle").get(id=response_id)
+
+ # Update validated values
+ if data.validated_cost is not None:
+ response.validated_cost = data.validated_cost
+ if data.validated_cycles is not None:
+ response.validated_cycles = data.validated_cycles
+ if data.validated_area is not None:
+ response.validated_area = data.validated_area
+
+ # Mark as no longer needing validation if we have all values
+ if all([response.final_cost, response.final_cycles, response.final_area]):
+ response.needs_manual_validation = False
+
+ response.save()
+
+ return response
+
+ except PuzzleResponse.DoesNotExist:
+ raise Http404("Response not found")
+
+
+@router.get("/responses/needs-validation", response=List[PuzzleResponseOut])
+def list_responses_needing_validation(request):
+ """Get all responses that need manual validation"""
+
+ if not request.user.is_authenticated or not request.user.is_staff:
+ return 403, {"detail": "Admin access required"}
+
+ return (
+ PuzzleResponse.objects.filter(needs_manual_validation=True)
+ .select_related("puzzle", "submission")
+ .prefetch_related("files")
+ )
+
+
+@router.post("/submissions/{submission_id}/validate", response=SubmissionOut)
+def validate_submission(request, submission_id: str):
+ """Mark entire submission as validated"""
+
+ if not request.user.is_authenticated or not request.user.is_staff:
+ return 403, {"detail": "Admin access required"}
+
+ try:
+ submission = Submission.objects.get(id=submission_id)
+
+ submission.is_validated = True
+ submission.validated_by = request.user
+ submission.validated_at = timezone.now()
+ submission.save()
+
+ # Also mark all responses as not needing validation
+ submission.responses.update(needs_manual_validation=False)
+
+ # Reload with relations
+ submission = Submission.objects.prefetch_related(
+ "responses__files", "responses__puzzle"
+ ).get(id=submission.id)
+
+ return submission
+
+ except Submission.DoesNotExist:
+ raise Http404("Submission not found")
+
+
+@router.delete("/submissions/{submission_id}")
+def delete_submission(request, submission_id: str):
+ """Delete a submission (admin only)"""
+
+ if not request.user.is_authenticated or not request.user.is_staff:
+ return 403, {"detail": "Admin access required"}
+
+ try:
+ submission = Submission.objects.get(id=submission_id)
+ submission.delete()
+ return {"detail": "Submission deleted successfully"}
+
+ except Submission.DoesNotExist:
+ raise Http404("Submission not found")
+
+
+@router.get("/stats")
+def get_stats(request):
+ """Get submission statistics"""
+
+ total_submissions = Submission.objects.count()
+ total_responses = PuzzleResponse.objects.count()
+ needs_validation = PuzzleResponse.objects.filter(
+ needs_manual_validation=True
+ ).count()
+ validated_submissions = Submission.objects.filter(is_validated=True).count()
+
+ return {
+ "total_submissions": total_submissions,
+ "total_responses": total_responses,
+ "needs_validation": needs_validation,
+ "validated_submissions": validated_submissions,
+ "validation_rate": validated_submissions / total_submissions
+ if total_submissions > 0
+ else 0,
+ }
diff --git a/opus_submitter/submissions/migrations/0004_submission_puzzleresponse_submissionfile.py b/opus_submitter/submissions/migrations/0004_submission_puzzleresponse_submissionfile.py
new file mode 100644
index 0000000..a0579ef
--- /dev/null
+++ b/opus_submitter/submissions/migrations/0004_submission_puzzleresponse_submissionfile.py
@@ -0,0 +1,82 @@
+# Generated by Django 5.2.7 on 2025-10-29 01:32
+
+import django.db.models.deletion
+import submissions.models
+import uuid
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('submissions', '0003_steamapikey'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Submission',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('notes', models.TextField(blank=True, help_text='Optional notes about the submission')),
+ ('is_validated', models.BooleanField(default=False, help_text='Whether this submission has been manually validated')),
+ ('validated_at', models.DateTimeField(blank=True, help_text='When this submission was validated', null=True)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('user', models.ForeignKey(blank=True, help_text='User who made the submission (null for anonymous)', null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ('validated_by', models.ForeignKey(blank=True, help_text='Admin user who validated this submission', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='validated_submissions', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'verbose_name': 'Submission',
+ 'verbose_name_plural': 'Submissions',
+ 'ordering': ['-created_at'],
+ },
+ ),
+ migrations.CreateModel(
+ name='PuzzleResponse',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('puzzle_name', models.CharField(help_text='Puzzle name as detected by OCR', max_length=255)),
+ ('cost', models.CharField(blank=True, help_text='Cost value from OCR', max_length=20)),
+ ('cycles', models.CharField(blank=True, help_text='Cycles value from OCR', max_length=20)),
+ ('area', models.CharField(blank=True, help_text='Area value from OCR', max_length=20)),
+ ('needs_manual_validation', models.BooleanField(default=False, help_text='Whether OCR failed and manual validation is needed')),
+ ('ocr_confidence_score', models.FloatField(blank=True, help_text='OCR confidence score (0.0 to 1.0)', null=True)),
+ ('validated_cost', models.CharField(blank=True, help_text='Manually validated cost value', max_length=20)),
+ ('validated_cycles', models.CharField(blank=True, help_text='Manually validated cycles value', max_length=20)),
+ ('validated_area', models.CharField(blank=True, help_text='Manually validated area value', max_length=20)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('puzzle', models.ForeignKey(help_text='The puzzle this response is for', on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='submissions.steamcollectionitem')),
+ ('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='submissions.submission')),
+ ],
+ options={
+ 'verbose_name': 'Puzzle Response',
+ 'verbose_name_plural': 'Puzzle Responses',
+ 'ordering': ['submission', 'puzzle__order_index'],
+ 'unique_together': {('submission', 'puzzle')},
+ },
+ ),
+ migrations.CreateModel(
+ name='SubmissionFile',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('file', models.FileField(help_text='Uploaded file (image/gif)', upload_to=submissions.models.submission_file_upload_path)),
+ ('original_filename', models.CharField(help_text='Original filename as uploaded by user', max_length=255)),
+ ('file_size', models.PositiveIntegerField(help_text='File size in bytes')),
+ ('content_type', models.CharField(help_text='MIME type of the file', max_length=100)),
+ ('ocr_processed', models.BooleanField(default=False, help_text='Whether OCR has been processed for this file')),
+ ('ocr_raw_data', models.JSONField(blank=True, help_text='Raw OCR data as JSON', null=True)),
+ ('ocr_error', models.TextField(blank=True, help_text='OCR processing error message')),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('response', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='submissions.puzzleresponse')),
+ ],
+ options={
+ 'verbose_name': 'Submission File',
+ 'verbose_name_plural': 'Submission Files',
+ 'ordering': ['response', 'created_at'],
+ },
+ ),
+ ]
diff --git a/opus_submitter/submissions/models.py b/opus_submitter/submissions/models.py
index d63fd8a..5005465 100644
--- a/opus_submitter/submissions/models.py
+++ b/opus_submitter/submissions/models.py
@@ -2,6 +2,8 @@ from django.db import models
from django.contrib.auth import get_user_model
from django.utils import timezone
from django.core.exceptions import ValidationError
+import uuid
+import os
User = get_user_model()
@@ -196,3 +198,242 @@ class SteamCollectionItem(models.Model):
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(
+ 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"
+ )
+
+ # 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 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.CharField(
+ max_length=20,
+ blank=True,
+ help_text="Cost value from OCR"
+ )
+ cycles = models.CharField(
+ max_length=20,
+ blank=True,
+ help_text="Cycles value from OCR"
+ )
+ area = models.CharField(
+ max_length=20,
+ 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_score = models.FloatField(
+ null=True,
+ blank=True,
+ help_text="OCR confidence score (0.0 to 1.0)"
+ )
+
+ # Manual validation overrides
+ validated_cost = models.CharField(
+ max_length=20,
+ blank=True,
+ help_text="Manually validated cost value"
+ )
+ validated_cycles = models.CharField(
+ max_length=20,
+ blank=True,
+ help_text="Manually validated cycles value"
+ )
+ validated_area = models.CharField(
+ max_length=20,
+ blank=True,
+ help_text="Manually validated area value"
+ )
+
+ # Timestamps
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ ordering = ['submission', 'puzzle__order_index']
+ unique_together = ['submission', 'puzzle']
+ verbose_name = "Puzzle Response"
+ verbose_name_plural = "Puzzle Responses"
+
+ 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)
+
diff --git a/opus_submitter/submissions/schemas.py b/opus_submitter/submissions/schemas.py
new file mode 100644
index 0000000..506e22e
--- /dev/null
+++ b/opus_submitter/submissions/schemas.py
@@ -0,0 +1,122 @@
+from ninja import Schema, ModelSchema, File
+from ninja.files import UploadedFile
+from typing import List, Optional
+from datetime import datetime
+from uuid import UUID
+
+from .models import Submission, PuzzleResponse, SubmissionFile, SteamCollectionItem
+
+
+# Input Schemas
+class SubmissionFileIn(Schema):
+ """Schema for file upload data"""
+ original_filename: str
+ content_type: str
+ ocr_data: Optional[dict] = None
+
+
+class PuzzleResponseIn(Schema):
+ """Schema for creating a puzzle response"""
+ puzzle_id: int
+ puzzle_name: str
+ cost: Optional[str] = None
+ cycles: Optional[str] = None
+ area: Optional[str] = None
+ needs_manual_validation: bool = False
+ ocr_confidence_score: Optional[float] = None
+
+
+class SubmissionIn(Schema):
+ """Schema for creating a submission"""
+ notes: Optional[str] = None
+ responses: List[PuzzleResponseIn]
+
+
+# Output Schemas
+class SubmissionFileOut(ModelSchema):
+ """Schema for submission file output"""
+ file_url: Optional[str]
+
+ class Meta:
+ model = SubmissionFile
+ fields = [
+ 'id', 'original_filename', 'file_size', 'content_type',
+ 'ocr_processed', 'ocr_raw_data', 'ocr_error', 'created_at'
+ ]
+
+
+class PuzzleResponseOut(ModelSchema):
+ """Schema for puzzle response output"""
+ files: List[SubmissionFileOut]
+ final_cost: Optional[str]
+ final_cycles: Optional[str]
+ final_area: Optional[str]
+
+ class Meta:
+ model = PuzzleResponse
+ fields = [
+ 'id', 'puzzle', 'puzzle_name', 'cost', 'cycles', 'area',
+ 'needs_manual_validation', 'ocr_confidence_score',
+ 'validated_cost', 'validated_cycles', 'validated_area',
+ 'created_at', 'updated_at'
+ ]
+
+
+class SubmissionOut(ModelSchema):
+ """Schema for submission output"""
+ responses: List[PuzzleResponseOut]
+ total_responses: int
+ needs_validation: bool
+
+ class Meta:
+ model = Submission
+ fields = [
+ 'id', 'user', 'notes', 'is_validated', 'validated_by',
+ 'validated_at', 'created_at', 'updated_at'
+ ]
+
+
+class SubmissionListOut(Schema):
+ """Schema for submission list output"""
+ id: UUID
+ user: Optional[int]
+ notes: Optional[str]
+ total_responses: int
+ needs_validation: bool
+ is_validated: bool
+ created_at: datetime
+ updated_at: datetime
+
+
+# Validation Schemas
+class ValidationIn(Schema):
+ """Schema for manual validation input"""
+ validated_cost: Optional[str] = None
+ validated_cycles: Optional[str] = None
+ validated_area: Optional[str] = None
+
+
+# Collection Schemas
+class SteamCollectionItemOut(ModelSchema):
+ """Schema for Steam collection item output"""
+ steam_url: str
+
+ class Meta:
+ model = SteamCollectionItem
+ fields = [
+ 'id', 'steam_item_id', 'title', 'author_name', 'description',
+ 'tags', 'order_index', 'created_at', 'updated_at'
+ ]
+
+
+# Error Schemas
+class ErrorOut(Schema):
+ """Schema for error responses"""
+ detail: str
+ code: Optional[str] = None
+
+
+class ValidationErrorOut(Schema):
+ """Schema for validation error responses"""
+ detail: str
+ errors: dict
diff --git a/pyproject.toml b/pyproject.toml
index d803eb0..05166b5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,6 +7,10 @@ dependencies = [
"django>=5.2.7",
"django-vite>=3.1.0",
"requests>=2.31.0",
+ "django-ninja>=1.3.0",
+ "django-storages>=1.14.0",
+ "boto3>=1.35.0",
+ "pillow>=10.0.0",
]
[build-system]