ruff + submissions validation

This commit is contained in:
Loïc Gremaud 2025-10-30 14:43:19 +01:00
parent 15de496501
commit 0e1e77c2dd
36 changed files with 1025 additions and 392 deletions

View File

@ -6,27 +6,31 @@ from .models import CustomUser
@admin.register(CustomUser)
class CustomUserAdmin(UserAdmin):
"""Admin interface for CustomUser."""
# Add custom fields to the user admin
fieldsets = UserAdmin.fieldsets + (
('CAS Information', {
'fields': ('cas_user_id', 'cas_groups', 'cas_attributes'),
}),
(
"CAS Information",
{
"fields": ("cas_user_id", "cas_groups", "cas_attributes"),
},
),
)
# Add custom fields to the list display
list_display = UserAdmin.list_display + ('cas_user_id', 'get_cas_groups_display')
list_display = UserAdmin.list_display + ("cas_user_id", "get_cas_groups_display")
# Add search fields
search_fields = UserAdmin.search_fields + ('cas_user_id',)
search_fields = UserAdmin.search_fields + ("cas_user_id",)
# Add filters
list_filter = UserAdmin.list_filter + ('cas_groups',)
list_filter = UserAdmin.list_filter + ("cas_groups",)
# Make CAS fields readonly in admin
readonly_fields = ('cas_user_id', 'cas_groups', 'cas_attributes')
readonly_fields = ("cas_user_id", "cas_groups", "cas_attributes")
def get_cas_groups_display(self, obj):
"""Display CAS groups in admin list."""
return obj.get_cas_groups_display()
get_cas_groups_display.short_description = 'CAS Groups'
get_cas_groups_display.short_description = "CAS Groups"

View File

@ -2,5 +2,5 @@ from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'accounts'
default_auto_field = "django.db.models.BigAutoField"
name = "accounts"

View File

@ -7,41 +7,131 @@ from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
("auth", "0012_alter_user_first_name_max_length"),
]
operations = [
migrations.CreateModel(
name='CustomUser',
name="CustomUser",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('cas_user_id', models.CharField(blank=True, max_length=50, null=True, unique=True)),
('cas_groups', models.JSONField(blank=True, default=list)),
('cas_attributes', models.JSONField(blank=True, default=dict)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="email address"
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
),
(
"cas_user_id",
models.CharField(blank=True, max_length=50, null=True, unique=True),
),
("cas_groups", models.JSONField(blank=True, default=list)),
("cas_attributes", models.JSONField(blank=True, default=dict)),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
"verbose_name": "user",
"verbose_name_plural": "users",
"abstract": False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
("objects", django.contrib.auth.models.UserManager()),
],
),
]

View File

@ -1,6 +1,5 @@
from django.contrib.auth.models import AbstractUser
from django.db import models
import json
class CustomUser(AbstractUser):
@ -57,4 +56,3 @@ class CustomUser(AbstractUser):
self.cas_attributes = attributes
self.save()

View File

@ -1,3 +1 @@
from django.test import TestCase
# Create your tests here.

View File

@ -1,3 +1 @@
from django.shortcuts import render
# Create your views here.

View File

@ -1,12 +1,13 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'opus_submitter.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "opus_submitter.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
@ -18,5 +19,5 @@ def main():
execute_from_command_line(sys.argv)
if __name__ == '__main__':
if __name__ == "__main__":
main()

View File

