This commit is contained in:
Loïc Gremaud 2025-10-29 02:35:56 +01:00
parent 2d21ff0d55
commit 07dd1bc0ff
11 changed files with 1195 additions and 22 deletions

231
opus_submitter/API_USAGE.md Normal file
View File

@ -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

View File

@ -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",
],
}

View File

@ -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",

View File

@ -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)

View File

@ -169,15 +169,6 @@ const findPuzzleByName = (ocrPuzzleName: string): SteamCollectionItem | null =>
<div class="flex-1">
<h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</h1>
</div>
<div class="flex-none">
<button
@click="openSubmissionModal"
class="btn btn-primary"
>
<i class="mdi mdi-plus mr-2"></i>
Submit Solution
</button>
</div>
</div>
</div>
@ -200,18 +191,13 @@ const findPuzzleByName = (ocrPuzzleName: string): SteamCollectionItem | null =>
<h2 class="card-title text-2xl">{{ collections[0].title }}</h2>
<p class="text-base-content/70">{{ collections[0].description }}</p>
<div class="flex flex-wrap gap-4 mt-4">
<div class="stat">
<div class="stat-title">Total Puzzles</div>
<div class="stat-value text-primary">{{ collections[0].total_items }}</div>
</div>
<div class="stat">
<div class="stat-title">Author</div>
<div class="stat-value text-sm">{{ collections[0].author_name }}</div>
</div>
<div class="stat">
<div class="stat-title">Visitors</div>
<div class="stat-value text-sm">{{ collections[0].unique_visitors }}</div>
</div>
<button
@click="openSubmissionModal"
class="btn btn-primary"
>
<i class="mdi mdi-plus mr-2"></i>
Submit Solution
</button>
</div>
</div>
</div>

View File

@ -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"

View File

@ -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,
}

View File

@ -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'],
},
),
]

View File

@ -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)

View File

@ -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

View File

@ -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]