api
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
+82
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user