@ -1,5 +1,4 @@
from ninja import NinjaAPI
from ninja.security import django_auth
from submissions.api import router as submissions_router
from submissions.schemas import UserInfoOut
@ -47,7 +46,7 @@ def api_info(request):
def get_user_info(request):
"""Get current user information"""
user = request.user
if user.is_authenticated:
return {
"id": user.id,
@ -58,7 +57,7 @@ def get_user_info(request):
"is_authenticated": True,
"is_staff": user.is_staff,
"is_superuser": user.is_superuser,
"cas_groups": getattr(user, 'cas_groups', [])
"cas_groups": getattr(user, "cas_groups", []),
}
else:
return {

View File

@ -11,6 +11,6 @@ import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'opus_submitter.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "opus_submitter.settings")
application = get_asgi_application()

View File

@ -176,6 +176,4 @@ STATICFILES_DIRS = [
os.path.join(BASE_DIR, "static_source/vite"),
]
JWT_SECRET_KEY = "rooCaimosaicae3Oos2quezieb9rohsem1eufieJoo"
from opus_submitter.settingsLocal import *
from opus_submitter.settingsLocal import * # noqa

View File

@ -11,6 +11,6 @@ import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'opus_submitter.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "opus_submitter.settings")
application = get_wsgi_application()

View File

@ -12,35 +12,39 @@ import urllib.parse
class SimpleCASLoginView(View):
"""Simple CAS login view."""
def get(self, request):
ticket = request.GET.get('ticket')
ticket = request.GET.get("ticket")
if ticket:
# Coming back from CAS with ticket - validate it
service_url = request.build_absolute_uri().split('?')[0] # Remove query params
service_url = request.build_absolute_uri().split("?")[
0
] # Remove query params
user = authenticate(request=request, ticket=ticket, service=service_url)
if user:
login(request, user)
return redirect(settings.LOGIN_REDIRECT_URL)
else:
return HttpResponse("Authentication failed", status=401)
else:
# No ticket - redirect to CAS
service_url = request.build_absolute_uri().split('?')[0] # Remove query params
service_url = request.build_absolute_uri().split("?")[
0
] # Remove query params
cas_login_url = f"{settings.CAS_SERVER_URL.rstrip('/')}/login?service={urllib.parse.quote(service_url)}"
return redirect(cas_login_url)
class SimpleCASLogoutView(View):
"""Simple CAS logout view."""
def get(self, request):
logout(request)
# Redirect to CAS logout
cas_logout_url = f"{settings.CAS_SERVER_URL.rstrip('/')}/logout"
return redirect(cas_logout_url)

View File

@ -146,6 +146,33 @@
</div>
</div>
<!-- Manual Puzzle Selection (when OCR confidence is low) -->
<div v-if="file.needsManualPuzzleSelection" class="mt-2">
<div class="alert alert-warning alert-sm">
<i class="mdi mdi-alert-circle text-lg"></i>
<div class="flex-1">
<div class="font-medium">Low OCR Confidence</div>
<div class="text-xs">Please select the correct puzzle manually</div>
</div>
</div>
<div class="mt-2">
<select
v-model="file.manualPuzzleSelection"
class="select select-bordered select-sm w-full"
@change="onManualPuzzleSelection(file)"
>
<option value="">Select puzzle...</option>
<option
v-for="puzzle in puzzlesStore.puzzles"
:key="puzzle.id"
:value="puzzle.title"
>
{{ puzzle.title }}
</option>
</select>
</div>
</div>
<!-- Manual OCR trigger for non-auto detected files -->
<div v-else-if="!file.ocrProcessing && !file.ocrError && !file.ocrData" class="mt-1">
<button
@ -335,6 +362,15 @@ const processOCR = async (submissionFile: SubmissionFile) => {
// Force reactivity update
await nextTick()
files.value[fileIndex].ocrData = ocrData
// Check if puzzle confidence is below 80% and needs manual selection
if (ocrData.confidence.puzzle < 0.8) {
files.value[fileIndex].needsManualPuzzleSelection = true
console.log(`Low puzzle confidence (${Math.round(ocrData.confidence.puzzle * 100)}%) for ${submissionFile.file.name}, requiring manual selection`)
} else {
files.value[fileIndex].needsManualPuzzleSelection = false
}
await nextTick()
} catch (error) {
console.error('OCR processing failed:', error)
@ -353,4 +389,16 @@ const getConfidenceBadgeClass = (confidence: number): string => {
if (confidence >= 0.6) return 'badge-warning'
return 'badge-error'
}
const onManualPuzzleSelection = (submissionFile: SubmissionFile) => {
// Find the file in the reactive array
const fileIndex = files.value.findIndex(f => f.file === submissionFile.file)
if (fileIndex === -1) return
// Clear the manual selection requirement once user has selected
if (files.value[fileIndex].manualPuzzleSelection) {
files.value[fileIndex].needsManualPuzzleSelection = false
console.log(`Manual puzzle selection: ${submissionFile.file.name} -> ${files.value[fileIndex].manualPuzzleSelection}`)
}
}
</script>

View File

@ -23,6 +23,18 @@
<!-- File Upload -->
<FileUpload v-model="submissionFiles" :puzzles="puzzles" />
<!-- Manual Selection Warning -->
<div v-if="filesNeedingManualSelection.length > 0" class="alert alert-warning">
<i class="mdi mdi-alert-circle text-xl"></i>
<div class="flex-1">
<div class="font-bold">Manual Puzzle Selection Required</div>
<div class="text-sm">
{{ filesNeedingManualSelection.length }} file(s) have low OCR confidence for puzzle names.
Please select the correct puzzle for each file before submitting.
</div>
</div>
</div>
<!-- Notes -->
<div class="form-control">
@ -64,10 +76,14 @@
<button
type="submit"
class="btn btn-primary"
:disabled="isSubmitting"
:disabled="!canSubmit"
>
<span v-if="isSubmitting" class="loading loading-spinner loading-sm"></span>
{{ isSubmitting ? 'Submitting...' : 'Submit Solution' }}
<span v-if="isSubmitting">Submitting...</span>
<span v-else-if="filesNeedingManualSelection.length > 0">
Select Puzzles ({{ filesNeedingManualSelection.length }} remaining)
</span>
<span v-else>Submit Solution</span>
</button>
</div>
</form>
@ -100,8 +116,12 @@ const isSubmitting = ref(false)
const notesLength = computed(() => notes.value.length)
const canSubmit = computed(() => {
return submissionFiles.value.length > 0 &&
!isSubmitting.value
const hasFiles = submissionFiles.value.length > 0
const noManualSelectionNeeded = !submissionFiles.value.some(file => file.needsManualPuzzleSelection)
return hasFiles &&
!isSubmitting.value &&
noManualSelectionNeeded
})
// Group files by detected puzzle
@ -109,8 +129,10 @@ const responsesByPuzzle = computed(() => {
const grouped: Record<string, { puzzle: SteamCollectionItem | null, files: SubmissionFile[] }> = {}
submissionFiles.value.forEach(file => {
if (file.ocrData?.puzzle) {
const puzzleName = file.ocrData.puzzle
// Use manual puzzle selection if available, otherwise fall back to OCR
const puzzleName = file.manualPuzzleSelection || file.ocrData?.puzzle
if (puzzleName) {
if (!grouped[puzzleName]) {
grouped[puzzleName] = {
puzzle: props.findPuzzleByName(puzzleName),
@ -124,6 +146,11 @@ const responsesByPuzzle = computed(() => {
return grouped
})
// Count files that need manual puzzle selection
const filesNeedingManualSelection = computed(() => {
return submissionFiles.value.filter(file => file.needsManualPuzzleSelection)
})
// Check if any OCR confidence is below 50%
const hasLowConfidence = computed(() => {
return submissionFiles.value.some(file => {

View File

@ -238,8 +238,10 @@ export const submissionHelpers = {
}> = {}
files.forEach(file => {
if (file.ocrData?.puzzle) {
const puzzleName = file.ocrData.puzzle
// Use manual puzzle selection if available, otherwise fall back to OCR
const puzzleName = file.manualPuzzleSelection || file.ocrData?.puzzle
if (puzzleName) {
if (!responsesByPuzzle[puzzleName]) {
responsesByPuzzle[puzzleName] = {
puzzle: puzzleHelpers.findPuzzleByName(puzzles, puzzleName),

View File

@ -46,6 +46,8 @@ export interface SubmissionFile {
ocrProcessing?: boolean
ocrError?: string
original_filename?: string
manualPuzzleSelection?: string
needsManualPuzzleSelection?: boolean
}
export interface PuzzleResponse {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -16,12 +16,12 @@
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2"
},
"src/main.ts": {
"file": "assets/main-fzs-6OUY.js",
"file": "assets/main-B14l8Jy0.js",
"name": "main",
"src": "src/main.ts",
"isEntry": true,
"css": [
"assets/main-DeQiP-Az.css"
"assets/main-COx9N9qO.css"
],
"assets": [
"assets/materialdesignicons-webfont-CSr8KVlo.eot",

View File

@ -2,8 +2,12 @@ from django.contrib import admin
from django.utils.html import format_html
from django.utils import timezone
from .models import (
SteamAPIKey, SteamCollection, SteamCollectionItem,
Submission, PuzzleResponse, SubmissionFile
SteamAPIKey,
SteamCollection,
SteamCollectionItem,
Submission,
PuzzleResponse,
SubmissionFile,
)
@ -151,7 +155,14 @@ 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"]
fields = [
"file",
"original_filename",
"file_size",
"content_type",
"ocr_processed",
"ocr_error",
]
class PuzzleResponseInline(admin.TabularInline):
@ -159,43 +170,73 @@ class PuzzleResponseInline(admin.TabularInline):
extra = 0
readonly_fields = ["created_at", "updated_at"]
fields = [
"puzzle", "puzzle_name", "cost", "cycles", "area",
"needs_manual_validation", "ocr_confidence_cost", "ocr_confidence_cycles", "ocr_confidence_area"
"puzzle",
"puzzle_name",
"cost",
"cycles",
"area",
"needs_manual_validation",
"ocr_confidence_cost",
"ocr_confidence_cycles",
"ocr_confidence_area",
]
@admin.register(Submission)
class SubmissionAdmin(admin.ModelAdmin):
list_display = [
"id", "user", "total_responses", "needs_validation",
"manual_validation_requested", "is_validated", "created_at"
"id",
"user",
"total_responses",
"needs_validation",
"manual_validation_requested",
"is_validated",
"created_at",
]
list_filter = [
"is_validated", "manual_validation_requested", "created_at", "updated_at"
"is_validated",
"manual_validation_requested",
"created_at",
"updated_at",
]
search_fields = ["id", "user__username", "notes"]
readonly_fields = ["id", "created_at", "updated_at", "total_responses", "needs_validation"]
readonly_fields = [
"id",
"created_at",
"updated_at",
"total_responses",
"needs_validation",
]
inlines = [PuzzleResponseInline]
fieldsets = (
("Basic Information", {
"fields": ("id", "user", "notes")
}),
("Validation", {
"fields": ("manual_validation_requested", "is_validated", "validated_by", "validated_at")
}),
("Statistics", {
"fields": ("total_responses", "needs_validation"),
"classes": ("collapse",)
}),
("Timestamps", {
"fields": ("created_at", "updated_at"),
"classes": ("collapse",)
}),
("Basic Information", {"fields": ("id", "user", "notes")}),
(
"Validation",
{
"fields": (
"manual_validation_requested",
"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
@ -208,59 +249,82 @@ class SubmissionAdmin(admin.ModelAdmin):
# 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"
"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"
"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_cost", "ocr_confidence_cycles", "ocr_confidence_area")
}),
("Validation", {
"fields": (
"needs_manual_validation",
"validated_cost", "validated_cycles", "validated_area"
)
}),
("Timestamps", {
"fields": ("created_at", "updated_at"),
"classes": ("collapse",)
}),
("Basic Information", {"fields": ("submission", "puzzle", "puzzle_name")}),
(
"OCR Data",
{
"fields": (
"cost",
"cycles",
"area",
"ocr_confidence_cost",
"ocr_confidence_cycles",
"ocr_confidence_area",
)
},
),
(
"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.")
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"
@ -268,37 +332,49 @@ class PuzzleResponseAdmin(admin.ModelAdmin):
@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"
"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"
"original_filename",
"response__puzzle_name",
"response__submission__id",
]
readonly_fields = [
"file_size", "content_type", "ocr_processed",
"created_at", "updated_at", "file_url"
"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",)
}),
(
"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:
@ -307,5 +383,5 @@ class SubmissionFileAdmin(admin.ModelAdmin):
return f"{obj.file_size / 1024:.1f} KB"
else:
return f"{obj.file_size / (1024 * 1024):.1f} MB"
file_size_display.short_description = "File Size"

View File

@ -69,17 +69,27 @@ def create_submission(
with transaction.atomic():
# Check if any confidence score is below 50% to auto-request validation
auto_request_validation = any(
(response_data.ocr_confidence_cost is not None and response_data.ocr_confidence_cost < 0.5) or
(response_data.ocr_confidence_cycles is not None and response_data.ocr_confidence_cycles < 0.5) or
(response_data.ocr_confidence_area is not None and response_data.ocr_confidence_area < 0.5)
(
response_data.ocr_confidence_cost is not None
and response_data.ocr_confidence_cost < 0.5
)
or (
response_data.ocr_confidence_cycles is not None
and response_data.ocr_confidence_cycles < 0.5
)
or (
response_data.ocr_confidence_area is not None
and response_data.ocr_confidence_area < 0.5
)
for response_data in data.responses
)
# Create the submission
submission = Submission.objects.create(
user=request.user if request.user.is_authenticated else None,
notes=data.notes,
manual_validation_requested=data.manual_validation_requested or auto_request_validation,
manual_validation_requested=data.manual_validation_requested
or auto_request_validation,
)
file_index = 0
@ -100,7 +110,7 @@ def create_submission(
cost=response_data.cost,
cycles=response_data.cycles,
area=response_data.area,
needs_manual_validation=response_data.needs_manual_validation,
needs_manual_validation=data.manual_validation_requested,
ocr_confidence_cost=response_data.ocr_confidence_cost,
ocr_confidence_cycles=response_data.ocr_confidence_cycles,
ocr_confidence_area=response_data.ocr_confidence_area,

View File

@ -2,5 +2,5 @@ from django.apps import AppConfig
class SubmissionsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'submissions'
default_auto_field = "django.db.models.BigAutoField"
name = "submissions"

View File

@ -8,40 +8,39 @@ from submissions.models import SteamCollection
class Command(BaseCommand):
help = 'Fetch Steam Workshop collection data and save to database'
help = "Fetch Steam Workshop collection data and save to database"
def add_arguments(self, parser):
parser.add_argument("url", type=str, help="Steam Workshop collection URL")
parser.add_argument(
'url',
"--api-key",
type=str,
help='Steam Workshop collection URL'
help="Steam API key (optional, can also be set via STEAM_API_KEY environment variable)",
)
parser.add_argument(
'--api-key',
type=str,
help='Steam API key (optional, can also be set via STEAM_API_KEY environment variable)'
"--force",
action="store_true",
help="Force refetch even if collection already exists",
)
parser.add_argument(
'--force',
action='store_true',
help='Force refetch even if collection already exists'
)
def handle(self, *args, **options):
url = options['url']
api_key = options.get('api_key')
force = options['force']
url = options["url"]
api_key = options.get("api_key")
force = options["force"]
self.stdout.write(f"Fetching Steam collection from: {url}")
try:
# Check if collection already exists
from submissions.utils import SteamCollectionFetcher
fetcher = SteamCollectionFetcher(api_key)
collection_id = fetcher.extract_collection_id(url)
if collection_id and not force:
existing = SteamCollection.objects.filter(steam_id=collection_id).first()
existing = SteamCollection.objects.filter(
steam_id=collection_id
).first()
if existing:
self.stdout.write(
self.style.WARNING(
@ -50,10 +49,10 @@ class Command(BaseCommand):
)
)
return
# Fetch and create/update collection
collection, created = create_or_update_collection(url)
if created:
self.stdout.write(
self.style.SUCCESS(
@ -66,27 +65,33 @@ class Command(BaseCommand):
f"Successfully updated collection: {collection.title} (ID: {collection.id})"
)
)
# Display collection info
self.stdout.write("\nCollection Details:")
self.stdout.write(f" Steam ID: {collection.steam_id}")
self.stdout.write(f" Title: {collection.title}")
self.stdout.write(f" Author: {collection.author_name or 'Unknown'}")
self.stdout.write(f" Description: {collection.description[:100]}{'...' if len(collection.description) > 100 else ''}")
self.stdout.write(
f" Description: {collection.description[:100]}{'...' if len(collection.description) > 100 else ''}"
)
self.stdout.write(f" Total Items: {collection.total_items}")
self.stdout.write(f" Unique Visitors: {collection.unique_visitors}")
self.stdout.write(f" Current Favorites: {collection.current_favorites}")
self.stdout.write(f" Total Favorites: {collection.total_favorites}")
if collection.items.exists():
self.stdout.write(f"\nCollection Items ({collection.items.count()}):")
for item in collection.items.all()[:10]: # Show first 10 items
self.stdout.write(f" - {item.title} (Steam ID: {item.steam_item_id})")
self.stdout.write(
f" - {item.title} (Steam ID: {item.steam_item_id})"
)
if collection.items.count() > 10:
self.stdout.write(f" ... and {collection.items.count() - 10} more items")
self.stdout.write(
f" ... and {collection.items.count() - 10} more items"
)
else:
self.stdout.write("\nNo items found in collection.")
except Exception as e:
raise CommandError(f"Failed to fetch collection: {e}")

View File

@ -5,68 +5,215 @@ from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
dependencies = []
operations = [
migrations.CreateModel(
name='Collection',
name="Collection",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('url', models.URLField()),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("url", models.URLField()),
],
),
migrations.CreateModel(
name='SteamCollection',
name="SteamCollection",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('steam_id', models.CharField(help_text='Steam collection ID from URL', max_length=50, unique=True)),
('url', models.URLField(help_text='Full Steam Workshop collection URL')),
('title', models.CharField(blank=True, help_text='Collection title', max_length=255)),
('description', models.TextField(blank=True, help_text='Collection description')),
('author_name', models.CharField(blank=True, help_text='Steam username of collection creator', max_length=100)),
('author_steam_id', models.CharField(blank=True, help_text='Steam ID of collection creator', max_length=50)),
('total_items', models.PositiveIntegerField(default=0, help_text='Number of items in collection')),
('unique_visitors', models.PositiveIntegerField(default=0, help_text='Number of unique visitors')),
('current_favorites', models.PositiveIntegerField(default=0, help_text='Current number of favorites')),
('total_favorites', models.PositiveIntegerField(default=0, help_text='Total unique favorites')),
('steam_created_date', models.DateTimeField(blank=True, help_text='When collection was created on Steam', null=True)),
('steam_updated_date', models.DateTimeField(blank=True, help_text='When collection was last updated on Steam', null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('last_fetched', models.DateTimeField(blank=True, help_text='When data was last fetched from Steam', null=True)),
('is_active', models.BooleanField(default=True, help_text='Whether this collection is actively tracked')),
('fetch_error', models.TextField(blank=True, help_text='Last error encountered when fetching data')),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"steam_id",
models.CharField(
help_text="Steam collection ID from URL",
max_length=50,
unique=True,
),
),
(
"url",
models.URLField(help_text="Full Steam Workshop collection URL"),
),
(
"title",
models.CharField(
blank=True, help_text="Collection title", max_length=255
),
),
(
"description",
models.TextField(blank=True, help_text="Collection description"),
),
(
"author_name",
models.CharField(
blank=True,
help_text="Steam username of collection creator",
max_length=100,
),
),
(
"author_steam_id",
models.CharField(
blank=True,
help_text="Steam ID of collection creator",
max_length=50,
),
),
(
"total_items",
models.PositiveIntegerField(
default=0, help_text="Number of items in collection"
),
),
(
"unique_visitors",
models.PositiveIntegerField(
default=0, help_text="Number of unique visitors"
),
),
(
"current_favorites",
models.PositiveIntegerField(
default=0, help_text="Current number of favorites"
),
),
(
"total_favorites",
models.PositiveIntegerField(
default=0, help_text="Total unique favorites"
),
),
(
"steam_created_date",
models.DateTimeField(
blank=True,
help_text="When collection was created on Steam",
null=True,
),
),
(
"steam_updated_date",
models.DateTimeField(
blank=True,
help_text="When collection was last updated on Steam",
null=True,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"last_fetched",
models.DateTimeField(
blank=True,
help_text="When data was last fetched from Steam",
null=True,
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Whether this collection is actively tracked",
),
),
(
"fetch_error",
models.TextField(
blank=True,
help_text="Last error encountered when fetching data",
),
),
],
options={
'verbose_name': 'Steam Collection',
'verbose_name_plural': 'Steam Collections',
'ordering': ['-created_at'],
"verbose_name": "Steam Collection",
"verbose_name_plural": "Steam Collections",
"ordering": ["-created_at"],
},
),
migrations.CreateModel(
name='SteamCollectionItem',
name="SteamCollectionItem",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('steam_item_id', models.CharField(help_text='Steam Workshop item ID', max_length=50)),
('title', models.CharField(blank=True, help_text='Item title', max_length=255)),
('author_name', models.CharField(blank=True, help_text='Steam username of item creator', max_length=100)),
('author_steam_id', models.CharField(blank=True, help_text='Steam ID of item creator', max_length=50)),
('description', models.TextField(blank=True, help_text='Item description')),
('tags', models.JSONField(blank=True, default=list, help_text='Item tags as JSON array')),
('order_index', models.PositiveIntegerField(default=0, help_text='Order of item in collection')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='submissions.steamcollection')),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"steam_item_id",
models.CharField(help_text="Steam Workshop item ID", max_length=50),
),
(
"title",
models.CharField(
blank=True, help_text="Item title", max_length=255
),
),
(
"author_name",
models.CharField(
blank=True,
help_text="Steam username of item creator",
max_length=100,
),
),
(
"author_steam_id",
models.CharField(
blank=True, help_text="Steam ID of item creator", max_length=50
),
),
(
"description",
models.TextField(blank=True, help_text="Item description"),
),
(
"tags",
models.JSONField(
blank=True, default=list, help_text="Item tags as JSON array"
),
),
(
"order_index",
models.PositiveIntegerField(
default=0, help_text="Order of item in collection"
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"collection",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="items",
to="submissions.steamcollection",
),
),
],
options={
'verbose_name': 'Steam Collection Item',
'verbose_name_plural': 'Steam Collection Items',
'ordering': ['collection', 'order_index'],
'unique_together': {('collection', 'steam_item_id')},
"verbose_name": "Steam Collection Item",
"verbose_name_plural": "Steam Collection Items",
"ordering": ["collection", "order_index"],
"unique_together": {("collection", "steam_item_id")},
},
),
]

View File

@ -4,13 +4,12 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('submissions', '0001_initial'),
("submissions", "0001_initial"),
]
operations = [
migrations.DeleteModel(
name='Collection',
name="Collection",
),
]

View File

@ -4,28 +4,66 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('submissions', '0002_delete_collection'),
("submissions", "0002_delete_collection"),
]
operations = [
migrations.CreateModel(
name='SteamAPIKey',
name="SteamAPIKey",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text="Descriptive name for this API key (e.g., 'Production Key', 'Development Key')", max_length=100, unique=True)),
('api_key', models.CharField(help_text='Steam Web API key from https://steamcommunity.com/dev/apikey', max_length=64)),
('is_active', models.BooleanField(default=True, help_text='Whether this API key should be used')),
('description', models.TextField(blank=True, help_text='Optional description or notes about this API key')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('last_used', models.DateTimeField(blank=True, help_text='When this API key was last used', null=True)),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"name",
models.CharField(
help_text="Descriptive name for this API key (e.g., 'Production Key', 'Development Key')",
max_length=100,
unique=True,
),
),
(
"api_key",
models.CharField(
help_text="Steam Web API key from https://steamcommunity.com/dev/apikey",
max_length=64,
),
),
(
"is_active",
models.BooleanField(
default=True, help_text="Whether this API key should be used"
),
),
(
"description",
models.TextField(
blank=True,
help_text="Optional description or notes about this API key",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"last_used",
models.DateTimeField(
blank=True,
help_text="When this API key was last used",
null=True,
),
),
],
options={
'verbose_name': 'Steam API Key',
'verbose_name_plural': 'Steam API Keys',
'ordering': ['-is_active', 'name'],
"verbose_name": "Steam API Key",
"verbose_name_plural": "Steam API Keys",
"ordering": ["-is_active", "name"],
},
),
]

View File

@ -8,75 +8,245 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('submissions', '0003_steamapikey'),
("submissions", "0003_steamapikey"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Submission',
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)),
(
"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'],
"verbose_name": "Submission",
"verbose_name_plural": "Submissions",
"ordering": ["-created_at"],
},
),
migrations.CreateModel(
name='PuzzleResponse',
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')),
(
"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')},
"verbose_name": "Puzzle Response",
"verbose_name_plural": "Puzzle Responses",
"ordering": ["submission", "puzzle__order_index"],
"unique_together": {("submission", "puzzle")},
},
),
migrations.CreateModel(
name='SubmissionFile',
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')),
(
"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'],
"verbose_name": "Submission File",
"verbose_name_plural": "Submission Files",
"ordering": ["response", "created_at"],
},
),
]

View File

@ -4,15 +4,16 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('submissions', '0004_submission_puzzleresponse_submissionfile'),
("submissions", "0004_submission_puzzleresponse_submissionfile"),
]
operations = [
migrations.AlterField(
model_name='submission',
name='notes',
field=models.TextField(blank=True, help_text='Optional notes about the submission', null=True),
model_name="submission",
name="notes",
field=models.TextField(
blank=True, help_text="Optional notes about the submission", null=True
),
),
]

View File

@ -4,29 +4,40 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('submissions', '0005_alter_submission_notes'),
("submissions", "0005_alter_submission_notes"),
]
operations = [
migrations.RemoveField(
model_name='puzzleresponse',
name='ocr_confidence_score',
model_name="puzzleresponse",
name="ocr_confidence_score",
),
migrations.AddField(
model_name='puzzleresponse',
name='ocr_confidence_area',
field=models.FloatField(blank=True, help_text='OCR confidence score for area (0.0 to 1.0)', null=True),
model_name="puzzleresponse",
name="ocr_confidence_area",
field=models.FloatField(
blank=True,
help_text="OCR confidence score for area (0.0 to 1.0)",
null=True,
),
),
migrations.AddField(
model_name='puzzleresponse',
name='ocr_confidence_cost',
field=models.FloatField(blank=True, help_text='OCR confidence score for cost (0.0 to 1.0)', null=True),
model_name="puzzleresponse",
name="ocr_confidence_cost",
field=models.FloatField(
blank=True,
help_text="OCR confidence score for cost (0.0 to 1.0)",
null=True,
),
),
migrations.AddField(
model_name='puzzleresponse',
name='ocr_confidence_cycles',
field=models.FloatField(blank=True, help_text='OCR confidence score for cycles (0.0 to 1.0)', null=True),
model_name="puzzleresponse",
name="ocr_confidence_cycles",
field=models.FloatField(
blank=True,
help_text="OCR confidence score for cycles (0.0 to 1.0)",
null=True,
),
),
]

View File

@ -4,15 +4,17 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('submissions', '0006_remove_puzzleresponse_ocr_confidence_score_and_more'),
("submissions", "0006_remove_puzzleresponse_ocr_confidence_score_and_more"),
]
operations = [
migrations.AddField(
model_name='submission',
name='manual_validation_requested',
field=models.BooleanField(default=False, help_text='Whether the user specifically requested manual validation'),
model_name="submission",
name="manual_validation_requested",
field=models.BooleanField(
default=False,
help_text="Whether the user specifically requested manual validation",
),
),
]

View File

@ -1,9 +1,7 @@
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()
@ -242,11 +240,11 @@ class Submission(models.Model):
validated_at = models.DateTimeField(
null=True, blank=True, help_text="When this submission was validated"
)
# Manual validation request
manual_validation_requested = models.BooleanField(
default=False,
help_text="Whether the user specifically requested manual validation"
default=False,
help_text="Whether the user specifically requested manual validation",
)
# Timestamps

View File

@ -1,5 +1,4 @@
from ninja import Schema, ModelSchema, File
from ninja.files import UploadedFile
from ninja import Schema, ModelSchema
from typing import List, Optional
from datetime import datetime
from uuid import UUID

View File

@ -1,3 +1 @@
from django.test import TestCase
# Create your tests here.

View File

@ -4,6 +4,7 @@ Utilities for fetching Steam Workshop collection data using Steam Web API
import re
import requests
from submissions.models import SteamCollection
from datetime import datetime
from django.utils import timezone
from django.conf import settings
@ -20,22 +21,28 @@ class SteamAPIClient:
def __init__(self, api_key: Optional[str] = None):
# Priority: parameter > database > settings > environment
self.api_key = api_key or self._get_api_key_from_db() or getattr(settings, "STEAM_API_KEY", None)
self.api_key = (
api_key
or self._get_api_key_from_db()
or getattr(settings, "STEAM_API_KEY", None)
)
self.session = requests.Session()
if not self.api_key:
logger.warning("No Steam API key provided. Some features may be limited.")
def _get_api_key_from_db(self) -> Optional[str]:
"""Get active API key from database"""
try:
from .models import SteamAPIKey
api_key_obj = SteamAPIKey.get_active_key()
if api_key_obj:
# Update last_used timestamp
from django.utils import timezone
api_key_obj.last_used = timezone.now()
api_key_obj.save(update_fields=['last_used'])
api_key_obj.save(update_fields=["last_used"])
return api_key_obj.api_key
except Exception as e:
logger.debug(f"Could not fetch API key from database: {e}")
@ -234,55 +241,59 @@ class SteamCollectionFetcher:
def _fetch_collection_items_via_api(self, collection_id: str) -> List[Dict]:
"""
Fetch collection items using GetCollectionDetails API
Args:
collection_id: Steam collection ID
Returns:
List of item dictionaries
"""
items = []
try:
# Use GetCollectionDetails API to get collection items
url = f"{self.api_client.BASE_URL}/ISteamRemoteStorage/GetCollectionDetails/v1/"
data = {
'collectioncount': 1,
'publishedfileids[0]': collection_id
}
data = {"collectioncount": 1, "publishedfileids[0]": collection_id}
response = self.api_client.session.post(url, data=data, timeout=30)
if response.status_code == 200:
collection_response = response.json()
if 'response' in collection_response and 'collectiondetails' in collection_response['response']:
for collection in collection_response['response']['collectiondetails']:
if collection.get('result') == 1 and 'children' in collection:
if (
"response" in collection_response
and "collectiondetails" in collection_response["response"]
):
for collection in collection_response["response"][
"collectiondetails"
]:
if collection.get("result") == 1 and "children" in collection:
# Extract item IDs with their sort order
child_items = []
for child in collection['children']:
if 'publishedfileid' in child:
child_items.append({
'id': str(child['publishedfileid']),
'sort_order': child.get('sortorder', 0)
})
for child in collection["children"]:
if "publishedfileid" in child:
child_items.append(
{
"id": str(child["publishedfileid"]),
"sort_order": child.get("sortorder", 0),
}
)
# Sort by sort order to maintain collection order
child_items.sort(key=lambda x: x['sort_order'])
item_ids = [item['id'] for item in child_items]
child_items.sort(key=lambda x: x["sort_order"])
item_ids = [item["id"] for item in child_items]
if item_ids:
items = self._fetch_items_by_ids(item_ids)
except Exception as e:
logger.error(f"Failed to fetch collection items via API: {e}")
return items
def _fetch_items_by_ids(self, item_ids: List[str]) -> List[Dict]:
"""Fetch item details by their IDs"""
items = []
# Fetch details for all items in batches (Steam API has limits)
batch_size = 20 # Conservative batch size
for i in range(0, len(item_ids), batch_size):
@ -300,7 +311,7 @@ class SteamCollectionFetcher:
):
item_id = item_data.get("publishedfileid", "unknown")
result = item_data.get("result", 0)
if result == 1: # Success
item_info = {
"steam_item_id": str(item_id),
@ -333,14 +344,15 @@ class SteamCollectionFetcher:
items.append(item_info)
else:
# Log failed items
logger.warning(f"Failed to fetch item {item_id}: result={result}, ban_reason={item_data.get('ban_reason', 'N/A')}")
logger.warning(
f"Failed to fetch item {item_id}: result={result}, ban_reason={item_data.get('ban_reason', 'N/A')}"
)
except Exception as e:
logger.error(f"Failed to fetch batch of collection items: {e}")
continue
return items
def fetch_steam_collection(url: str) -> Dict:
@ -357,7 +369,7 @@ def fetch_steam_collection(url: str) -> Dict:
return fetcher.fetch_collection_data(url)
def create_or_update_collection(url: str) -> Tuple["SteamCollection", bool]:
def create_or_update_collection(url: str) -> Tuple[SteamCollection, bool]:
"""
Create or update a Steam collection in the database

View File

@ -1,3 +1 @@
from django.shortcuts import render
# Create your views here.