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]