api
This commit is contained in:
parent
2d21ff0d55
commit
07dd1bc0ff
231
opus_submitter/API_USAGE.md
Normal file
231
opus_submitter/API_USAGE.md
Normal 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
|
||||
41
opus_submitter/opus_submitter/api.py
Normal file
41
opus_submitter/opus_submitter/api.py
Normal 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",
|
||||
],
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
249
opus_submitter/submissions/api.py
Normal file
249
opus_submitter/submissions/api.py
Normal 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,
|
||||
}
|
||||
@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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)
|
||||
|
||||
|
||||
122
opus_submitter/submissions/schemas.py
Normal file
122
opus_submitter/submissions/schemas.py
Normal 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
|
||||
@ -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]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user