chore: opus-submitter -> polylan-submitter
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
@@ -0,0 +1,36 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
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"),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
# Add custom fields to the list display
|
||||
list_display = UserAdmin.list_display + ("cas_user_id", "get_cas_groups_display")
|
||||
|
||||
# Add search fields
|
||||
search_fields = UserAdmin.search_fields + ("cas_user_id",)
|
||||
|
||||
# Add filters
|
||||
list_filter = UserAdmin.list_filter + ("cas_groups",)
|
||||
|
||||
# Make CAS fields readonly in admin
|
||||
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"
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "accounts"
|
||||
@@ -0,0 +1,137 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-28 23:41
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
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",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "user",
|
||||
"verbose_name_plural": "users",
|
||||
"abstract": False,
|
||||
},
|
||||
managers=[
|
||||
("objects", django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
|
||||
|
||||
class CustomUser(AbstractUser):
|
||||
"""Custom User model to store CAS attributes from PolyLAN."""
|
||||
|
||||
# Store CAS user ID (the numeric ID from CAS)
|
||||
cas_user_id = models.CharField(max_length=50, blank=True, null=True, unique=True)
|
||||
|
||||
# Store CAS groups as JSON
|
||||
cas_groups = models.JSONField(default=list, blank=True)
|
||||
|
||||
# Additional fields that might come from CAS
|
||||
cas_attributes = models.JSONField(default=dict, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.username} ({self.cas_user_id})"
|
||||
|
||||
def has_cas_group(self, group_name):
|
||||
"""Check if user has a specific CAS group."""
|
||||
return group_name in self.cas_groups
|
||||
|
||||
def get_cas_groups_display(self):
|
||||
"""Get a comma-separated list of CAS groups for display."""
|
||||
return ", ".join(self.cas_groups) if self.cas_groups else "No groups"
|
||||
|
||||
def update_cas_data(self, cas_user_id, attributes):
|
||||
"""Update user with CAS data."""
|
||||
self.cas_user_id = cas_user_id
|
||||
|
||||
# Update basic fields from CAS attributes
|
||||
if "firstname" in attributes:
|
||||
self.first_name = attributes["firstname"]
|
||||
if "lastname" in attributes:
|
||||
self.last_name = attributes["lastname"]
|
||||
if "email" in attributes:
|
||||
self.email = attributes["email"]
|
||||
|
||||
# Store groups
|
||||
if "groups" in attributes:
|
||||
# CAS groups come as a list or single value
|
||||
groups = attributes["groups"]
|
||||
if isinstance(groups, str):
|
||||
self.cas_groups = [groups]
|
||||
elif isinstance(groups, list):
|
||||
self.cas_groups = groups
|
||||
else:
|
||||
self.cas_groups = []
|
||||
|
||||
if "RESPONSABLE_ANIMATION" in self.cas_groups:
|
||||
self.is_staff = True
|
||||
self.is_superuser = True
|
||||
|
||||
# Store all other attributes
|
||||
self.cas_attributes = attributes
|
||||
|
||||
self.save()
|
||||
@@ -0,0 +1 @@
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1 @@
|
||||
# Create your views here.
|
||||
@@ -0,0 +1,59 @@
|
||||
from django.contrib import admin
|
||||
from animations.models import PuzzlePointsFactor, PuzzlePointsValue
|
||||
|
||||
|
||||
@admin.register(PuzzlePointsFactor)
|
||||
class PuzzlePointsFactorAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id",
|
||||
"cost",
|
||||
"cycles",
|
||||
"area",
|
||||
"special_notes",
|
||||
]
|
||||
list_filter = ["cost", "cycles", "area", "special_notes"]
|
||||
search_fields = ["cost", "cycles", "area", "special_notes"]
|
||||
readonly_fields = ["created_at", "updated_at"]
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Basic Information",
|
||||
{"fields": ("cost", "cycles", "area")},
|
||||
),
|
||||
(
|
||||
"Special notes",
|
||||
{
|
||||
"fields": ("special_notes",),
|
||||
"description": "Special notes about the puzzle. May be some extra restriction, etc...",
|
||||
},
|
||||
),
|
||||
(
|
||||
"Metadata",
|
||||
{
|
||||
"fields": ("created_at", "updated_at"),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(PuzzlePointsValue)
|
||||
class PuzzlePointsValueAdmin(admin.ModelAdmin):
|
||||
list_display = ["id", "points"]
|
||||
list_filter = ["points"]
|
||||
search_fields = ["points"]
|
||||
readonly_fields = ["created_at", "updated_at"]
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Basic Information",
|
||||
{"fields": ("points",)},
|
||||
),
|
||||
(
|
||||
"Metadata",
|
||||
{
|
||||
"fields": ("created_at", "updated_at"),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
from django.http.request import HttpRequest
|
||||
from ninja import Router
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from accounts.models import CustomUser
|
||||
from animations.schemas import RankingSchema
|
||||
from submissions.models import PuzzleResponse, SteamCollectionItem
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.get("results", response=RankingSchema)
|
||||
def results(request: HttpRequest) -> dict:
|
||||
responses_by_userid = defaultdict(list)
|
||||
responses_by_puzzleid = defaultdict(list)
|
||||
|
||||
for response in list(
|
||||
PuzzleResponse.objects.filter(needs_manual_validation=False)
|
||||
.filter_user_best_response()
|
||||
.prefetch_related("submission__user")
|
||||
):
|
||||
responses_by_userid[response.submission.user.id].append(response)
|
||||
responses_by_puzzleid[response.puzzle.id].append(response)
|
||||
|
||||
ranking = {}
|
||||
|
||||
for puzzle_id, responses in responses_by_puzzleid.items():
|
||||
ranking[puzzle_id] = sorted(responses, key=lambda x: x.rank_points)
|
||||
|
||||
return {
|
||||
"users": CustomUser.objects.filter(pk__in=responses_by_userid.keys()),
|
||||
"puzzles": SteamCollectionItem.objects.all(),
|
||||
"responses_by_userid": responses_by_userid,
|
||||
"ranking_by_puzzle": ranking,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AnimationsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "animations"
|
||||
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-23 22:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="PuzzlePointsFactor",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("cost", models.IntegerField()),
|
||||
("cycles", models.IntegerField()),
|
||||
("area", models.IntegerField()),
|
||||
("special_notes", models.TextField(blank=True)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-24 01:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("animations", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="PuzzlePointsValue",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("points", models.JSONField(default=[])),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-05 13:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("animations", "0002_puzzlepointsvalue"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="puzzlepointsvalue",
|
||||
name="points",
|
||||
field=models.JSONField(default=list),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class PuzzlePointsFactor(models.Model):
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
cost = models.IntegerField()
|
||||
cycles = models.IntegerField()
|
||||
area = models.IntegerField()
|
||||
|
||||
special_notes = models.TextField(blank=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.cost} - {self.cycles} - {self.area}"
|
||||
|
||||
|
||||
class PuzzlePointsValue(models.Model):
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
points = models.JSONField(default=list)
|
||||
@@ -0,0 +1,37 @@
|
||||
from ninja import ModelSchema, Schema
|
||||
|
||||
from submissions.models import PuzzleResponse
|
||||
from submissions.schemas import SteamCollectionItemOut, UserInfoOut
|
||||
|
||||
|
||||
class PuzzleResponseRankingOut(ModelSchema):
|
||||
class Meta:
|
||||
model = PuzzleResponse
|
||||
fields = [
|
||||
"id",
|
||||
"puzzle_name",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
points: int
|
||||
rank_points: int
|
||||
puzzle_user_rank: int
|
||||
user_response_rank: int
|
||||
|
||||
user_id: int
|
||||
|
||||
final_cost: int | None
|
||||
final_cycles: int | None
|
||||
final_area: int | None
|
||||
|
||||
@staticmethod
|
||||
def resolve_user_id(obj) -> int:
|
||||
return obj.submission.user.id
|
||||
|
||||
|
||||
class RankingSchema(Schema):
|
||||
users: list[UserInfoOut]
|
||||
puzzles: list[SteamCollectionItemOut]
|
||||
responses_by_userid: dict[int, list[PuzzleResponseRankingOut]]
|
||||
ranking_by_puzzle: dict[int, list[PuzzleResponseRankingOut]]
|
||||
@@ -0,0 +1 @@
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1 @@
|
||||
# Create your views here.
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/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", "polylan_submitter.settings")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "polylan_submitter",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@vueuse/core": "^14.0.0",
|
||||
"install": "^0.13.0",
|
||||
"pinia": "^3.0.3",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"tesseract.js": "^5.1.1",
|
||||
"vue": "^3.5.22"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mdi/font": "^7.4.47",
|
||||
"@types/node": "^24.6.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"daisyui": "^5.3.10",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.1.7",
|
||||
"vue-tsc": "^3.1.0"
|
||||
}
|
||||
}
|
||||
Generated
+1547
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
from ninja import NinjaAPI
|
||||
from submissions.api import router as submissions_router
|
||||
from submissions.schemas import UserInfoOut
|
||||
from animations.api import router as results_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.
|
||||
|
||||
The Opus Magnum Submission API allows clients to upload, manage, validate, and review puzzle solution submissions for the Opus Magnum puzzle game community.
|
||||
It provides features for user authentication, puzzle listing, submission uploads, automated and manual OCR validation, and administrative workflows.
|
||||
""",
|
||||
openapi_extra={
|
||||
"info": {
|
||||
"contact": {
|
||||
"name": "Legrems",
|
||||
"email": "loic.gremaud@polylan.ch",
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# 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"])
|
||||
api.add_router("/results/", results_router, tags=["results"])
|
||||
|
||||
|
||||
# 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",
|
||||
"OCR validation",
|
||||
"Manual validation workflow",
|
||||
"Admin validation tools",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# User info endpoint
|
||||
@api.get("/user", response=UserInfoOut)
|
||||
def get_user_info(request):
|
||||
"""Get current user information"""
|
||||
user = request.user
|
||||
|
||||
if user.is_authenticated:
|
||||
return {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"email": user.email,
|
||||
"is_authenticated": True,
|
||||
"is_staff": user.is_staff,
|
||||
"is_superuser": user.is_superuser,
|
||||
"cas_groups": getattr(user, "cas_groups", []),
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"is_authenticated": False,
|
||||
"is_staff": False,
|
||||
"is_superuser": False,
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
ASGI config for polylan_submitter project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polylan_submitter.settings")
|
||||
|
||||
application = get_asgi_application()
|
||||
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Django settings for polylan_submitter project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 5.2.7.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.2/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = "django-insecure-m0ivj_3gpbf281jl1$&n5pdo!5le(bp4z31u(1&4s=n#!tpy=n"
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"django_vite",
|
||||
"accounts",
|
||||
"animations",
|
||||
"submissions",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
# Removed CASMiddleware to avoid conflicts with our custom backend
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "polylan_submitter.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [BASE_DIR / "templates"],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "polylan_submitter.wsgi.application"
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
|
||||
TIME_ZONE = "UTC"
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
||||
|
||||
STATIC_URL = "static/"
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
# Custom User Model
|
||||
AUTH_USER_MODEL = "accounts.CustomUser"
|
||||
|
||||
# Simple CAS Configuration
|
||||
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"
|
||||
|
||||
# File Upload Limits
|
||||
FILE_UPLOAD_MAX_MEMORY_SIZE = 256 * 1024 * 1024 # 256MB
|
||||
DATA_UPLOAD_MAX_MEMORY_SIZE = 256 * 1024 * 1024 # 256MB
|
||||
|
||||
# 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",
|
||||
"simple_cas_backend.SimpleCASBackend",
|
||||
]
|
||||
|
||||
# Login/Logout URLs
|
||||
LOGIN_URL = "/cas/login/"
|
||||
LOGOUT_URL = "/cas/logout/"
|
||||
LOGIN_REDIRECT_URL = "/"
|
||||
LOGOUT_REDIRECT_URL = "/"
|
||||
DJANGO_VITE = {
|
||||
"default": {
|
||||
"dev_mode": False,
|
||||
}
|
||||
}
|
||||
STATIC_URL = "/static/"
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, "static")
|
||||
STATICFILES_DIRS = [
|
||||
os.path.join(BASE_DIR, "static_source"),
|
||||
os.path.join(BASE_DIR, "static_source/vite"),
|
||||
]
|
||||
|
||||
from polylan_submitter.settingsLocal import * # noqa
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
URL configuration for polylan_submitter project.
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/5.2/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
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
|
||||
def home(request: HttpRequest):
|
||||
from submissions.models import SteamCollection
|
||||
|
||||
return render(
|
||||
request,
|
||||
"index.html",
|
||||
{
|
||||
"collection": SteamCollection.objects.filter(is_active=True).last(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for polylan_submitter project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polylan_submitter.settings")
|
||||
|
||||
application = get_wsgi_application()
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Simple CAS 2.0 authentication backend - bare minimum implementation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.backends import BaseBackend
|
||||
|
||||
|
||||
class SimpleCASBackend(BaseBackend):
|
||||
"""Simple CAS 2.0 authentication backend."""
|
||||
|
||||
def authenticate(self, request, ticket=None, service=None, **kwargs):
|
||||
"""Authenticate user using CAS ticket."""
|
||||
if not ticket or not service:
|
||||
return None
|
||||
|
||||
# Validate ticket with CAS server
|
||||
cas_user_id, attributes = self.validate_ticket(ticket, service)
|
||||
|
||||
if not cas_user_id:
|
||||
return None
|
||||
|
||||
print(f"CAS User ID: {cas_user_id}")
|
||||
print(f"CAS Attributes: {attributes}")
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
# Try to find user by CAS user ID first, then by username
|
||||
username = attributes.get("username", cas_user_id).lower()
|
||||
|
||||
try:
|
||||
# First try to find by CAS user ID
|
||||
user = User.objects.get(cas_user_id=cas_user_id)
|
||||
except User.DoesNotExist:
|
||||
try:
|
||||
# Then try by username
|
||||
user = User.objects.get(username=username)
|
||||
# Update the CAS user ID if found by username
|
||||
user.cas_user_id = cas_user_id
|
||||
user.save()
|
||||
except User.DoesNotExist:
|
||||
# Create new user
|
||||
user = User.objects.create_user(
|
||||
username=username,
|
||||
cas_user_id=cas_user_id,
|
||||
first_name=attributes.get("firstname", ""),
|
||||
last_name=attributes.get("lastname", ""),
|
||||
email=attributes.get("email", ""),
|
||||
)
|
||||
|
||||
# Always update CAS data on login
|
||||
user.update_cas_data(cas_user_id, attributes)
|
||||
|
||||
return user
|
||||
|
||||
def validate_ticket(self, ticket, service):
|
||||
"""Validate CAS ticket and return username and attributes."""
|
||||
validate_url = f"{settings.CAS_SERVER_URL.rstrip('/')}/serviceValidate"
|
||||
|
||||
params = {"ticket": ticket, "service": service, "format": "JSON"}
|
||||
|
||||
try:
|
||||
response = requests.get(validate_url, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
data = json.loads(response.text)
|
||||
|
||||
# Parse CAS 2.0 JSON response
|
||||
service_response = data.get("serviceResponse", {})
|
||||
auth_success = service_response.get("authenticationSuccess")
|
||||
|
||||
if auth_success:
|
||||
cas_user_id = auth_success.get("user", "")
|
||||
attributes = auth_success.get("attributes", {})
|
||||
return cas_user_id, attributes
|
||||
|
||||
return None, None
|
||||
|
||||
except Exception as e:
|
||||
print(f"CAS validation error: {e}")
|
||||
return None, None
|
||||
|
||||
def get_user(self, user_id):
|
||||
"""Get user by ID."""
|
||||
User = get_user_model()
|
||||
try:
|
||||
return User.objects.get(pk=user_id)
|
||||
except User.DoesNotExist:
|
||||
return None
|
||||
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Simple CAS views - bare minimum implementation.
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import authenticate, login, logout
|
||||
from django.shortcuts import redirect
|
||||
from django.http import HttpResponse
|
||||
from django.views import View
|
||||
import urllib.parse
|
||||
|
||||
|
||||
class SimpleCASLoginView(View):
|
||||
"""Simple CAS login view."""
|
||||
|
||||
def get(self, request):
|
||||
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
|
||||
|
||||
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
|
||||
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)
|
||||
@@ -0,0 +1,250 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import PuzzleCard from "@/components/PuzzleCard.vue";
|
||||
import SubmissionForm from "@/components/SubmissionForm.vue";
|
||||
import AdminPanel from "@/components/AdminPanel.vue";
|
||||
import Results from "@/components/Results.vue";
|
||||
import { apiService, errorHelpers } from "@/services/apiService";
|
||||
import { usePuzzlesStore } from "@/stores/puzzles";
|
||||
import { useSubmissionsStore } from "@/stores/submissions";
|
||||
import type { PuzzleResponse, UserInfo } from "@/types";
|
||||
import { useCountdown } from "@vueuse/core";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
const props = defineProps<{
|
||||
collectionTitle: string;
|
||||
collectionUrl: string;
|
||||
collectionDescription: string;
|
||||
}>();
|
||||
|
||||
const puzzlesStore = usePuzzlesStore();
|
||||
const submissionsStore = useSubmissionsStore();
|
||||
|
||||
const { submissions, isSubmissionModalOpen } = storeToRefs(submissionsStore);
|
||||
const { openSubmissionModal, loadSubmissions, closeSubmissionModal } =
|
||||
submissionsStore;
|
||||
|
||||
// Local state
|
||||
const userInfo = ref<UserInfo | null>(null);
|
||||
const isLoading = ref(true);
|
||||
const error = ref<string>("");
|
||||
|
||||
// Computed properties
|
||||
const isSuperuser = computed(() => {
|
||||
return userInfo.value?.is_superuser || false;
|
||||
});
|
||||
|
||||
// Computed property to get responses grouped by puzzle
|
||||
const responsesByPuzzle = computed(() => {
|
||||
const grouped: Record<number, PuzzleResponse[]> = {};
|
||||
submissions.value.forEach((submission) => {
|
||||
submission.responses.forEach((response) => {
|
||||
// Handle both number and object types for puzzle field
|
||||
if (!grouped[response.puzzle_id]) {
|
||||
grouped[response.puzzle_id] = [];
|
||||
}
|
||||
grouped[response.puzzle_id].push(response);
|
||||
});
|
||||
});
|
||||
return grouped;
|
||||
});
|
||||
|
||||
async function initialize() {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
error.value = "";
|
||||
|
||||
console.log("Starting data load...");
|
||||
|
||||
// Load user info
|
||||
console.log("Loading user info...");
|
||||
const userResponse = await apiService.getUserInfo();
|
||||
if (userResponse.data) {
|
||||
userInfo.value = userResponse.data;
|
||||
console.log("User info loaded:", userResponse.data);
|
||||
} else if (userResponse.error) {
|
||||
console.warn("User info error:", userResponse.error);
|
||||
}
|
||||
|
||||
// Load puzzles from API using store
|
||||
console.log("Loading puzzles...");
|
||||
await puzzlesStore.loadPuzzles();
|
||||
console.log("Puzzles loaded:", puzzlesStore.puzzles.length);
|
||||
|
||||
// Load existing submissions using store
|
||||
console.log("Loading submissions...");
|
||||
await loadSubmissions();
|
||||
console.log("Submissions loaded:", submissions.value.length);
|
||||
|
||||
console.log("Data load complete!");
|
||||
} catch (err) {
|
||||
error.value = errorHelpers.getErrorMessage(err);
|
||||
console.error("Failed to load data:", err);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
console.log("Loading state set to false");
|
||||
}
|
||||
|
||||
if (userInfo.value?.is_superuser) {
|
||||
start();
|
||||
}
|
||||
}
|
||||
|
||||
const { remaining, start } = useCountdown(60, {
|
||||
onComplete() {
|
||||
initialize();
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await initialize();
|
||||
});
|
||||
|
||||
// Function to match puzzle name from OCR to actual puzzle
|
||||
const findPuzzleByName = (ocrPuzzleName: string) => {
|
||||
return puzzlesStore.findPuzzleByName(ocrPuzzleName);
|
||||
};
|
||||
|
||||
const reloadPage = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-base-200">
|
||||
<!-- Header -->
|
||||
<div class="navbar bg-base-100 shadow-lg">
|
||||
<div class="container mx-auto">
|
||||
<div class="flex-1">
|
||||
<h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</h1>
|
||||
</div>
|
||||
<div class="flex items-start justify-between">
|
||||
<div
|
||||
v-if="userInfo?.is_authenticated"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<div class="text-sm">
|
||||
<span class="font-medium">{{ userInfo.username }}</span>
|
||||
<span
|
||||
v-if="userInfo.is_superuser"
|
||||
class="badge badge-warning badge-xs ml-1"
|
||||
>Admin</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-base-content/70">Not logged in</div>
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<a href="/api/docs" class="btn btn-xs">API docs</a>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<a href="/admin" class="btn btn-xs btn-warning">Admin panel</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Loading State -->
|
||||
<div v-if="userInfo?.is_superuser" class="flex justify-center">
|
||||
<div class="text-center">
|
||||
<p class="mb-6 text-base-content/70">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
Auto reload page in {{ remaining }} seconds ...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex justify-center items-center min-h-[400px]"
|
||||
>
|
||||
<div class="text-center">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
<p class="mt-4 text-base-content/70">Loading puzzles...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="alert alert-error max-w-2xl mx-auto">
|
||||
<i class="mdi mdi-alert-circle text-xl"></i>
|
||||
<div>
|
||||
<h3 class="font-bold">Error Loading Data</h3>
|
||||
<div class="text-sm">{{ error }}</div>
|
||||
</div>
|
||||
<button @click="reloadPage" class="btn btn-sm btn-outline">
|
||||
<i class="mdi mdi-refresh mr-1"></i>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div v-else class="space-y-8">
|
||||
<!-- Collection Info -->
|
||||
<div class="mb-8">
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl">{{ props.collectionTitle }}</h2>
|
||||
<p class="text-base-content/70">
|
||||
{{ props.collectionDescription }}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-4 mt-4">
|
||||
<button @click="openSubmissionModal" class="btn btn-primary">
|
||||
<i class="mdi mdi-plus mr-2"></i>
|
||||
Submit Solution
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Results />
|
||||
|
||||
<!-- Admin Panel (only for superusers) -->
|
||||
<div v-if="isSuperuser">
|
||||
<AdminPanel />
|
||||
</div>
|
||||
|
||||
<!-- Puzzles Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<PuzzleCard
|
||||
v-for="puzzle in puzzlesStore.puzzles"
|
||||
:key="puzzle.id"
|
||||
:puzzle="puzzle"
|
||||
:responses="responsesByPuzzle[puzzle.id] || []"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="puzzlesStore.puzzles.length === 0" class="text-center py-12">
|
||||
<div class="text-6xl mb-4">🧩</div>
|
||||
<h3 class="text-xl font-bold mb-2">No Puzzles Available</h3>
|
||||
<p class="text-base-content/70">
|
||||
Check back later for new puzzle collections!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submission Modal -->
|
||||
<div v-if="isSubmissionModalOpen" class="modal modal-open">
|
||||
<div class="modal-box max-w-6xl">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="font-bold text-lg">Submit Solution</h3>
|
||||
<button
|
||||
@click="closeSubmissionModal"
|
||||
class="btn btn-sm btn-circle btn-ghost"
|
||||
>
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SubmissionForm
|
||||
:puzzles="puzzlesStore.puzzles"
|
||||
:find-puzzle-by-name="findPuzzleByName"
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-backdrop" @click="closeSubmissionModal"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,441 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<i class="mdi mdi-shield-account text-2xl text-warning"></i>
|
||||
Admin Panel
|
||||
</h2>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats stats-vertical lg:stats-horizontal shadow mb-6">
|
||||
<div class="stat">
|
||||
<div class="stat-title">Total Submissions</div>
|
||||
<div class="stat-value text-primary">
|
||||
{{ stats.total_submissions }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Total Responses</div>
|
||||
<div class="stat-value text-secondary">
|
||||
{{ stats.total_responses }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Need Validation</div>
|
||||
<div class="stat-value text-warning">
|
||||
{{ stats.needs_validation }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Validation Rate</div>
|
||||
<div class="stat-value text-success">
|
||||
{{ Math.round(stats.validation_rate * 100) }}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-sm btn-primary" @click="autoValidationResponse">
|
||||
<i class="mdi mdi-check-circle mr-1"></i>
|
||||
Auto validation for all responses
|
||||
</button>
|
||||
|
||||
<!-- Responses Needing Validation -->
|
||||
<div v-if="responsesNeedingValidation.length > 0">
|
||||
<h3 class="text-lg font-bold mb-4">Responses Needing Validation</h3>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Puzzle</th>
|
||||
<th>OCR Data</th>
|
||||
<th>Confidence</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="response in responsesNeedingValidation"
|
||||
:key="response.id"
|
||||
>
|
||||
<td>
|
||||
<div class="font-bold">{{ response.puzzle_name }}</div>
|
||||
<div class="text-sm opacity-50">ID: {{ response.id }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-sm space-y-1">
|
||||
<div class="flex justify-between items-center">
|
||||
<span>Cost: {{ response.cost || "-" }}</span>
|
||||
<span
|
||||
v-if="response.ocr_confidence_cost"
|
||||
class="badge badge-xs"
|
||||
:class="
|
||||
getConfidenceBadgeClass(response.ocr_confidence_cost)
|
||||
"
|
||||
>
|
||||
{{ Math.round(response.ocr_confidence_cost * 100) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span>Cycles: {{ response.cycles || "-" }}</span>
|
||||
<span
|
||||
v-if="response.ocr_confidence_cycles"
|
||||
class="badge badge-xs"
|
||||
:class="
|
||||
getConfidenceBadgeClass(
|
||||
response.ocr_confidence_cycles,
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ Math.round(response.ocr_confidence_cycles * 100) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span>Area: {{ response.area || "-" }}</span>
|
||||
<span
|
||||
v-if="response.ocr_confidence_area"
|
||||
class="badge badge-xs"
|
||||
:class="
|
||||
getConfidenceBadgeClass(response.ocr_confidence_area)
|
||||
"
|
||||
>
|
||||
{{ Math.round(response.ocr_confidence_area * 100) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="badge badge-warning badge-sm">
|
||||
{{ getOverallConfidence(response) }}%
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
@click="openValidationModal(response)"
|
||||
class="btn btn-sm btn-primary mr-2"
|
||||
>
|
||||
<i class="mdi mdi-check-circle mr-1"></i>
|
||||
Validate
|
||||
</button>
|
||||
<button
|
||||
v-if="response.id"
|
||||
@click="autoValidation(response.id)"
|
||||
class="btn btn-sm btn-warning"
|
||||
>
|
||||
<i class="mdi mdi-check-circle mr-1"></i>
|
||||
Auto Validation
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-8">
|
||||
<i class="mdi mdi-check-all text-6xl text-success opacity-50"></i>
|
||||
<p class="text-lg font-medium mt-2">All responses validated!</p>
|
||||
<p class="text-sm opacity-70">
|
||||
No responses currently need manual validation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Validation Modal -->
|
||||
<div v-if="validationModal.show" class="modal modal-open">
|
||||
<div class="modal-box w-11/12 max-w-5xl">
|
||||
<h3 class="font-bold text-lg mb-4">Validate Response</h3>
|
||||
|
||||
<div v-for="file in validationModal.response?.files ?? []">
|
||||
<img :src="file.file_url" />
|
||||
</div>
|
||||
|
||||
<div v-if="validationModal.response" class="space-y-4">
|
||||
<div class="alert alert-info">
|
||||
<i class="mdi mdi-information-outline"></i>
|
||||
<div>
|
||||
<div class="font-bold">
|
||||
{{ validationModal.response.puzzle_name }}
|
||||
</div>
|
||||
<div class="text-sm">Review and correct the OCR data below</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Puzzle</span>
|
||||
</label>
|
||||
<select
|
||||
v-model="validationModal.data.puzzle"
|
||||
class="select select-bordered select-sm w-full"
|
||||
>
|
||||
<option value="">Select puzzle...</option>
|
||||
<option
|
||||
v-for="puzzle in puzzlesStore.puzzles"
|
||||
:key="puzzle.id"
|
||||
:value="puzzle.id"
|
||||
>
|
||||
{{ puzzle.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Cost</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="validationModal.data.validated_cost"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:placeholder="
|
||||
validationModal.response.cost?.toString() || 'Enter cost'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Cycles</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="validationModal.data.validated_cycles"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:placeholder="
|
||||
validationModal.response.cycles?.toString() || 'Enter cycles'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Area</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="validationModal.data.validated_area"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:placeholder="
|
||||
validationModal.response.area?.toString() || 'Enter area'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button @click="closeValidationModal" class="btn btn-ghost">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="submitValidation"
|
||||
class="btn btn-primary"
|
||||
:disabled="isValidating"
|
||||
>
|
||||
<span
|
||||
v-if="isValidating"
|
||||
class="loading loading-spinner loading-sm"
|
||||
></span>
|
||||
{{ isValidating ? "Validating..." : "Validate" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mockup-code w-full">
|
||||
<pre><code>{{ validationModal}}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop" @click="closeValidationModal"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { apiService } from "@/services/apiService";
|
||||
import type { PuzzleResponse } from "@/types";
|
||||
import { usePuzzlesStore } from "@/stores/puzzles";
|
||||
const puzzlesStore = usePuzzlesStore();
|
||||
|
||||
// Reactive data
|
||||
const stats = ref({
|
||||
total_submissions: 0,
|
||||
total_responses: 0,
|
||||
needs_validation: 0,
|
||||
validated_submissions: 0,
|
||||
validation_rate: 0,
|
||||
});
|
||||
|
||||
const responsesNeedingValidation = ref<PuzzleResponse[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const isValidating = ref(false);
|
||||
|
||||
const validationModal = ref({
|
||||
show: false,
|
||||
response: null as PuzzleResponse | null,
|
||||
data: {
|
||||
puzzle: -1,
|
||||
validated_cost: 0,
|
||||
validated_cycles: 0,
|
||||
validated_area: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Methods
|
||||
const loadData = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
|
||||
// Load stats (skip if endpoint doesn't exist)
|
||||
try {
|
||||
const statsResponse = await apiService.getStats();
|
||||
if (statsResponse.data) {
|
||||
stats.value = statsResponse.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Stats endpoint not available:", error);
|
||||
// Set default stats
|
||||
stats.value = {
|
||||
total_submissions: 0,
|
||||
total_responses: 0,
|
||||
needs_validation: 0,
|
||||
validated_submissions: 0,
|
||||
validation_rate: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Load responses needing validation
|
||||
const responsesResponse = await apiService.getResponsesNeedingValidation();
|
||||
if (responsesResponse.data) {
|
||||
responsesNeedingValidation.value = responsesResponse.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load admin data:", error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const autoValidationResponse = async () => {
|
||||
for (const response of Array.from(responsesNeedingValidation.value)) {
|
||||
if (!response.id) {
|
||||
continue;
|
||||
}
|
||||
const { data, error } = await apiService.autoValidateResponses(response.id);
|
||||
|
||||
if (data && !data.needs_manual_validation) {
|
||||
// Remove from validation list
|
||||
responsesNeedingValidation.value =
|
||||
responsesNeedingValidation.value.filter((r) => r.id !== response.id);
|
||||
stats.value.needs_validation -= 1;
|
||||
} else if (error) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openValidationModal = (response: PuzzleResponse) => {
|
||||
validationModal.value.response = response;
|
||||
validationModal.value.data = {
|
||||
puzzle: response.puzzle_id || -1,
|
||||
validated_cost: response.cost || 0,
|
||||
validated_cycles: response.cycles || 0,
|
||||
validated_area: response.area || 0,
|
||||
};
|
||||
validationModal.value.show = true;
|
||||
};
|
||||
|
||||
const closeValidationModal = () => {
|
||||
validationModal.value.show = false;
|
||||
validationModal.value.response = null;
|
||||
validationModal.value.data = {
|
||||
puzzle: -1,
|
||||
validated_cost: 0,
|
||||
validated_cycles: 0,
|
||||
validated_area: 0,
|
||||
};
|
||||
};
|
||||
|
||||
const autoValidation = async (id: number) => {
|
||||
const { data } = await apiService.autoValidateResponses(id);
|
||||
console.log(data);
|
||||
|
||||
if (data && !data.needs_manual_validation) {
|
||||
// Remove from validation list
|
||||
responsesNeedingValidation.value = responsesNeedingValidation.value.filter(
|
||||
(r) => r.id !== id,
|
||||
);
|
||||
console.log(stats.value);
|
||||
stats.value.needs_validation -= 1;
|
||||
console.log(stats.value);
|
||||
}
|
||||
};
|
||||
|
||||
const submitValidation = async () => {
|
||||
if (!validationModal.value.response?.id) return;
|
||||
|
||||
try {
|
||||
isValidating.value = true;
|
||||
|
||||
const response = await apiService.validateResponse(
|
||||
validationModal.value.response.id,
|
||||
validationModal.value.data,
|
||||
);
|
||||
|
||||
if (response.error) {
|
||||
alert(`Validation failed: ${response.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove from validation list
|
||||
responsesNeedingValidation.value = responsesNeedingValidation.value.filter(
|
||||
(r) => r.id !== validationModal.value.response?.id,
|
||||
);
|
||||
|
||||
// Update stats
|
||||
stats.value.needs_validation = Math.max(
|
||||
0,
|
||||
stats.value.needs_validation - 1,
|
||||
);
|
||||
|
||||
closeValidationModal();
|
||||
} catch (error) {
|
||||
console.error("Validation error:", error);
|
||||
alert("Validation failed");
|
||||
} finally {
|
||||
isValidating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
|
||||
// Helper functions for confidence display
|
||||
const getConfidenceBadgeClass = (confidence: number): string => {
|
||||
if (confidence >= 0.8) return "badge-success";
|
||||
if (confidence >= 0.6) return "badge-warning";
|
||||
return "badge-error";
|
||||
};
|
||||
|
||||
const getOverallConfidence = (response: PuzzleResponse): number => {
|
||||
const confidences = [
|
||||
response.ocr_confidence_cost,
|
||||
response.ocr_confidence_cycles,
|
||||
response.ocr_confidence_area,
|
||||
].filter((conf) => conf !== undefined && conf !== null) as number[];
|
||||
|
||||
if (confidences.length === 0) return 0;
|
||||
|
||||
const average =
|
||||
confidences.reduce((sum, conf) => sum + conf, 0) / confidences.length;
|
||||
return Math.round(average * 100);
|
||||
};
|
||||
|
||||
// Expose refresh method
|
||||
defineExpose({
|
||||
refresh: loadData,
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,368 @@
|
||||
<template>
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Upload Solution Files</span>
|
||||
<span class="label-text-alt text-xs">Images or GIFs only</span>
|
||||
</label>
|
||||
|
||||
<div
|
||||
class="border-2 border-dashed border-base-300 rounded-lg p-6 text-center hover:border-primary transition-colors duration-300"
|
||||
:class="{ 'border-primary bg-primary/5': isDragOver }"
|
||||
@drop="handleDrop"
|
||||
@dragover.prevent="isDragOver = true"
|
||||
@dragleave="isDragOver = false"
|
||||
@dragenter.prevent
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,.gif"
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
|
||||
<div v-if="submissionFiles.length === 0" class="space-y-4">
|
||||
<div
|
||||
class="mx-auto w-12 h-12 text-base-content/40 flex items-center justify-center"
|
||||
>
|
||||
<i class="mdi mdi-cloud-upload text-5xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-base-content/70 mb-2">Drop your files here or</p>
|
||||
<button
|
||||
type="button"
|
||||
@click="fileInput?.click()"
|
||||
class="btn btn-primary btn-sm"
|
||||
>
|
||||
Choose Files
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/50">
|
||||
Supported formats: JPG, PNG, GIF (max 256MB each)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="(file, index) in submissionFiles"
|
||||
:key="index"
|
||||
class="relative group"
|
||||
>
|
||||
<div class="aspect-square rounded-lg overflow-hidden bg-base-200">
|
||||
<img
|
||||
:src="file.preview"
|
||||
:alt="file.file.name"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 bg-black/80 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center"
|
||||
>
|
||||
<button
|
||||
@click="removeFile(index)"
|
||||
class="btn btn-error btn-lg btn-circle"
|
||||
>
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<p class="text-xs font-medium truncate">{{ file.file.name }}</p>
|
||||
<p class="text-xs text-base-content/60">
|
||||
{{ formatFileSize(file.file.size) }} •
|
||||
{{ file.type.toUpperCase() }}
|
||||
</p>
|
||||
|
||||
<!-- OCR Status and Results -->
|
||||
<div
|
||||
v-if="file.ocrProcessing"
|
||||
class="mt-1 flex items-center gap-1"
|
||||
>
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
<span class="text-xs text-info">Extracting puzzle data...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="file.ocrError" class="mt-1">
|
||||
<p class="text-xs text-error">{{ file.ocrError }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="file.ocrData" class="mt-1 space-y-1">
|
||||
<div class="text-xs flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-success">✓ OCR Complete</span>
|
||||
<span
|
||||
v-if="file.ocrData.confidence"
|
||||
class="badge badge-xs"
|
||||
:class="
|
||||
getConfidenceBadgeClass(file.ocrData.confidence.overall)
|
||||
"
|
||||
:title="`Overall confidence: ${Math.round(file.ocrData.confidence.overall * 100)}%`"
|
||||
>
|
||||
{{ Math.round(file.ocrData.confidence.overall * 100) }}%
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
@click="processOCR(file)"
|
||||
class="btn btn-xs btn-ghost"
|
||||
title="Retry OCR"
|
||||
>
|
||||
<i class="mdi mdi-refresh"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-xs space-y-1 bg-base-200 p-2 rounded">
|
||||
<div v-if="file.ocrData.puzzle">
|
||||
<strong>Puzzle:</strong> {{ file.ocrData.puzzle }}
|
||||
<span
|
||||
v-if="file.ocrData.confidence?.puzzle"
|
||||
class="ml-2 opacity-60"
|
||||
:title="`Puzzle confidence: ${Math.round(file.ocrData.confidence.puzzle * 100)}%`"
|
||||
>
|
||||
({{ Math.round(file.ocrData.confidence.puzzle * 100) }}%)
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="file.ocrData.cost">
|
||||
<strong>Cost:</strong> {{ file.ocrData.cost }}
|
||||
<span
|
||||
v-if="file.ocrData.confidence?.cost"
|
||||
class="ml-2 opacity-60"
|
||||
:title="`Cost confidence: ${Math.round(file.ocrData.confidence.cost * 100)}%`"
|
||||
>
|
||||
({{ Math.round(file.ocrData.confidence.cost * 100) }}%)
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="file.ocrData.cycles">
|
||||
<strong>Cycles:</strong> {{ file.ocrData.cycles }}
|
||||
<span
|
||||
v-if="file.ocrData.confidence?.cycles"
|
||||
class="ml-2 opacity-60"
|
||||
:title="`Cycles confidence: ${Math.round(file.ocrData.confidence.cycles * 100)}%`"
|
||||
>
|
||||
({{ Math.round(file.ocrData.confidence.cycles * 100) }}%)
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="file.ocrData.area">
|
||||
<strong>Area:</strong> {{ file.ocrData.area }}
|
||||
<span
|
||||
v-if="file.ocrData.confidence?.area"
|
||||
class="ml-2 opacity-60"
|
||||
:title="`Area confidence: ${Math.round(file.ocrData.confidence.area * 100)}%`"
|
||||
>
|
||||
({{ Math.round(file.ocrData.confidence.area * 100) }}%)
|
||||
</span>
|
||||
</div>
|
||||
</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
|
||||
@click="processOCR(file)"
|
||||
class="btn btn-xs btn-outline"
|
||||
>
|
||||
<i class="mdi mdi-text-recognition"></i>
|
||||
Extract Puzzle Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
@click="fileInput?.click()"
|
||||
class="btn btn-outline btn-sm"
|
||||
>
|
||||
Add More Files
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="label">
|
||||
<span class="label-text-alt text-error">{{ error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from "vue";
|
||||
import { ocrService } from "@/services/ocrService";
|
||||
import { usePuzzlesStore } from "@/stores/puzzles";
|
||||
import { useUploadsStore } from "@/stores/uploads";
|
||||
import type { SubmissionFile } from "@/types";
|
||||
|
||||
// Pinia store
|
||||
const puzzlesStore = usePuzzlesStore();
|
||||
const { submissionFiles, processOCR } = useUploadsStore();
|
||||
|
||||
const fileInput = ref<HTMLInputElement>();
|
||||
const isDragOver = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
// Watch for puzzle changes and update OCR service
|
||||
watch(
|
||||
() => puzzlesStore.puzzles,
|
||||
(newPuzzles) => {
|
||||
if (newPuzzles && newPuzzles.length > 0) {
|
||||
ocrService.setAvailablePuzzleNames(puzzlesStore.puzzleNames);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const handleFileSelect = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target.files) {
|
||||
processFiles(Array.from(target.files));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
isDragOver.value = false;
|
||||
|
||||
if (event.dataTransfer?.files) {
|
||||
processFiles(Array.from(event.dataTransfer.files));
|
||||
}
|
||||
};
|
||||
|
||||
const processFiles = async (newFiles: File[]) => {
|
||||
error.value = "";
|
||||
|
||||
for (const file of newFiles) {
|
||||
if (!isValidFile(file)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const preview = await createPreview(file);
|
||||
const fileType = file.type.startsWith("image/gif") ? "gif" : "image";
|
||||
|
||||
const submissionFile: SubmissionFile = {
|
||||
file,
|
||||
file_url: "",
|
||||
preview,
|
||||
type: fileType,
|
||||
ocrProcessing: false,
|
||||
ocrError: undefined,
|
||||
ocrData: undefined,
|
||||
};
|
||||
|
||||
submissionFiles.push(submissionFile);
|
||||
|
||||
// Start OCR processing for Opus Magnum images (with delay to ensure reactivity)
|
||||
if (isOpusMagnumImage(file)) {
|
||||
nextTick(() => {
|
||||
processOCR(submissionFile);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = `Failed to process ${file.name}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isValidFile = (file: File): boolean => {
|
||||
// Check file type
|
||||
if (!file.type.startsWith("image/")) {
|
||||
error.value = `${file.name} is not a valid image file`;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check file size (256MB limit)
|
||||
if (file.size > 256 * 1024 * 1024) {
|
||||
error.value = `${file.name} is too large (max 256MB)`;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const createPreview = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve(e.target?.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
submissionFiles.splice(index, 1);
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
const isOpusMagnumImage = (file: File): boolean => {
|
||||
// Basic heuristic - could be enhanced with actual image analysis
|
||||
return file.type.startsWith("image/") && file.size > 50000; // > 50KB likely screenshot
|
||||
};
|
||||
|
||||
const getConfidenceBadgeClass = (confidence: number): string => {
|
||||
if (confidence >= 0.8) return "badge-success";
|
||||
if (confidence >= 0.6) return "badge-warning";
|
||||
return "badge-error";
|
||||
};
|
||||
|
||||
const onManualPuzzleSelection = (submissionFile: SubmissionFile) => {
|
||||
// Find the file in the reactive array
|
||||
const fileIndex = submissionFiles.findIndex(
|
||||
(f) => f.file === submissionFile.file,
|
||||
);
|
||||
if (fileIndex === -1) return;
|
||||
|
||||
// Clear the manual selection requirement once user has selected
|
||||
if (submissionFiles[fileIndex].manualPuzzleSelection) {
|
||||
submissionFiles[fileIndex].needsManualPuzzleSelection = false;
|
||||
console.log(
|
||||
`Manual puzzle selection: ${submissionFile.file.name} -> ${submissionFiles[fileIndex].manualPuzzleSelection}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<div
|
||||
class="card bg-base-100 shadow-lg hover:shadow-2xl transition-shadow duration-300"
|
||||
:class="responses?.length == 0 ? 'shadow-red-900' : 'shadow-primary-300'"
|
||||
>
|
||||
<div class="card-body">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h3 class="card-title text-lg font-bold">{{ puzzle.title }}</h3>
|
||||
<p class="text-sm text-base-content/70 mb-2">
|
||||
by {{ puzzle.author_name }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="badge badge-primary badge-sm">
|
||||
{{ puzzle.steam_item_id }}
|
||||
</div>
|
||||
<div class="badge badge-ghost badge-sm">ID: {{ puzzle.id }}</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="puzzle.description"
|
||||
class="text-sm text-base-content/80 mb-4"
|
||||
>
|
||||
{{ puzzle.description }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="puzzle.tags && puzzle.tags.length > 0"
|
||||
class="flex flex-wrap gap-1 mb-4"
|
||||
>
|
||||
<span
|
||||
v-for="tag in puzzle.tags.slice(0, 3)"
|
||||
:key="tag"
|
||||
class="badge badge-outline badge-xs"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
<span
|
||||
v-if="puzzle.tags.length > 3"
|
||||
class="badge badge-outline badge-xs"
|
||||
>
|
||||
+{{ puzzle.tags.length - 3 }} more
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<div class="tooltip" data-tip="View on Steam Workshop">
|
||||
<a
|
||||
:href="`https://steamcommunity.com/workshop/filedetails/?id=${puzzle.steam_item_id}`"
|
||||
target="_blank"
|
||||
class="btn btn-ghost btn-sm btn-square"
|
||||
>
|
||||
<i class="mdi mdi-steam text-lg"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Responses Table -->
|
||||
<div v-if="responses && responses.length > 0" class="mt-6">
|
||||
<div class="divider">
|
||||
<span class="text-sm font-medium"
|
||||
>Solutions ({{ responses.length }})</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<table class="table table-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Cost</th>
|
||||
<th>Cycles</th>
|
||||
<th>Area</th>
|
||||
<th>Files</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="response in responses"
|
||||
:key="response.id"
|
||||
class="hover"
|
||||
>
|
||||
<td>
|
||||
<span
|
||||
v-if="response.final_cost || response.cost"
|
||||
class="badge badge-success badge-xs"
|
||||
>
|
||||
{{ response.final_cost || response.cost }}
|
||||
</span>
|
||||
<span v-else class="text-base-content/50">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
v-if="response.final_cycles || response.cycles"
|
||||
class="badge badge-info badge-xs"
|
||||
>
|
||||
{{ response.final_cycles || response.cycles }}
|
||||
</span>
|
||||
<span v-else class="text-base-content/50">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
v-if="response.final_area || response.area"
|
||||
class="badge badge-warning badge-xs"
|
||||
>
|
||||
{{ response.final_area || response.area }}
|
||||
</span>
|
||||
<span v-else class="text-base-content/50">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="badge badge-ghost badge-xs">{{
|
||||
response.files?.length || 0
|
||||
}}</span>
|
||||
<div
|
||||
v-if="response.files?.length"
|
||||
class="tooltip"
|
||||
:data-tip="
|
||||
response.files
|
||||
.map((f) => f.original_filename || f.file?.name)
|
||||
.join(', ')
|
||||
"
|
||||
>
|
||||
<i class="mdi mdi-information-outline text-xs"></i>
|
||||
</div>
|
||||
<div
|
||||
v-if="response.needs_manual_validation"
|
||||
class="tooltip"
|
||||
data-tip="Needs manual validation"
|
||||
>
|
||||
<i class="mdi mdi-alert-circle text-xs text-warning"></i>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No responses state -->
|
||||
<div
|
||||
v-else
|
||||
class="mt-6 text-center py-4 border-2 border-dashed border-base-300 rounded-lg hover:border-primary transition-colors duration-300 cursor-pointer"
|
||||
@click="openSubmissionModal"
|
||||
>
|
||||
<i class="mdi mdi-upload text-2xl text-base-content/40"></i>
|
||||
<p class="text-sm text-base-content/60 mt-2">No solutions yet</p>
|
||||
<p class="text-xs text-base-content/40">
|
||||
Upload solutions using the submit button
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SteamCollectionItem, PuzzleResponse } from "@/types";
|
||||
import { useSubmissionsStore } from "@/stores/submissions";
|
||||
|
||||
interface Props {
|
||||
puzzle: SteamCollectionItem;
|
||||
responses?: PuzzleResponse[];
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
const { openSubmissionModal } = useSubmissionsStore();
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<div class="mb-8">
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl">General Results</h2>
|
||||
<div class="flex flex-wrap gap-4 mt-4">TODO :)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-xl mb-6">
|
||||
<i class="mdi mdi-check-circle text-2xl text-primary"></i>
|
||||
Submit Solution
|
||||
</h2>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||
<!-- Detected Puzzles Summary -->
|
||||
<div
|
||||
v-if="Object.keys(responsesByPuzzle).length > 0"
|
||||
class="alert alert-info"
|
||||
>
|
||||
<i class="mdi mdi-information-outline text-xl"></i>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-bold">
|
||||
Detected Puzzles ({{ Object.keys(responsesByPuzzle).length }})
|
||||
</h4>
|
||||
<div class="text-sm space-y-1 mt-1">
|
||||
<div
|
||||
v-for="(data, puzzleName) in responsesByPuzzle"
|
||||
:key="puzzleName"
|
||||
class="flex justify-between"
|
||||
>
|
||||
<span>{{ puzzleName }}</span>
|
||||
<span class="badge badge-ghost badge-sm ml-2"
|
||||
>{{ data.files.length }} file(s)</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Upload -->
|
||||
<FileUpload />
|
||||
|
||||
<!-- Manual Selection Warning -->
|
||||
<div
|
||||
v-if="submissionFilesNeedingManualSelection.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">
|
||||
{{ submissionFilesNeedingManualSelection.length }} file(s) have
|
||||
low OCR confidence for puzzle names. Please select the correct
|
||||
puzzle for each file before submitting.
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn mt-3 w-full"
|
||||
@click="processLowConfidenceOCRFiles"
|
||||
>
|
||||
<span class="mdi mdi-reload text-2xl"></span>
|
||||
Retry OCR on low confidence puzzle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="form-control">
|
||||
<div class="flex-1">
|
||||
<label class="flex label">
|
||||
<span class="label-text font-medium">Notes (Optional)</span>
|
||||
<span class="label-text-alt">{{ notesLength }}/500</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="notes"
|
||||
class="flex textarea textarea-bordered h-24 w-full resize-none"
|
||||
placeholder="Add any notes about your solution, approach, or interesting findings..."
|
||||
maxlength="500"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual Validation Request -->
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="manualValidationRequested"
|
||||
class="checkbox checkbox-primary"
|
||||
:disabled="hasLowConfidence"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<span class="label-text font-medium"
|
||||
>Request manual validation</span
|
||||
>
|
||||
<div class="label-text-alt text-xs opacity-70 mt-1">
|
||||
Check this if you want an admin to manually review your
|
||||
submission, even if OCR confidence is high.
|
||||
<br />
|
||||
<em
|
||||
>Note: This will be automatically checked if any OCR
|
||||
confidence is below 80%.</em
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="card-actions justify-end">
|
||||
<button type="submit" class="btn btn-primary" :disabled="!canSubmit">
|
||||
<span
|
||||
v-if="isSubmitting"
|
||||
class="loading loading-spinner loading-sm"
|
||||
></span>
|
||||
<span v-if="isSubmitting">Submitting...</span>
|
||||
<span v-else-if="submissionFilesNeedingManualSelection.length > 0">
|
||||
Select Puzzles ({{ submissionFilesNeedingManualSelection.length }}
|
||||
remaining)
|
||||
</span>
|
||||
<span v-else>Submit Solution</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue";
|
||||
import FileUpload from "@/components/FileUpload.vue";
|
||||
import type { SteamCollectionItem, SubmissionFile } from "@/types";
|
||||
import { useUploadsStore } from "@/stores/uploads";
|
||||
import { useSubmissionsStore } from "@/stores/submissions";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
interface Props {
|
||||
puzzles: SteamCollectionItem[];
|
||||
findPuzzleByName: (name: string) => SteamCollectionItem | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const uploadsStore = useUploadsStore();
|
||||
const {
|
||||
submissionFiles,
|
||||
hasLowConfidence,
|
||||
submissionFilesNeedingManualSelection,
|
||||
} = storeToRefs(uploadsStore);
|
||||
const { clearFiles, processLowConfidenceOCRFiles } = uploadsStore;
|
||||
const { handleSubmission } = useSubmissionsStore();
|
||||
|
||||
const notes = ref("");
|
||||
const manualValidationRequested = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const notesLength = computed(() => notes.value.length);
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
const hasFiles = submissionFiles.value.length > 0;
|
||||
const noManualSelectionNeeded = !submissionFiles.value.some(
|
||||
(file) => file.needsManualPuzzleSelection,
|
||||
);
|
||||
|
||||
return hasFiles && !isSubmitting.value && noManualSelectionNeeded;
|
||||
});
|
||||
|
||||
watch(hasLowConfidence, (newValue) => {
|
||||
if (newValue) {
|
||||
manualValidationRequested.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Group files by detected puzzle
|
||||
const responsesByPuzzle = computed(() => {
|
||||
const grouped: Record<
|
||||
string,
|
||||
{ puzzle: SteamCollectionItem | null; files: SubmissionFile[] }
|
||||
> = {};
|
||||
|
||||
submissionFiles.value.forEach((file) => {
|
||||
// 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),
|
||||
files: [],
|
||||
};
|
||||
}
|
||||
grouped[puzzleName].files.push(file);
|
||||
}
|
||||
});
|
||||
|
||||
return grouped;
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit.value) return;
|
||||
|
||||
isSubmitting.value = true;
|
||||
|
||||
try {
|
||||
// Emit the files and notes for the store to handle API submission
|
||||
handleSubmission({
|
||||
files: submissionFiles.value,
|
||||
notes: notes.value.trim() || undefined,
|
||||
manualValidationRequested:
|
||||
hasLowConfidence.value || manualValidationRequested.value,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
clearFiles();
|
||||
notes.value = "";
|
||||
manualValidationRequested.value = false;
|
||||
} catch (error) {
|
||||
console.error("Submission error:", error);
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from '@/App.vue'
|
||||
import { pinia } from '@/stores'
|
||||
import '@/style.css'
|
||||
|
||||
// const app = createApp(App)
|
||||
const selector = "#app"
|
||||
const mountData = document.querySelector<HTMLElement>(selector)
|
||||
const app = createApp(App, { ...mountData?.dataset })
|
||||
app.use(pinia)
|
||||
app.mount(selector)
|
||||
@@ -0,0 +1,300 @@
|
||||
import type {
|
||||
SteamCollectionItem,
|
||||
Submission,
|
||||
PuzzleResponse,
|
||||
SubmissionFile,
|
||||
UserInfo
|
||||
} from '../types'
|
||||
|
||||
// API Configuration
|
||||
const API_BASE_URL = '/api'
|
||||
|
||||
// API Response Types
|
||||
interface ApiResponse<T> {
|
||||
data?: T
|
||||
error?: string
|
||||
status: number
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
items: T[]
|
||||
count: number
|
||||
}
|
||||
|
||||
interface SubmissionStats {
|
||||
total_submissions: number
|
||||
total_responses: number
|
||||
needs_validation: number
|
||||
validated_submissions: number
|
||||
validation_rate: number
|
||||
}
|
||||
|
||||
// API Service Class
|
||||
export class ApiService {
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
error: data.detail || `HTTP ${response.status}`,
|
||||
status: response.status
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
status: response.status
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Network error',
|
||||
status: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadRequest<T>(
|
||||
endpoint: string,
|
||||
formData: FormData
|
||||
): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
error: data.detail || `HTTP ${response.status}`,
|
||||
status: response.status
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
status: response.status
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Network error',
|
||||
status: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Puzzle endpoints
|
||||
async getPuzzles(): Promise<ApiResponse<SteamCollectionItem[]>> {
|
||||
return this.request<SteamCollectionItem[]>('/submissions/puzzles')
|
||||
}
|
||||
|
||||
// Submission endpoints
|
||||
async getSubmissions(limit = 20, offset = 0): Promise<ApiResponse<PaginatedResponse<Submission>>> {
|
||||
return this.request<PaginatedResponse<Submission>>(
|
||||
`/submissions/submissions?limit=${limit}&offset=${offset}`
|
||||
)
|
||||
}
|
||||
|
||||
async getSubmission(id: string): Promise<ApiResponse<Submission>> {
|
||||
return this.request<Submission>(`/submissions/submissions/${id}`)
|
||||
}
|
||||
|
||||
async createSubmission(
|
||||
submissionData: {
|
||||
notes?: string
|
||||
manual_validation_requested?: boolean
|
||||
responses: Array<{
|
||||
puzzle_id: number
|
||||
puzzle_name: string
|
||||
cost?: number
|
||||
cycles?: number
|
||||
area?: number
|
||||
needs_manual_validation?: boolean
|
||||
ocr_confidence_cost?: number
|
||||
ocr_confidence_cycles?: number
|
||||
ocr_confidence_area?: number
|
||||
}>
|
||||
},
|
||||
files: File[]
|
||||
): Promise<ApiResponse<Submission>> {
|
||||
const formData = new FormData()
|
||||
|
||||
// Add JSON data
|
||||
formData.append('data', JSON.stringify(submissionData))
|
||||
|
||||
// Add files
|
||||
files.forEach((file) => {
|
||||
formData.append('files', file)
|
||||
})
|
||||
|
||||
return this.uploadRequest<Submission>('/submissions/submissions', formData)
|
||||
}
|
||||
|
||||
// Admin endpoints (require staff permissions)
|
||||
async validateResponse(
|
||||
responseId: number,
|
||||
validationData: {
|
||||
validated_cost?: number
|
||||
validated_cycles?: number
|
||||
validated_area?: number
|
||||
}
|
||||
): Promise<ApiResponse<PuzzleResponse>> {
|
||||
return this.request<PuzzleResponse>(`/submissions/responses/${responseId}/validate`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(validationData),
|
||||
})
|
||||
}
|
||||
|
||||
async autoValidateResponses(responseId: number): Promise<ApiResponse<PuzzleResponse>> {
|
||||
return this.request<PuzzleResponse>(`/submissions/responses/${responseId}/validate/auto`, {
|
||||
method: 'PUT',
|
||||
})
|
||||
}
|
||||
|
||||
async getResponsesNeedingValidation(): Promise<ApiResponse<PuzzleResponse[]>> {
|
||||
return this.request<PuzzleResponse[]>('/submissions/responses/needs-validation')
|
||||
}
|
||||
|
||||
async validateSubmission(submissionId: string): Promise<ApiResponse<Submission>> {
|
||||
return this.request<Submission>(`/submissions/submissions/${submissionId}/validate`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async deleteSubmission(submissionId: string): Promise<ApiResponse<{ detail: string }>> {
|
||||
return this.request<{ detail: string }>(`/submissions/submissions/${submissionId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
// Statistics endpoint
|
||||
async getStats(): Promise<ApiResponse<SubmissionStats>> {
|
||||
return this.request<SubmissionStats>('/submissions/stats')
|
||||
}
|
||||
|
||||
// Health check
|
||||
async healthCheck(): Promise<ApiResponse<{ status: string; service: string }>> {
|
||||
return this.request<{ status: string; service: string }>('/health')
|
||||
}
|
||||
|
||||
// User info
|
||||
async getUserInfo(): Promise<ApiResponse<UserInfo>> {
|
||||
return this.request<UserInfo>('/user')
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const apiService = new ApiService()
|
||||
|
||||
// Helper functions for common operations
|
||||
export const puzzleHelpers = {
|
||||
async loadPuzzles(): Promise<SteamCollectionItem[]> {
|
||||
const response = await apiService.getPuzzles()
|
||||
if (response.error) {
|
||||
console.error('Failed to load puzzles:', response.error)
|
||||
return []
|
||||
}
|
||||
return response.data || []
|
||||
},
|
||||
|
||||
findPuzzleByName(puzzles: SteamCollectionItem[], name: string): SteamCollectionItem | null {
|
||||
if (!name) return null
|
||||
|
||||
// Try exact match first
|
||||
let match = puzzles.find(p =>
|
||||
p.title.toLowerCase() === name.toLowerCase()
|
||||
)
|
||||
|
||||
if (!match) {
|
||||
// Try partial match
|
||||
match = puzzles.find(p =>
|
||||
p.title.toLowerCase().includes(name.toLowerCase()) ||
|
||||
name.toLowerCase().includes(p.title.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
return match || null
|
||||
}
|
||||
}
|
||||
|
||||
export const submissionHelpers = {
|
||||
async createFromFiles(
|
||||
files: SubmissionFile[],
|
||||
puzzles: SteamCollectionItem[],
|
||||
notes?: string,
|
||||
manualValidationRequested?: boolean
|
||||
): Promise<ApiResponse<Submission>> {
|
||||
|
||||
const responses = files.map(item => {
|
||||
|
||||
const puzzle = puzzleHelpers.findPuzzleByName(puzzles, item.ocrData?.puzzle || '')
|
||||
if (!puzzle) { return }
|
||||
return {
|
||||
puzzle_id: puzzle.id,
|
||||
puzzle_name: item.ocrData?.puzzle || '',
|
||||
cost: item.ocrData?.cost,
|
||||
cycles: item.ocrData?.cycles,
|
||||
area: item.ocrData?.area,
|
||||
needs_manual_validation: (item.ocrData?.confidence.overall ?? 0) <= 0.8,
|
||||
ocr_confidence_cost: item.ocrData?.confidence?.cost || 0.0,
|
||||
ocr_confidence_cycles: item.ocrData?.confidence?.cycles || 0.0,
|
||||
ocr_confidence_area: item.ocrData?.confidence?.area || 0.0
|
||||
}
|
||||
}).filter(item => item !== undefined)
|
||||
|
||||
// Extract actual File objects for upload
|
||||
const fileObjects = files.map(f => f.file)
|
||||
|
||||
return apiService.createSubmission({
|
||||
notes,
|
||||
manual_validation_requested: manualValidationRequested,
|
||||
responses
|
||||
}, fileObjects)
|
||||
},
|
||||
|
||||
async loadSubmissions(limit = 20, offset = 0): Promise<Submission[]> {
|
||||
const response = await apiService.getSubmissions(limit, offset)
|
||||
if (response.error) {
|
||||
console.error('Failed to load submissions:', response.error)
|
||||
return []
|
||||
}
|
||||
return response.data?.items || []
|
||||
}
|
||||
}
|
||||
|
||||
// Error handling utilities
|
||||
export const errorHelpers = {
|
||||
getErrorMessage(error: unknown): string {
|
||||
if (typeof error === 'string') return error
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'object' && error !== null && 'detail' in error) {
|
||||
return String((error as any).detail)
|
||||
}
|
||||
return 'An unknown error occurred'
|
||||
},
|
||||
|
||||
isNetworkError(error: unknown): boolean {
|
||||
return typeof error === 'string' && error.includes('Network')
|
||||
},
|
||||
|
||||
isValidationError(status: number): boolean {
|
||||
return status === 400
|
||||
},
|
||||
|
||||
isAuthError(status: number): boolean {
|
||||
return status === 401 || status === 403
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
import { OpusMagnumData } from '@/types';
|
||||
import { createWorker } from 'tesseract.js';
|
||||
|
||||
export interface OpusMagnumOCRData {
|
||||
puzzle: string;
|
||||
cost: string;
|
||||
cycles: string;
|
||||
area: string;
|
||||
confidence: {
|
||||
puzzle: number;
|
||||
cost: number;
|
||||
cycles: number;
|
||||
area: number;
|
||||
overall: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OCRRegion {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export class OpusMagnumOCRService {
|
||||
private worker: Tesseract.Worker | null = null;
|
||||
private availablePuzzleNames: string[] = [];
|
||||
|
||||
// Regions based on main.py coordinates (adjusted for web usage)
|
||||
private readonly regions: Record<string, OCRRegion> = {
|
||||
puzzle: { x: 15, y: 600, width: 330, height: 28 },
|
||||
cost: { x: 412, y: 603, width: 65, height: 22 },
|
||||
cycles: { x: 577, y: 603, width: 65, height: 22 },
|
||||
area: { x: 739, y: 603, width: 65, height: 22 }
|
||||
};
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.worker) return;
|
||||
|
||||
this.worker = await createWorker('eng');
|
||||
await this.worker.setParameters({
|
||||
tessedit_ocr_engine_mode: '3',
|
||||
tessedit_pageseg_mode: 7 as any
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the list of available puzzle names for better OCR matching
|
||||
*/
|
||||
setAvailablePuzzleNames(puzzleNames: string[]): void {
|
||||
this.availablePuzzleNames = puzzleNames;
|
||||
console.log('OCR service updated with puzzle names:', puzzleNames);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure OCR specifically for puzzle name recognition
|
||||
* Uses aggressive character whitelisting and dictionary constraints
|
||||
*/
|
||||
private async configurePuzzleOCR(): Promise<void> {
|
||||
if (!this.worker) return;
|
||||
|
||||
// Configure Tesseract for maximum constraint to our puzzle names
|
||||
await this.worker.setParameters({
|
||||
// Disable all system dictionaries to prevent interference
|
||||
load_system_dawg: '0',
|
||||
load_freq_dawg: '0',
|
||||
load_punc_dawg: '0',
|
||||
load_number_dawg: '0',
|
||||
load_unambig_dawg: '0',
|
||||
load_bigram_dawg: '0',
|
||||
load_fixed_length_dawgs: '0',
|
||||
|
||||
// Use only characters from our puzzle names
|
||||
tessedit_char_whitelist: this.getPuzzleCharacterSet(),
|
||||
|
||||
// Optimize for single words/short phrases
|
||||
tessedit_pageseg_mode: 8 as any, // Single word
|
||||
|
||||
// Increase penalties for non-dictionary words
|
||||
segment_penalty_dict_nonword: '2.0',
|
||||
segment_penalty_dict_frequent_word: '0.001',
|
||||
segment_penalty_dict_case_ok: '0.001',
|
||||
segment_penalty_dict_case_bad: '0.1',
|
||||
|
||||
// Make OCR more conservative about character recognition
|
||||
classify_enable_learning: '0',
|
||||
classify_enable_adaptive_matcher: '1',
|
||||
|
||||
// Preserve word boundaries
|
||||
preserve_interword_spaces: '1'
|
||||
});
|
||||
|
||||
console.log('OCR configured for puzzle names with character set:', this.getPuzzleCharacterSet());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get character set from available puzzle names for more accurate OCR (fallback)
|
||||
*/
|
||||
private getPuzzleCharacterSet(): string {
|
||||
if (this.availablePuzzleNames.length === 0) {
|
||||
// Fallback to common characters
|
||||
return 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 -'
|
||||
}
|
||||
|
||||
// Extract unique characters from all puzzle names
|
||||
const chars = new Set<string>()
|
||||
this.availablePuzzleNames.forEach(name => {
|
||||
for (const char of name) {
|
||||
chars.add(char)
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(chars).join('')
|
||||
}
|
||||
|
||||
async extractOpusMagnumData(imageFile: File): Promise<OpusMagnumData> {
|
||||
if (!this.worker) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
// Convert file to image element for canvas processing
|
||||
const imageUrl = URL.createObjectURL(imageFile);
|
||||
const img = new Image();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
img.onload = async () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// Extract text from each region
|
||||
const results: Partial<OpusMagnumOCRData> = {};
|
||||
const confidenceScores: Record<string, number> = {};
|
||||
|
||||
for (const [key, region] of Object.entries(this.regions)) {
|
||||
const regionCanvas = document.createElement('canvas');
|
||||
const regionCtx = regionCanvas.getContext('2d')!;
|
||||
|
||||
regionCanvas.width = region.width;
|
||||
regionCanvas.height = region.height;
|
||||
|
||||
// Extract region from main image
|
||||
regionCtx.drawImage(
|
||||
canvas,
|
||||
region.x, region.y, region.width, region.height,
|
||||
0, 0, region.width, region.height
|
||||
);
|
||||
|
||||
// Convert to grayscale and invert (similar to main.py processing)
|
||||
const imageData = regionCtx.getImageData(0, 0, region.width, region.height);
|
||||
this.preprocessImage(imageData);
|
||||
regionCtx.putImageData(imageData, 0, 0);
|
||||
|
||||
// Configure OCR based on content type
|
||||
if (key === 'cost') {
|
||||
// Cost field has digits + 'G' for gold (content type: 'digits_with_6')
|
||||
await this.worker!.setParameters({
|
||||
tessedit_char_whitelist: '0123456789G'
|
||||
});
|
||||
} else if (key === 'cycles' || key === 'area') {
|
||||
// Pure digits (content type: 'digits')
|
||||
await this.worker!.setParameters({
|
||||
tessedit_char_whitelist: '0123456789'
|
||||
});
|
||||
} else if (key === 'puzzle') {
|
||||
// Puzzle name - use user words file for better matching
|
||||
await this.configurePuzzleOCR();
|
||||
} else {
|
||||
// Default - allow all characters
|
||||
await this.worker!.setParameters({
|
||||
tessedit_char_whitelist: ''
|
||||
});
|
||||
}
|
||||
|
||||
// Perform OCR on the region
|
||||
const { data: { text, confidence } } = await this.worker!.recognize(regionCanvas);
|
||||
let cleanText = text.trim();
|
||||
|
||||
// Store the confidence score for this field
|
||||
confidenceScores[key] = confidence / 100; // Tesseract returns 0-100, we want 0-1
|
||||
|
||||
// Post-process based on field type
|
||||
if (key === 'cost') {
|
||||
// Handle common OCR misreadings where G is read as 6
|
||||
// If the text ends with 6 and looks like it should be G, remove it
|
||||
if (cleanText.endsWith('6') && cleanText.length > 1) {
|
||||
// Check if removing the last character gives a reasonable cost value
|
||||
const withoutLast = cleanText.slice(0, -1);
|
||||
if (/^\d+$/.test(withoutLast)) {
|
||||
cleanText = withoutLast;
|
||||
}
|
||||
}
|
||||
// Remove any trailing G characters
|
||||
cleanText = cleanText.replace(/G+$/g, '');
|
||||
// Ensure only digits remain
|
||||
cleanText = cleanText.replace(/[^0-9]/g, '');
|
||||
} else if (key === 'cycles' || key === 'area') {
|
||||
// Ensure only digits remain
|
||||
cleanText = cleanText.replace(/[^0-9]/g, '');
|
||||
} else if (key === 'puzzle') {
|
||||
// Post-process puzzle names with aggressive matching to force selection from available puzzles
|
||||
cleanText = this.findBestPuzzleMatch(cleanText);
|
||||
|
||||
// If we still don't have a match and we have available puzzles, force the best match
|
||||
if (this.availablePuzzleNames.length > 0 && !this.availablePuzzleNames.includes(cleanText)) {
|
||||
const forcedMatch = this.findBestPuzzleMatchForced(cleanText);
|
||||
if (forcedMatch) {
|
||||
cleanText = forcedMatch;
|
||||
console.log(`Forced OCR match: "${text.trim()}" -> "${cleanText}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(results as any)[key] = cleanText;
|
||||
}
|
||||
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
|
||||
// Calculate overall confidence as the average of all field confidences
|
||||
const confidenceValues = Object.values(confidenceScores);
|
||||
const overallConfidence = confidenceValues.length > 0
|
||||
? confidenceValues.reduce((sum, conf) => sum + conf, 0) / confidenceValues.length
|
||||
: 0;
|
||||
|
||||
resolve({
|
||||
puzzle: results.puzzle || '',
|
||||
cost: parseInt(results.cost || ''),
|
||||
cycles: parseInt(results.cycles || ''),
|
||||
area: parseInt(results.area || ''),
|
||||
confidence: {
|
||||
puzzle: confidenceScores.puzzle || 0,
|
||||
cost: confidenceScores.cost || 0,
|
||||
cycles: confidenceScores.cycles || 0,
|
||||
area: confidenceScores.area || 0,
|
||||
overall: overallConfidence,
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
reject(new Error('Failed to load image'));
|
||||
};
|
||||
|
||||
img.src = imageUrl;
|
||||
});
|
||||
}
|
||||
|
||||
private preprocessImage(imageData: ImageData): void {
|
||||
// Convert to grayscale and invert (similar to cv2.bitwise_not in main.py)
|
||||
const data = imageData.data;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
// Convert to grayscale
|
||||
const gray = Math.round(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]);
|
||||
|
||||
// Invert the grayscale value
|
||||
const inverted = 255 - gray;
|
||||
|
||||
data[i] = inverted; // Red
|
||||
data[i + 1] = inverted; // Green
|
||||
data[i + 2] = inverted; // Blue
|
||||
// Alpha channel (data[i + 3]) remains unchanged
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Levenshtein distance between two strings
|
||||
*/
|
||||
private levenshteinDistance(str1: string, str2: string): number {
|
||||
const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null));
|
||||
|
||||
for (let i = 0; i <= str1.length; i++) matrix[0][i] = i;
|
||||
for (let j = 0; j <= str2.length; j++) matrix[j][0] = j;
|
||||
|
||||
for (let j = 1; j <= str2.length; j++) {
|
||||
for (let i = 1; i <= str1.length; i++) {
|
||||
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
|
||||
matrix[j][i] = Math.min(
|
||||
matrix[j][i - 1] + 1, // deletion
|
||||
matrix[j - 1][i] + 1, // insertion
|
||||
matrix[j - 1][i - 1] + indicator // substitution
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[str2.length][str1.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best matching puzzle name from available options using multiple strategies
|
||||
*/
|
||||
private findBestPuzzleMatch(ocrText: string): string {
|
||||
if (!this.availablePuzzleNames.length) {
|
||||
return ocrText.trim();
|
||||
}
|
||||
|
||||
const cleanedOcr = ocrText.trim();
|
||||
if (!cleanedOcr) return '';
|
||||
|
||||
// Strategy 1: Exact match (case insensitive)
|
||||
const exactMatch = this.availablePuzzleNames.find(
|
||||
name => name.toLowerCase() === cleanedOcr.toLowerCase()
|
||||
);
|
||||
if (exactMatch) return exactMatch;
|
||||
|
||||
// Strategy 2: Substring match (either direction)
|
||||
const substringMatch = this.availablePuzzleNames.find(
|
||||
name => name.toLowerCase().includes(cleanedOcr.toLowerCase()) ||
|
||||
cleanedOcr.toLowerCase().includes(name.toLowerCase())
|
||||
);
|
||||
if (substringMatch) return substringMatch;
|
||||
|
||||
// Strategy 3: Multiple fuzzy matching approaches
|
||||
let bestMatch = cleanedOcr;
|
||||
let bestScore = 0;
|
||||
|
||||
for (const puzzleName of this.availablePuzzleNames) {
|
||||
const scores = [
|
||||
this.calculateLevenshteinSimilarity(cleanedOcr, puzzleName),
|
||||
this.calculateJaroWinklerSimilarity(cleanedOcr, puzzleName),
|
||||
this.calculateNGramSimilarity(cleanedOcr, puzzleName, 2)
|
||||
];
|
||||
|
||||
// Use the maximum score from all algorithms
|
||||
const maxScore = Math.max(...scores);
|
||||
|
||||
// Lower threshold for better matching - force selection even with moderate confidence
|
||||
if (maxScore > bestScore && maxScore > 0.4) {
|
||||
bestScore = maxScore;
|
||||
bestMatch = puzzleName;
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 4: If no good match found, try character-based matching
|
||||
if (bestScore < 0.6) {
|
||||
const charMatch = this.findBestCharacterMatch(cleanedOcr);
|
||||
if (charMatch) {
|
||||
bestMatch = charMatch;
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Levenshtein similarity (normalized)
|
||||
*/
|
||||
private calculateLevenshteinSimilarity(str1: string, str2: string): number {
|
||||
const distance = this.levenshteinDistance(str1.toLowerCase(), str2.toLowerCase());
|
||||
const maxLength = Math.max(str1.length, str2.length);
|
||||
return maxLength === 0 ? 1 : 1 - (distance / maxLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Jaro-Winkler similarity
|
||||
*/
|
||||
private calculateJaroWinklerSimilarity(str1: string, str2: string): number {
|
||||
const s1 = str1.toLowerCase();
|
||||
const s2 = str2.toLowerCase();
|
||||
|
||||
if (s1 === s2) return 1;
|
||||
|
||||
const matchWindow = Math.floor(Math.max(s1.length, s2.length) / 2) - 1;
|
||||
if (matchWindow < 0) return 0;
|
||||
|
||||
const s1Matches = new Array(s1.length).fill(false);
|
||||
const s2Matches = new Array(s2.length).fill(false);
|
||||
|
||||
let matches = 0;
|
||||
let transpositions = 0;
|
||||
|
||||
// Find matches
|
||||
for (let i = 0; i < s1.length; i++) {
|
||||
const start = Math.max(0, i - matchWindow);
|
||||
const end = Math.min(i + matchWindow + 1, s2.length);
|
||||
|
||||
for (let j = start; j < end; j++) {
|
||||
if (s2Matches[j] || s1[i] !== s2[j]) continue;
|
||||
s1Matches[i] = true;
|
||||
s2Matches[j] = true;
|
||||
matches++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matches === 0) return 0;
|
||||
|
||||
// Count transpositions
|
||||
let k = 0;
|
||||
for (let i = 0; i < s1.length; i++) {
|
||||
if (!s1Matches[i]) continue;
|
||||
while (!s2Matches[k]) k++;
|
||||
if (s1[i] !== s2[k]) transpositions++;
|
||||
k++;
|
||||
}
|
||||
|
||||
const jaro = (matches / s1.length + matches / s2.length + (matches - transpositions / 2) / matches) / 3;
|
||||
|
||||
// Jaro-Winkler bonus for common prefix
|
||||
let prefix = 0;
|
||||
for (let i = 0; i < Math.min(s1.length, s2.length, 4); i++) {
|
||||
if (s1[i] === s2[i]) prefix++;
|
||||
else break;
|
||||
}
|
||||
|
||||
return jaro + (0.1 * prefix * (1 - jaro));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate N-gram similarity
|
||||
*/
|
||||
private calculateNGramSimilarity(str1: string, str2: string, n: number): number {
|
||||
const s1 = str1.toLowerCase();
|
||||
const s2 = str2.toLowerCase();
|
||||
|
||||
if (s1 === s2) return 1;
|
||||
if (s1.length < n || s2.length < n) return 0;
|
||||
|
||||
const ngrams1 = new Set<string>();
|
||||
const ngrams2 = new Set<string>();
|
||||
|
||||
for (let i = 0; i <= s1.length - n; i++) {
|
||||
ngrams1.add(s1.substr(i, n));
|
||||
}
|
||||
|
||||
for (let i = 0; i <= s2.length - n; i++) {
|
||||
ngrams2.add(s2.substr(i, n));
|
||||
}
|
||||
|
||||
const intersection = new Set([...ngrams1].filter(x => ngrams2.has(x)));
|
||||
const union = new Set([...ngrams1, ...ngrams2]);
|
||||
|
||||
return intersection.size / union.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find best match based on character frequency
|
||||
*/
|
||||
private findBestCharacterMatch(ocrText: string): string | null {
|
||||
let bestMatch = null;
|
||||
let bestScore = 0;
|
||||
|
||||
for (const puzzleName of this.availablePuzzleNames) {
|
||||
const score = this.calculateCharacterFrequencyScore(ocrText.toLowerCase(), puzzleName.toLowerCase());
|
||||
if (score > bestScore && score > 0.3) {
|
||||
bestScore = score;
|
||||
bestMatch = puzzleName;
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate character frequency similarity
|
||||
*/
|
||||
private calculateCharacterFrequencyScore(str1: string, str2: string): number {
|
||||
const freq1 = new Map<string, number>();
|
||||
const freq2 = new Map<string, number>();
|
||||
|
||||
for (const char of str1) {
|
||||
freq1.set(char, (freq1.get(char) || 0) + 1);
|
||||
}
|
||||
|
||||
for (const char of str2) {
|
||||
freq2.set(char, (freq2.get(char) || 0) + 1);
|
||||
}
|
||||
|
||||
const allChars = new Set([...freq1.keys(), ...freq2.keys()]);
|
||||
let similarity = 0;
|
||||
let totalChars = 0;
|
||||
|
||||
for (const char of allChars) {
|
||||
const count1 = freq1.get(char) || 0;
|
||||
const count2 = freq2.get(char) || 0;
|
||||
similarity += Math.min(count1, count2);
|
||||
totalChars += Math.max(count1, count2);
|
||||
}
|
||||
|
||||
return totalChars === 0 ? 0 : similarity / totalChars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a match to available puzzle names - always returns a puzzle name
|
||||
* This is used as a last resort to ensure OCR always selects from available puzzles
|
||||
*/
|
||||
private findBestPuzzleMatchForced(ocrText: string): string | null {
|
||||
if (!this.availablePuzzleNames.length || !ocrText.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cleanedOcr = ocrText.trim().toLowerCase();
|
||||
let bestMatch = this.availablePuzzleNames[0]; // Default to first puzzle
|
||||
let bestScore = 0;
|
||||
|
||||
// Try all matching algorithms and pick the best overall score
|
||||
for (const puzzleName of this.availablePuzzleNames) {
|
||||
const scores = [
|
||||
this.calculateLevenshteinSimilarity(cleanedOcr, puzzleName),
|
||||
this.calculateJaroWinklerSimilarity(cleanedOcr, puzzleName),
|
||||
this.calculateNGramSimilarity(cleanedOcr, puzzleName, 2),
|
||||
this.calculateCharacterFrequencyScore(cleanedOcr, puzzleName.toLowerCase()),
|
||||
// Add length similarity bonus
|
||||
this.calculateLengthSimilarity(cleanedOcr, puzzleName.toLowerCase())
|
||||
];
|
||||
|
||||
// Use weighted average with emphasis on character frequency and length
|
||||
const weightedScore = (
|
||||
scores[0] * 0.25 + // Levenshtein
|
||||
scores[1] * 0.25 + // Jaro-Winkler
|
||||
scores[2] * 0.2 + // N-gram
|
||||
scores[3] * 0.2 + // Character frequency
|
||||
scores[4] * 0.1 // Length similarity
|
||||
);
|
||||
|
||||
if (weightedScore > bestScore) {
|
||||
bestScore = weightedScore;
|
||||
bestMatch = puzzleName;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Forced match for "${ocrText}": "${bestMatch}" (score: ${bestScore.toFixed(3)})`);
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate similarity based on string length
|
||||
*/
|
||||
private calculateLengthSimilarity(str1: string, str2: string): number {
|
||||
const len1 = str1.length;
|
||||
const len2 = str2.length;
|
||||
const maxLen = Math.max(len1, len2);
|
||||
const minLen = Math.min(len1, len2);
|
||||
|
||||
return maxLen === 0 ? 1 : minLen / maxLen;
|
||||
}
|
||||
|
||||
|
||||
async terminate(): Promise<void> {
|
||||
if (this.worker) {
|
||||
await this.worker.terminate();
|
||||
this.worker = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Utility method to validate if an image looks like an Opus Magnum screenshot
|
||||
static isValidOpusMagnumImage(file: File): boolean {
|
||||
// Basic validation - could be enhanced with actual image analysis
|
||||
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'];
|
||||
return validTypes.includes(file.type);
|
||||
}
|
||||
|
||||
// Debug method to visualize OCR regions (similar to main.py debug rectangles)
|
||||
static drawDebugRegions(imageFile: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const imageUrl = URL.createObjectURL(imageFile);
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// Draw debug rectangles
|
||||
ctx.strokeStyle = '#00ff00';
|
||||
ctx.lineWidth = 2;
|
||||
|
||||
const service = new OpusMagnumOCRService();
|
||||
Object.values(service.regions).forEach(region => {
|
||||
ctx.strokeRect(region.x, region.y, region.width, region.height);
|
||||
});
|
||||
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
resolve(canvas.toDataURL());
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
reject(new Error('Failed to load image for debug'));
|
||||
};
|
||||
|
||||
img.src = imageUrl;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance for the application
|
||||
export const ocrService = new OpusMagnumOCRService();
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
export const pinia = createPinia()
|
||||
@@ -0,0 +1,78 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { SteamCollectionItem } from '@/types'
|
||||
import { apiService } from '@/services/apiService'
|
||||
|
||||
export const usePuzzlesStore = defineStore('puzzles', () => {
|
||||
// State
|
||||
const puzzles = ref<SteamCollectionItem[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string>('')
|
||||
|
||||
// Getters
|
||||
const puzzleNames = computed(() => puzzles.value.map(puzzle => puzzle.title))
|
||||
|
||||
const findPuzzleByName = computed(() => (name: string): SteamCollectionItem | null => {
|
||||
if (!name) return null
|
||||
|
||||
// First try exact match (case insensitive)
|
||||
const exactMatch = puzzles.value.find(
|
||||
puzzle => puzzle.title.toLowerCase() === name.toLowerCase()
|
||||
)
|
||||
if (exactMatch) return exactMatch
|
||||
|
||||
// Then try partial match
|
||||
const partialMatch = puzzles.value.find(
|
||||
puzzle => puzzle.title.toLowerCase().includes(name.toLowerCase()) ||
|
||||
name.toLowerCase().includes(puzzle.title.toLowerCase())
|
||||
)
|
||||
|
||||
return partialMatch || null
|
||||
})
|
||||
|
||||
// Actions
|
||||
const loadPuzzles = async () => {
|
||||
if (puzzles.value.length > 0) return // Already loaded
|
||||
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
|
||||
const response = await apiService.getPuzzles()
|
||||
if (response.error) {
|
||||
error.value = response.error
|
||||
console.error('Failed to load puzzles:', response.error)
|
||||
return
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
puzzles.value = response.data
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = 'Failed to load puzzles'
|
||||
console.error('Error loading puzzles:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshPuzzles = async () => {
|
||||
puzzles.value = []
|
||||
await loadPuzzles()
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
puzzles,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Getters
|
||||
puzzleNames,
|
||||
findPuzzleByName,
|
||||
|
||||
// Actions
|
||||
loadPuzzles,
|
||||
refreshPuzzles
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
import { defineStore, storeToRefs } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { Submission, SubmissionFile } from '@/types'
|
||||
import { submissionHelpers } from '@/services/apiService'
|
||||
import { usePuzzlesStore } from '@/stores/puzzles'
|
||||
import { errorHelpers } from "@/services/apiService";
|
||||
|
||||
export const useSubmissionsStore = defineStore('submissions', () => {
|
||||
// State
|
||||
const submissions = ref<Submission[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string>('')
|
||||
const isSubmissionModalOpen = ref(false)
|
||||
|
||||
const puzzlesStore = usePuzzlesStore()
|
||||
const { puzzles } = storeToRefs(puzzlesStore)
|
||||
|
||||
// Actions
|
||||
const loadSubmissions = async (limit = 20, offset = 0) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
|
||||
const loadedSubmissions = await submissionHelpers.loadSubmissions(limit, offset)
|
||||
|
||||
if (offset === 0) {
|
||||
submissions.value = loadedSubmissions
|
||||
} else {
|
||||
submissions.value.push(...loadedSubmissions)
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = 'Failed to load submissions'
|
||||
console.error('Error loading submissions:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createSubmission = async (
|
||||
files: SubmissionFile[],
|
||||
notes?: string,
|
||||
manualValidationRequested?: boolean
|
||||
): Promise<Submission | undefined> => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
|
||||
const response = await submissionHelpers.createFromFiles(
|
||||
files,
|
||||
puzzles.value,
|
||||
notes,
|
||||
manualValidationRequested
|
||||
)
|
||||
|
||||
if (response.error) {
|
||||
error.value = response.error
|
||||
throw new Error(response.error)
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
// Add to local submissions list
|
||||
submissions.value.unshift(response.data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
return undefined
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to create submission'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openSubmissionModal = () => {
|
||||
isSubmissionModalOpen.value = true
|
||||
}
|
||||
|
||||
const closeSubmissionModal = () => {
|
||||
isSubmissionModalOpen.value = false
|
||||
}
|
||||
|
||||
const refreshSubmissions = async () => {
|
||||
submissions.value = []
|
||||
await loadSubmissions()
|
||||
}
|
||||
|
||||
const handleSubmission = async (submissionData: {
|
||||
files: any[];
|
||||
notes?: string;
|
||||
manualValidationRequested?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
error.value = "";
|
||||
|
||||
// Create submission via store
|
||||
const submission = await createSubmission(
|
||||
submissionData.files,
|
||||
submissionData.notes,
|
||||
submissionData.manualValidationRequested,
|
||||
);
|
||||
|
||||
// Show success message
|
||||
if (submission) {
|
||||
const puzzleNames = submission.responses
|
||||
.map((r) => r.puzzle_name)
|
||||
.join(", ");
|
||||
|
||||
alert(`Solutions submitted successfully for puzzles: ${puzzleNames}`);
|
||||
|
||||
} else {
|
||||
alert("Submission created successfully!");
|
||||
}
|
||||
|
||||
// Close modal
|
||||
closeSubmissionModal();
|
||||
|
||||
} catch (err) {
|
||||
|
||||
const errorMessage = errorHelpers.getErrorMessage(err);
|
||||
error.value = errorMessage;
|
||||
alert(`Submission failed: ${errorMessage}`);
|
||||
|
||||
console.error("Submission error:", err);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
submissions,
|
||||
isLoading,
|
||||
error,
|
||||
isSubmissionModalOpen,
|
||||
|
||||
// Actions
|
||||
loadSubmissions,
|
||||
createSubmission,
|
||||
openSubmissionModal,
|
||||
closeSubmissionModal,
|
||||
refreshSubmissions,
|
||||
handleSubmission
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { SubmissionFile } from '@/types'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, nextTick, computed } from "vue";
|
||||
import { ocrService } from "@/services/ocrService";
|
||||
|
||||
const CONFIDENCE_VALUE = 0.8;
|
||||
|
||||
export const useUploadsStore = defineStore('uploads', () => {
|
||||
const submissionFiles = ref<SubmissionFile[]>([])
|
||||
|
||||
const isProcessingOCR = computed(() =>
|
||||
submissionFiles.value.some(item => item.ocrProcessing)
|
||||
);
|
||||
|
||||
const hasLowConfidence = computed(() =>
|
||||
submissionFiles.value.some(file => {
|
||||
return isLowConfidence(file)
|
||||
})
|
||||
)
|
||||
|
||||
const submissionFilesNeedingManualSelection = computed(() => {
|
||||
return submissionFiles.value.filter(file => file.needsManualPuzzleSelection)
|
||||
})
|
||||
|
||||
const isLowConfidence = (file: SubmissionFile) => {
|
||||
if (!file.ocrData?.confidence) return false;
|
||||
return (
|
||||
file.ocrData.confidence.cost < CONFIDENCE_VALUE ||
|
||||
file.ocrData.confidence.cycles < CONFIDENCE_VALUE ||
|
||||
file.ocrData.confidence.area < CONFIDENCE_VALUE
|
||||
)
|
||||
}
|
||||
|
||||
const processOCR = async (submissionFile: SubmissionFile) => {
|
||||
while (isProcessingOCR.value) {
|
||||
const waitingTimeMs = Math.floor(Math.random() * 400) + 100;
|
||||
console.log(`OCR is already processing, waiting ${waitingTimeMs}ms...`);
|
||||
await new Promise((res) => setTimeout(res, waitingTimeMs));
|
||||
}
|
||||
|
||||
const index = submissionFiles.value.indexOf(submissionFile)
|
||||
|
||||
// Update the reactive array directly
|
||||
submissionFiles.value[index].ocrProcessing = true;
|
||||
submissionFiles.value[index].ocrError = undefined;
|
||||
submissionFiles.value[index].ocrData = undefined;
|
||||
|
||||
try {
|
||||
console.log("Starting OCR processing for:", submissionFile.file.name);
|
||||
await ocrService.initialize();
|
||||
const ocrData = await ocrService.extractOpusMagnumData(submissionFile.file);
|
||||
console.log("OCR completed:", ocrData);
|
||||
|
||||
// Force reactivity update
|
||||
await nextTick();
|
||||
submissionFiles.value[index].ocrData = ocrData;
|
||||
|
||||
// Check if puzzle confidence is below CONFIDENCE_VALUE and needs manual selection
|
||||
if (ocrData.confidence.puzzle < CONFIDENCE_VALUE) {
|
||||
submissionFiles.value[index].needsManualPuzzleSelection = true;
|
||||
console.log(
|
||||
`Low puzzle confidence (${Math.round(ocrData.confidence.puzzle * 100)}%) for ${submissionFile.file.name}, requiring manual selection`,
|
||||
);
|
||||
} else {
|
||||
submissionFiles.value[index].needsManualPuzzleSelection = false;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
} catch (error) {
|
||||
console.error("OCR processing failed:", error);
|
||||
submissionFiles.value[index].ocrError = "Failed to extract puzzle data";
|
||||
|
||||
} finally {
|
||||
submissionFiles.value[index].ocrProcessing = false;
|
||||
}
|
||||
};
|
||||
|
||||
const processLowConfidenceOCRFiles = async () => {
|
||||
const files = submissionFiles.value.filter(file => isLowConfidence(file))
|
||||
|
||||
for (const file of files) {
|
||||
processOCR(file)
|
||||
}
|
||||
}
|
||||
|
||||
const clearFiles = () => {
|
||||
submissionFiles.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
submissionFiles,
|
||||
submissionFilesNeedingManualSelection,
|
||||
processOCR,
|
||||
processLowConfidenceOCRFiles,
|
||||
clearFiles,
|
||||
|
||||
// computed
|
||||
isProcessingOCR,
|
||||
hasLowConfidence,
|
||||
|
||||
CONFIDENCE_VALUE
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
@import '@mdi/font/css/materialdesignicons.css';
|
||||
@import "tailwindcss";
|
||||
|
||||
@plugin "daisyui";
|
||||
@plugin "daisyui/theme" {
|
||||
name: "dim";
|
||||
default: false;
|
||||
prefersdark: false;
|
||||
color-scheme: "dark";
|
||||
--color-base-100: oklch(30.857% 0.023 264.149);
|
||||
--color-base-200: oklch(28.036% 0.019 264.182);
|
||||
--color-base-300: oklch(26.346% 0.018 262.177);
|
||||
--color-base-content: oklch(82.901% 0.031 222.959);
|
||||
--color-primary: oklch(86.133% 0.141 139.549);
|
||||
--color-primary-content: oklch(17.226% 0.028 139.549);
|
||||
--color-secondary: oklch(73.375% 0.165 35.353);
|
||||
--color-secondary-content: oklch(14.675% 0.033 35.353);
|
||||
--color-accent: oklch(74.229% 0.133 311.379);
|
||||
--color-accent-content: oklch(14.845% 0.026 311.379);
|
||||
--color-neutral: oklch(24.731% 0.02 264.094);
|
||||
--color-neutral-content: oklch(82.901% 0.031 222.959);
|
||||
--color-info: oklch(86.078% 0.142 206.182);
|
||||
--color-info-content: oklch(17.215% 0.028 206.182);
|
||||
--color-success: oklch(86.171% 0.142 166.534);
|
||||
--color-success-content: oklch(17.234% 0.028 166.534);
|
||||
--color-warning: oklch(86.163% 0.142 94.818);
|
||||
--color-warning-content: oklch(17.232% 0.028 94.818);
|
||||
--color-error: oklch(82.418% 0.099 33.756);
|
||||
--color-error-content: oklch(16.483% 0.019 33.756);
|
||||
--radius-selector: 2rem;
|
||||
--radius-field: 0.25rem;
|
||||
--radius-box: 0.25rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 0;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
export interface SteamCollection {
|
||||
id: number
|
||||
steam_id: string
|
||||
title: string
|
||||
description: string
|
||||
author_name: string
|
||||
total_items: number
|
||||
unique_visitors: number
|
||||
current_favorites: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface SteamCollectionItem {
|
||||
id: number
|
||||
steam_item_id: string
|
||||
title: string
|
||||
author_name: string
|
||||
description: string
|
||||
tags: string[]
|
||||
order_index: number
|
||||
collection: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface OpusMagnumData {
|
||||
puzzle: string
|
||||
cost: number
|
||||
cycles: number
|
||||
area: number
|
||||
confidence: {
|
||||
puzzle: number
|
||||
cost: number
|
||||
cycles: number
|
||||
area: number
|
||||
overall: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface SubmissionFile {
|
||||
file: File
|
||||
file_url: string
|
||||
preview: string
|
||||
type: 'image' | 'gif'
|
||||
ocrData?: OpusMagnumData
|
||||
ocrProcessing?: boolean
|
||||
ocrError?: string
|
||||
original_filename?: string
|
||||
manualPuzzleSelection?: string
|
||||
needsManualPuzzleSelection?: boolean
|
||||
}
|
||||
|
||||
export interface PuzzleResponse {
|
||||
id?: number
|
||||
// puzzle: number | SteamCollectionItem
|
||||
puzzle_id: number
|
||||
puzzle_name: string
|
||||
cost?: number
|
||||
cycles?: number
|
||||
area?: number
|
||||
needs_manual_validation?: boolean
|
||||
ocr_confidence_cost?: number
|
||||
ocr_confidence_cycles?: number
|
||||
ocr_confidence_area?: number
|
||||
validated_cost?: number
|
||||
validated_cycles?: number
|
||||
validated_area?: number
|
||||
final_cost?: number
|
||||
final_cycles?: number
|
||||
final_area?: number
|
||||
files?: SubmissionFile[]
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface Submission {
|
||||
id?: string
|
||||
user?: number | null
|
||||
responses: PuzzleResponse[]
|
||||
notes?: string
|
||||
is_validated?: boolean
|
||||
validated_by?: number | null
|
||||
validated_at?: string | null
|
||||
manual_validation_requested?: boolean
|
||||
total_responses?: number
|
||||
needs_validation?: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id?: number
|
||||
username?: string
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
email?: string
|
||||
is_authenticated: boolean
|
||||
is_staff: boolean
|
||||
is_superuser: boolean
|
||||
cas_groups?: string[]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot": {
|
||||
"file": "assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot"
|
||||
},
|
||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf": {
|
||||
"file": "assets/materialdesignicons-webfont-B7mPwVP_.ttf",
|
||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf"
|
||||
},
|
||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff": {
|
||||
"file": "assets/materialdesignicons-webfont-PXm3-2wK.woff",
|
||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff"
|
||||
},
|
||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2": {
|
||||
"file": "assets/materialdesignicons-webfont-Dp5v-WZN.woff2",
|
||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2"
|
||||
},
|
||||
"src/main.ts": {
|
||||
"file": "assets/main-C--Q4E8C.js",
|
||||
"name": "main",
|
||||
"src": "src/main.ts",
|
||||
"isEntry": true,
|
||||
"css": [
|
||||
"assets/main-4xelLIeX.css"
|
||||
],
|
||||
"assets": [
|
||||
"assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
||||
"assets/materialdesignicons-webfont-Dp5v-WZN.woff2",
|
||||
"assets/materialdesignicons-webfont-PXm3-2wK.woff",
|
||||
"assets/materialdesignicons-webfont-B7mPwVP_.ttf"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,388 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.utils import timezone
|
||||
from submissions.models import (
|
||||
SteamAPIKey,
|
||||
SteamCollection,
|
||||
SteamCollectionItem,
|
||||
Submission,
|
||||
PuzzleResponse,
|
||||
SubmissionFile,
|
||||
)
|
||||
|
||||
|
||||
@admin.register(SteamAPIKey)
|
||||
class SteamAPIKeyAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "masked_api_key", "is_active", "last_used", "created_at"]
|
||||
list_filter = ["is_active", "created_at", "last_used"]
|
||||
search_fields = ["name", "description"]
|
||||
readonly_fields = ["created_at", "updated_at", "last_used", "masked_api_key"]
|
||||
|
||||
fieldsets = (
|
||||
("Basic Information", {"fields": ("name", "description", "is_active")}),
|
||||
(
|
||||
"API Key",
|
||||
{
|
||||
"fields": ("api_key", "masked_api_key"),
|
||||
"description": "Get your Steam API key from https://steamcommunity.com/dev/apikey",
|
||||
},
|
||||
),
|
||||
(
|
||||
"Metadata",
|
||||
{
|
||||
"fields": ("created_at", "updated_at", "last_used"),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
def masked_api_key(self, obj):
|
||||
"""Display masked API key in admin"""
|
||||
if obj.api_key:
|
||||
return format_html(
|
||||
'<code style="background: #f8f9fa; padding: 2px 4px; border-radius: 3px;">{}</code>',
|
||||
obj.masked_key,
|
||||
)
|
||||
return "No key set"
|
||||
|
||||
masked_api_key.short_description = "API Key (Masked)"
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Only superusers can see API keys"""
|
||||
qs = super().get_queryset(request)
|
||||
if not request.user.is_superuser:
|
||||
return qs.none()
|
||||
return qs
|
||||
|
||||
def has_view_permission(self, request, obj=None):
|
||||
"""Only superusers can view API keys"""
|
||||
return request.user.is_superuser
|
||||
|
||||
def has_add_permission(self, request):
|
||||
"""Only superusers can add API keys"""
|
||||
return request.user.is_superuser
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
"""Only superusers can change API keys"""
|
||||
return request.user.is_superuser
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
"""Only superusers can delete API keys"""
|
||||
return request.user.is_superuser
|
||||
|
||||
|
||||
@admin.register(SteamCollection)
|
||||
class SteamCollectionAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"title",
|
||||
"steam_id",
|
||||
"author_name",
|
||||
"total_items",
|
||||
"current_favorites",
|
||||
"last_fetched",
|
||||
"is_active",
|
||||
]
|
||||
list_filter = ["is_active", "last_fetched", "created_at"]
|
||||
search_fields = ["title", "steam_id", "author_name", "description"]
|
||||
readonly_fields = ["steam_id", "created_at", "updated_at", "last_fetched"]
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Basic Information",
|
||||
{"fields": ("steam_id", "url", "title", "description", "is_active")},
|
||||
),
|
||||
("Author Information", {"fields": ("author_name", "author_steam_id")}),
|
||||
(
|
||||
"Statistics",
|
||||
{
|
||||
"fields": (
|
||||
"total_items",
|
||||
"unique_visitors",
|
||||
"current_favorites",
|
||||
"total_favorites",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Timestamps",
|
||||
{
|
||||
"fields": (
|
||||
"steam_created_date",
|
||||
"steam_updated_date",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"last_fetched",
|
||||
)
|
||||
},
|
||||
),
|
||||
("Status", {"fields": ("fetch_error",)}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(SteamCollectionItem)
|
||||
class SteamCollectionItemAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"title",
|
||||
"steam_item_id",
|
||||
"collection",
|
||||
"author_name",
|
||||
"order_index",
|
||||
]
|
||||
list_filter = ["collection", "created_at"]
|
||||
search_fields = ["title", "steam_item_id", "author_name", "description"]
|
||||
readonly_fields = ["created_at", "updated_at"]
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Basic Information",
|
||||
{
|
||||
"fields": (
|
||||
"collection",
|
||||
"steam_item_id",
|
||||
"title",
|
||||
"description",
|
||||
"order_index",
|
||||
)
|
||||
},
|
||||
),
|
||||
("Author Information", {"fields": ("author_name", "author_steam_id")}),
|
||||
("Metadata", {"fields": ("tags",)}),
|
||||
("Timestamps", {"fields": ("created_at", "updated_at")}),
|
||||
("Points factor", {"fields": ("points_factor", "points_value")}),
|
||||
)
|
||||
|
||||
|
||||
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_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",
|
||||
]
|
||||
list_filter = [
|
||||
"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",
|
||||
]
|
||||
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",)},
|
||||
),
|
||||
)
|
||||
|
||||
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_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."
|
||||
)
|
||||
|
||||
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,293 @@
|
||||
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.utils import timezone
|
||||
from django.shortcuts import get_object_or_404
|
||||
from typing import List
|
||||
|
||||
from polylan_submitter.submissions.utils import verify_and_validate_ocr_date_for_submission
|
||||
|
||||
from .models import Submission, PuzzleResponse, SubmissionFile, SteamCollectionItem
|
||||
from .schemas import (
|
||||
SubmissionIn,
|
||||
SubmissionOut,
|
||||
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").filter(
|
||||
collection__is_active=True
|
||||
)
|
||||
|
||||
|
||||
@router.get("/submissions", response=List[SubmissionOut])
|
||||
@paginate
|
||||
def list_submissions(request):
|
||||
"""Get paginated list of submissions"""
|
||||
return Submission.objects.prefetch_related(
|
||||
"responses__files", "responses__puzzle"
|
||||
).filter(user=request.user)
|
||||
|
||||
|
||||
@router.get("/submissions/{submission_id}", response=SubmissionOut)
|
||||
def get_submission(request, submission_id: str):
|
||||
"""Get detailed submission by ID"""
|
||||
return get_object_or_404(
|
||||
Submission.objects.prefetch_related(
|
||||
"responses__files", "responses__puzzle"
|
||||
).filter(user=request.user),
|
||||
id=submission_id,
|
||||
)
|
||||
|
||||
|
||||
@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"}
|
||||
|
||||
print(data, files)
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Check if any confidence score is below 80% to auto-request validation
|
||||
auto_request_validation = any(
|
||||
(
|
||||
response_data.ocr_confidence_cost is not None
|
||||
and response_data.ocr_confidence_cost < 0.8
|
||||
)
|
||||
or (
|
||||
response_data.ocr_confidence_cycles is not None
|
||||
and response_data.ocr_confidence_cycles < 0.8
|
||||
)
|
||||
or (
|
||||
response_data.ocr_confidence_area is not None
|
||||
and response_data.ocr_confidence_area < 0.8
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
file_index = 0
|
||||
for response_data in data.responses:
|
||||
# Get the puzzle
|
||||
puzzle = get_object_or_404(
|
||||
SteamCollectionItem, id=response_data.puzzle_id
|
||||
)
|
||||
|
||||
# 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=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,
|
||||
**{
|
||||
# Put validated if not manual validation is needed
|
||||
"validated_cost": response_data.cost,
|
||||
"validated_cycles": response_data.cycles,
|
||||
"validated_area": response_data.area,
|
||||
}
|
||||
if not data.manual_validation_requested
|
||||
else {},
|
||||
)
|
||||
|
||||
# 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
|
||||
print("FI", file_index, files)
|
||||
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 (256MB limit)
|
||||
if uploaded_file.size > 256 * 1024 * 1024:
|
||||
return 400, {"detail": "File too large (max 256MB)"}
|
||||
|
||||
# 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:
|
||||
print(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"}
|
||||
|
||||
response = get_object_or_404(PuzzleResponse, id=response_id)
|
||||
|
||||
if data.puzzle is not None:
|
||||
puzzle = get_object_or_404(SteamCollectionItem, id=data.puzzle)
|
||||
response.puzzle = puzzle
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
@router.put("/responses/{response_id}/validate/auto", response=PuzzleResponseOut)
|
||||
def validate_auto(request, response_id: int):
|
||||
"""Try to auto validate a puzzle response"""
|
||||
|
||||
if not request.user.is_authenticated or not request.user.is_staff:
|
||||
return 403, {"detail": "Admin access required"}
|
||||
|
||||
response = get_object_or_404(PuzzleResponse, id=response_id)
|
||||
|
||||
for file in response.files.all():
|
||||
verify_and_validate_ocr_date_for_submission(file)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@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)
|
||||
.filter(puzzle__collection__is_active=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"}
|
||||
|
||||
submission = get_object_or_404(Submission, 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
|
||||
|
||||
|
||||
@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"}
|
||||
|
||||
submission = get_object_or_404(Submission, id=submission_id)
|
||||
submission.delete()
|
||||
return {"detail": "Submission deleted successfully"}
|
||||
|
||||
|
||||
@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": (total_responses - needs_validation) / total_responses
|
||||
if total_responses
|
||||
else 0,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SubmissionsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "submissions"
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Django management command to fetch Steam Workshop collections
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from submissions.utils import create_or_update_collection
|
||||
from submissions.models import SteamAPIKey, SteamCollection
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
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(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Force refetch even if collection already exists",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
url = options["url"]
|
||||
force = options["force"]
|
||||
|
||||
self.stdout.write(f"Fetching Steam collection from: {url}")
|
||||
|
||||
api_key = SteamAPIKey.objects.filter(is_active=True).first()
|
||||
|
||||
if not api_key:
|
||||
self.stderr.write("No API key defined! Aborting...")
|
||||
return
|
||||
|
||||
self.stdout.write(f"Using api key: {api_key}")
|
||||
|
||||
try:
|
||||
# Check if collection already exists
|
||||
from submissions.utils import SteamCollectionFetcher
|
||||
|
||||
fetcher = SteamCollectionFetcher(api_key.api_key)
|
||||
collection_id = fetcher.extract_collection_id(url)
|
||||
|
||||
if collection_id and not force:
|
||||
existing = SteamCollection.objects.filter(
|
||||
steam_id=collection_id
|
||||
).first()
|
||||
if existing:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f"Collection {collection_id} already exists (ID: {existing.id}). "
|
||||
"Use --force to refetch."
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# Fetch and create/update collection
|
||||
collection, created = create_or_update_collection(url)
|
||||
|
||||
if created:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Successfully created collection: {collection.title} (ID: {collection.id})"
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
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" 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})"
|
||||
)
|
||||
|
||||
if collection.items.count() > 10:
|
||||
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}")
|
||||
@@ -0,0 +1,20 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from submissions.utils import verify_and_validate_ocr_date_for_submission
|
||||
from submissions.models import SubmissionFile
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--force", action="store_true", help="Force redo the OCR")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
for file in SubmissionFile.objects.prefetch_related(
|
||||
"response__puzzle",
|
||||
"response__submission__user",
|
||||
):
|
||||
if not file.response.needs_manual_validation and not options.get(
|
||||
"force", False
|
||||
):
|
||||
continue
|
||||
|
||||
verify_and_validate_ocr_date_for_submission(file)
|
||||
@@ -0,0 +1,219 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-29 00:11
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Collection",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("url", models.URLField()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
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",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Steam Collection",
|
||||
"verbose_name_plural": "Steam Collections",
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
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",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Steam Collection Item",
|
||||
"verbose_name_plural": "Steam Collection Items",
|
||||
"ordering": ["collection", "order_index"],
|
||||
"unique_together": {("collection", "steam_item_id")},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-29 00:12
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("submissions", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name="Collection",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,69 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-29 00:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("submissions", "0002_delete_collection"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Steam API Key",
|
||||
"verbose_name_plural": "Steam API Keys",
|
||||
"ordering": ["-is_active", "name"],
|
||||
},
|
||||
),
|
||||
]
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
# 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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-29 01:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("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
|
||||
),
|
||||
),
|
||||
]
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-30 10:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("submissions", "0005_alter_submission_notes"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
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,
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
]
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-30 11:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-30 20:39
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("submissions", "0007_submission_manual_validation_requested"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name="puzzleresponse",
|
||||
unique_together=set(),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-23 22:33
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("animations", "0001_initial"),
|
||||
("submissions", "0008_alter_puzzleresponse_unique_together"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="steamcollectionitem",
|
||||
name="points_factor",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="animations.puzzlepointsfactor",
|
||||
),
|
||||
),
|
||||
]
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-23 23:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("submissions", "0009_steamcollectionitem_points_factor"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="puzzleresponse",
|
||||
name="validated_area",
|
||||
field=models.IntegerField(
|
||||
blank=True, help_text="Manually validated area value"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="puzzleresponse",
|
||||
name="validated_cost",
|
||||
field=models.IntegerField(
|
||||
blank=True, help_text="Manually validated cost value"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="puzzleresponse",
|
||||
name="validated_cycles",
|
||||
field=models.IntegerField(
|
||||
blank=True, help_text="Manually validated cycles value"
|
||||
),
|
||||
),
|
||||
]
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-23 23:13
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("submissions", "0010_alter_puzzleresponse_validated_area_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="puzzleresponse",
|
||||
name="area",
|
||||
field=models.IntegerField(blank=True, help_text="Area value from OCR"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="puzzleresponse",
|
||||
name="cost",
|
||||
field=models.IntegerField(blank=True, help_text="Cost value from OCR"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="puzzleresponse",
|
||||
name="cycles",
|
||||
field=models.IntegerField(blank=True, help_text="Cycles value from OCR"),
|
||||
),
|
||||
]
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-23 23:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
(
|
||||
"submissions",
|
||||
"0011_alter_puzzleresponse_area_alter_puzzleresponse_cost_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="puzzleresponse",
|
||||
name="validated_area",
|
||||
field=models.IntegerField(
|
||||
blank=True, help_text="Manually validated area value", null=True
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="puzzleresponse",
|
||||
name="validated_cost",
|
||||
field=models.IntegerField(
|
||||
blank=True, help_text="Manually validated cost value", null=True
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="puzzleresponse",
|
||||
name="validated_cycles",
|
||||
field=models.IntegerField(
|
||||
blank=True, help_text="Manually validated cycles value", null=True
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-24 01:00
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("animations", "0002_puzzlepointsvalue"),
|
||||
("submissions", "0012_alter_puzzleresponse_validated_area_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="steamcollectionitem",
|
||||
name="points_value",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="animations.puzzlepointsvalue",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,495 @@
|
||||
from typing import Self
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ValidationError
|
||||
import uuid
|
||||
|
||||
from django.db.models.expressions import Window
|
||||
from django.db.models.functions import Cast, Rank, RowNumber
|
||||
from django.db.models.query import F
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class JsonIndex(models.Func):
|
||||
function = ""
|
||||
template = "%(json_field)s -> (%(index)s::int)"
|
||||
|
||||
def __init__(self, json_field, index_expression, **extra):
|
||||
super().__init__(json_field, index_expression, **extra)
|
||||
self.output_field = models.IntegerField()
|
||||
|
||||
def resolve_expression(
|
||||
self, query, allow_joins=True, reuse=None, summarize=False, for_save=False
|
||||
):
|
||||
# Resolve both expressions in the query context to ensure joins are set up correctly
|
||||
clone = self.copy()
|
||||
clone.source_expressions = [
|
||||
expr.resolve_expression(query, allow_joins, reuse, summarize, for_save)
|
||||
for expr in self.source_expressions
|
||||
]
|
||||
return clone
|
||||
|
||||
def as_sql(self, compiler, connection):
|
||||
json_sql, json_params = compiler.compile(self.source_expressions[0])
|
||||
idx_sql, idx_params = compiler.compile(self.source_expressions[1])
|
||||
sql = self.template % {"json_field": json_sql, "index": idx_sql}
|
||||
params = json_params + idx_params
|
||||
return sql, params
|
||||
|
||||
|
||||
class SteamAPIKey(models.Model):
|
||||
"""Model to store Steam API key configuration - Admin only"""
|
||||
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
unique=True,
|
||||
help_text="Descriptive name for this API key (e.g., 'Production Key', 'Development Key')",
|
||||
)
|
||||
api_key = models.CharField(
|
||||
max_length=64,
|
||||
help_text="Steam Web API key from https://steamcommunity.com/dev/apikey",
|
||||
)
|
||||
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"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
last_used = models.DateTimeField(
|
||||
null=True, blank=True, help_text="When this API key was last used"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Steam API Key"
|
||||
verbose_name_plural = "Steam API Keys"
|
||||
ordering = ["-is_active", "name"]
|
||||
|
||||
def __str__(self):
|
||||
status = "Active" if self.is_active else "Inactive"
|
||||
return f"{self.name} ({status})"
|
||||
|
||||
def clean(self):
|
||||
"""Validate the API key format"""
|
||||
if self.api_key:
|
||||
# Steam API keys are typically 32 characters of hexadecimal
|
||||
if len(self.api_key) != 32:
|
||||
raise ValidationError("Steam API key should be 32 characters long")
|
||||
|
||||
# Check if it's hexadecimal
|
||||
try:
|
||||
int(self.api_key, 16)
|
||||
except ValueError:
|
||||
raise ValidationError(
|
||||
"Steam API key should contain only hexadecimal characters (0-9, A-F)"
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def get_active_key(cls):
|
||||
"""Get the currently active API key"""
|
||||
return cls.objects.filter(is_active=True).first()
|
||||
|
||||
@property
|
||||
def masked_key(self):
|
||||
"""Return a masked version of the API key for display"""
|
||||
if not self.api_key:
|
||||
return ""
|
||||
return f"{self.api_key[:8]}{'*' * 16}{self.api_key[-8:]}"
|
||||
|
||||
|
||||
class SteamCollection(models.Model):
|
||||
"""Model representing a Steam Workshop collection"""
|
||||
|
||||
# Basic collection info
|
||||
steam_id = models.CharField(
|
||||
max_length=50, unique=True, help_text="Steam collection ID from URL"
|
||||
)
|
||||
url = models.URLField(help_text="Full Steam Workshop collection URL")
|
||||
title = models.CharField(max_length=255, blank=True, help_text="Collection title")
|
||||
description = models.TextField(blank=True, help_text="Collection description")
|
||||
|
||||
# Author information
|
||||
author_name = models.CharField(
|
||||
max_length=100, blank=True, help_text="Steam username of collection creator"
|
||||
)
|
||||
author_steam_id = models.CharField(
|
||||
max_length=50, blank=True, help_text="Steam ID of collection creator"
|
||||
)
|
||||
|
||||
# Collection metadata
|
||||
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"
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
steam_created_date = models.DateTimeField(
|
||||
null=True, blank=True, help_text="When collection was created on Steam"
|
||||
)
|
||||
steam_updated_date = models.DateTimeField(
|
||||
null=True, blank=True, help_text="When collection was last updated on Steam"
|
||||
)
|
||||
|
||||
# Local tracking
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
last_fetched = models.DateTimeField(
|
||||
null=True, blank=True, help_text="When data was last fetched from Steam"
|
||||
)
|
||||
|
||||
# Status
|
||||
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"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
verbose_name = "Steam Collection"
|
||||
verbose_name_plural = "Steam Collections"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title or f'Collection {self.steam_id}'}"
|
||||
|
||||
@property
|
||||
def steam_url(self):
|
||||
"""Generate the Steam Workshop URL from steam_id"""
|
||||
return f"https://steamcommunity.com/workshop/filedetails/?id={self.steam_id}"
|
||||
|
||||
|
||||
class SteamCollectionItem(models.Model):
|
||||
"""Model representing individual items within a Steam collection"""
|
||||
|
||||
# Relationships
|
||||
collection = models.ForeignKey(
|
||||
SteamCollection, on_delete=models.CASCADE, related_name="items"
|
||||
)
|
||||
|
||||
# Item identification
|
||||
steam_item_id = models.CharField(max_length=50, help_text="Steam Workshop item ID")
|
||||
title = models.CharField(max_length=255, blank=True, help_text="Item title")
|
||||
|
||||
# Author information
|
||||
author_name = models.CharField(
|
||||
max_length=100, blank=True, help_text="Steam username of item creator"
|
||||
)
|
||||
author_steam_id = models.CharField(
|
||||
max_length=50, blank=True, help_text="Steam ID of item creator"
|
||||
)
|
||||
|
||||
# Item metadata
|
||||
description = models.TextField(blank=True, help_text="Item description")
|
||||
tags = models.JSONField(
|
||||
default=list, blank=True, help_text="Item tags as JSON array"
|
||||
)
|
||||
|
||||
# Position in collection
|
||||
order_index = models.PositiveIntegerField(
|
||||
default=0, help_text="Order of item in collection"
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
# Puzzle points
|
||||
points_factor = models.ForeignKey(
|
||||
"animations.PuzzlePointsFactor",
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
|
||||
points_value = models.ForeignKey(
|
||||
"animations.PuzzlePointsValue",
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["collection", "order_index"]
|
||||
unique_together = ["collection", "steam_item_id"]
|
||||
verbose_name = "Steam Collection Item"
|
||||
verbose_name_plural = "Steam Collection Items"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title or f'Item {self.steam_item_id}'} (in {self.collection})"
|
||||
|
||||
@property
|
||||
def steam_url(self):
|
||||
"""Generate the Steam Workshop URL for this item"""
|
||||
return (
|
||||
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(
|
||||
null=True,
|
||||
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"
|
||||
)
|
||||
|
||||
# Manual validation request
|
||||
manual_validation_requested = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether the user specifically requested manual validation",
|
||||
)
|
||||
|
||||
# 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 PuzzleResponseQuerySet(models.QuerySet):
|
||||
def annotate_rank_points(self) -> Self:
|
||||
return (
|
||||
self.annotate(
|
||||
points=F("puzzle__points_factor__cost") * F("validated_cost")
|
||||
+ F("puzzle__points_factor__cycles") * F("validated_cycles")
|
||||
+ F("puzzle__points_factor__area") * F("validated_area")
|
||||
)
|
||||
.annotate(
|
||||
user_response_rank=Window(
|
||||
expression=RowNumber(),
|
||||
partition_by=[F("puzzle"), F("submission__user")],
|
||||
order_by=F("points").asc(),
|
||||
)
|
||||
)
|
||||
# .filter(user_response_rank=1)
|
||||
.annotate(
|
||||
puzzle_user_rank=Window(
|
||||
expression=Rank(),
|
||||
partition_by=[F("puzzle")],
|
||||
order_by=F("points").asc(),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
rank_points=Cast(
|
||||
JsonIndex(
|
||||
F("puzzle__points_value__points"),
|
||||
Cast(F("puzzle_user_rank") - 1, models.IntegerField()),
|
||||
),
|
||||
models.IntegerField(),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def filter_user_best_response(self) -> Self:
|
||||
return self.annotate_rank_points().filter(user_response_rank=1)
|
||||
|
||||
|
||||
class PuzzleResponseManager(models.Manager.from_queryset(PuzzleResponseQuerySet)):
|
||||
pass
|
||||
|
||||
|
||||
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.IntegerField(blank=True, help_text="Cost value from OCR")
|
||||
cycles = models.IntegerField(blank=True, help_text="Cycles value from OCR")
|
||||
area = models.IntegerField(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_cost = models.FloatField(
|
||||
null=True, blank=True, help_text="OCR confidence score for cost (0.0 to 1.0)"
|
||||
)
|
||||
ocr_confidence_cycles = models.FloatField(
|
||||
null=True, blank=True, help_text="OCR confidence score for cycles (0.0 to 1.0)"
|
||||
)
|
||||
ocr_confidence_area = models.FloatField(
|
||||
null=True, blank=True, help_text="OCR confidence score for area (0.0 to 1.0)"
|
||||
)
|
||||
|
||||
# Manual validation overrides
|
||||
validated_cost = models.IntegerField(
|
||||
null=True, blank=True, help_text="Manually validated cost value"
|
||||
)
|
||||
validated_cycles = models.IntegerField(
|
||||
null=True, blank=True, help_text="Manually validated cycles value"
|
||||
)
|
||||
validated_area = models.IntegerField(
|
||||
null=True, blank=True, help_text="Manually validated area value"
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
objects = PuzzleResponseManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ["submission", "puzzle__order_index"]
|
||||
verbose_name = "Puzzle Response"
|
||||
verbose_name_plural = "Puzzle Responses"
|
||||
_base_manager_name = "objects"
|
||||
|
||||
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,182 @@
|
||||
from ninja import Schema, ModelSchema
|
||||
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[int] = None
|
||||
cycles: Optional[int] = None
|
||||
area: Optional[int] = None
|
||||
needs_manual_validation: bool = False
|
||||
ocr_confidence_cost: Optional[float] = None
|
||||
ocr_confidence_cycles: Optional[float] = None
|
||||
ocr_confidence_area: Optional[float] = None
|
||||
|
||||
|
||||
class SubmissionIn(Schema):
|
||||
"""Schema for creating a submission"""
|
||||
|
||||
notes: Optional[str] = None
|
||||
manual_validation_requested: bool = False
|
||||
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[int]
|
||||
final_cycles: Optional[int]
|
||||
final_area: Optional[int]
|
||||
|
||||
class Meta:
|
||||
model = PuzzleResponse
|
||||
fields = [
|
||||
"id",
|
||||
"puzzle",
|
||||
"puzzle_name",
|
||||
"cost",
|
||||
"cycles",
|
||||
"area",
|
||||
"needs_manual_validation",
|
||||
"ocr_confidence_cost",
|
||||
"ocr_confidence_cycles",
|
||||
"ocr_confidence_area",
|
||||
"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",
|
||||
"manual_validation_requested",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class SubmissionListOut(Schema):
|
||||
"""Schema for submission list output"""
|
||||
|
||||
id: UUID
|
||||
# user: 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"""
|
||||
|
||||
puzzle: Optional[int] = None
|
||||
validated_cost: Optional[int] = None
|
||||
validated_cycles: Optional[int] = None
|
||||
validated_area: Optional[int] = 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
|
||||
|
||||
|
||||
# User Schemas
|
||||
class UserInfoOut(Schema):
|
||||
"""Schema for user information output"""
|
||||
|
||||
id: Optional[int] = None
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
is_authenticated: bool
|
||||
is_staff: bool
|
||||
is_superuser: bool
|
||||
cas_groups: Optional[List[str]] = None
|
||||
@@ -0,0 +1 @@
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,516 @@
|
||||
"""
|
||||
Utilities for fetching Steam Workshop collection data using Steam Web API
|
||||
"""
|
||||
|
||||
import re
|
||||
import requests
|
||||
from submissions.models import SteamCollection, SteamCollectionItem, SubmissionFile
|
||||
from datetime import datetime
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
import logging
|
||||
from PIL import Image
|
||||
import cv2
|
||||
import pytesseract
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SteamAPIClient:
|
||||
"""Client for interacting with Steam Web API"""
|
||||
|
||||
BASE_URL = "https://api.steampowered.com"
|
||||
|
||||
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.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"])
|
||||
return api_key_obj.api_key
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not fetch API key from database: {e}")
|
||||
return None
|
||||
|
||||
def get_published_file_details(self, file_ids: List[str]) -> Dict:
|
||||
"""
|
||||
Get details for published files (collections/items) using Steam Web API
|
||||
|
||||
Args:
|
||||
file_ids: List of Steam Workshop file IDs
|
||||
|
||||
Returns:
|
||||
API response data
|
||||
"""
|
||||
url = f"{self.BASE_URL}/ISteamRemoteStorage/GetPublishedFileDetails/v1/"
|
||||
|
||||
# Prepare form data for POST request
|
||||
data = {
|
||||
"itemcount": len(file_ids),
|
||||
}
|
||||
|
||||
# Add each file ID
|
||||
for i, file_id in enumerate(file_ids):
|
||||
data[f"publishedfileids[{i}]"] = file_id
|
||||
|
||||
try:
|
||||
response = self.session.post(url, data=data, timeout=30)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to fetch Steam API data: {e}")
|
||||
raise
|
||||
|
||||
|
||||
class SteamCollectionFetcher:
|
||||
"""Utility class for fetching Steam Workshop collection data using Steam API"""
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None):
|
||||
self.api_client = SteamAPIClient(api_key)
|
||||
|
||||
def extract_collection_id(self, url: str) -> Optional[str]:
|
||||
"""
|
||||
Extract Steam collection ID from various URL formats
|
||||
|
||||
Args:
|
||||
url: Steam Workshop collection URL
|
||||
|
||||
Returns:
|
||||
Collection ID as string, or None if not found
|
||||
"""
|
||||
# Handle different URL formats
|
||||
patterns = [
|
||||
r"steamcommunity\.com/workshop/filedetails/\?id=(\d+)",
|
||||
r"steamcommunity\.com/sharedfiles/filedetails/\?id=(\d+)",
|
||||
r"id=(\d+)",
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
|
||||
def fetch_collection_data(self, url: str) -> Dict:
|
||||
"""
|
||||
Fetch collection data from Steam Web API
|
||||
|
||||
Args:
|
||||
url: Steam Workshop collection URL
|
||||
|
||||
Returns:
|
||||
Dictionary containing collection data
|
||||
|
||||
Raises:
|
||||
requests.RequestException: If API request fails
|
||||
ValueError: If collection ID cannot be extracted or data is invalid
|
||||
"""
|
||||
collection_id = self.extract_collection_id(url)
|
||||
if not collection_id:
|
||||
raise ValueError(f"Cannot extract collection ID from URL: {url}")
|
||||
|
||||
# Fetch collection details from Steam API
|
||||
api_response = self.api_client.get_published_file_details([collection_id])
|
||||
|
||||
if "response" not in api_response:
|
||||
raise ValueError("Invalid API response format")
|
||||
|
||||
response_data = api_response["response"]
|
||||
|
||||
if (
|
||||
"publishedfiledetails" not in response_data
|
||||
or not response_data["publishedfiledetails"]
|
||||
):
|
||||
raise ValueError("No collection data found in API response")
|
||||
|
||||
collection_data = response_data["publishedfiledetails"][0]
|
||||
|
||||
# Check if collection exists and is accessible
|
||||
if collection_data.get("result") != 1:
|
||||
raise ValueError(
|
||||
f"Collection not found or inaccessible (result: {collection_data.get('result')})"
|
||||
)
|
||||
|
||||
return self._parse_api_collection_data(collection_data, collection_id, url)
|
||||
|
||||
def _parse_api_collection_data(
|
||||
self, api_data: Dict, collection_id: str, url: str
|
||||
) -> Dict:
|
||||
"""
|
||||
Parse collection data from Steam API response
|
||||
|
||||
Args:
|
||||
api_data: Steam API response data for the collection
|
||||
collection_id: Steam collection ID
|
||||
url: Original URL
|
||||
|
||||
Returns:
|
||||
Dictionary containing parsed collection data
|
||||
"""
|
||||
data = {
|
||||
"steam_id": collection_id,
|
||||
"url": url,
|
||||
"title": api_data.get("title", ""),
|
||||
"description": api_data.get("description", ""),
|
||||
"author_name": "",
|
||||
"author_steam_id": str(api_data.get("creator", "")),
|
||||
"total_items": 0,
|
||||
"unique_visitors": api_data.get("views", 0),
|
||||
"current_favorites": api_data.get("favorited", 0),
|
||||
"total_favorites": api_data.get("lifetime_favorited", 0),
|
||||
"steam_created_date": None,
|
||||
"steam_updated_date": None,
|
||||
"items": [],
|
||||
}
|
||||
|
||||
# Parse timestamps
|
||||
if "time_created" in api_data:
|
||||
data["steam_created_date"] = timezone.make_aware(
|
||||
datetime.fromtimestamp(api_data["time_created"])
|
||||
)
|
||||
|
||||
if "time_updated" in api_data:
|
||||
data["steam_updated_date"] = timezone.make_aware(
|
||||
datetime.fromtimestamp(api_data["time_updated"])
|
||||
)
|
||||
|
||||
# Get author name if we have Steam ID
|
||||
if data["author_steam_id"]:
|
||||
try:
|
||||
author_info = self._get_user_info(data["author_steam_id"])
|
||||
if author_info:
|
||||
data["author_name"] = author_info.get("personaname", "")
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not fetch author info: {e}")
|
||||
|
||||
# Fetch collection items using GetCollectionDetails API
|
||||
data["items"] = self._fetch_collection_items_via_api(collection_id)
|
||||
data["total_items"] = len(data["items"])
|
||||
|
||||
return data
|
||||
|
||||
def _get_user_info(self, steam_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
Get user information from Steam API
|
||||
|
||||
Args:
|
||||
steam_id: Steam user ID
|
||||
|
||||
Returns:
|
||||
User info dictionary or None if not available
|
||||
"""
|
||||
if not self.api_client.api_key:
|
||||
return None
|
||||
|
||||
url = f"{self.api_client.BASE_URL}/ISteamUser/GetPlayerSummaries/v0002/"
|
||||
params = {"key": self.api_client.api_key, "steamids": steam_id}
|
||||
|
||||
try:
|
||||
response = self.api_client.session.get(url, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if (
|
||||
"response" in data
|
||||
and "players" in data["response"]
|
||||
and data["response"]["players"]
|
||||
):
|
||||
return data["response"]["players"][0]
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to fetch user info for {steam_id}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
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}
|
||||
|
||||
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:
|
||||
# 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),
|
||||
}
|
||||
)
|
||||
|
||||
# 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]
|
||||
|
||||
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):
|
||||
batch_ids = item_ids[i : i + batch_size]
|
||||
|
||||
try:
|
||||
api_response = self.api_client.get_published_file_details(batch_ids)
|
||||
|
||||
if (
|
||||
"response" in api_response
|
||||
and "publishedfiledetails" in api_response["response"]
|
||||
):
|
||||
for j, item_data in enumerate(
|
||||
api_response["response"]["publishedfiledetails"]
|
||||
):
|
||||
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),
|
||||
"title": item_data.get("title", ""),
|
||||
"author_name": "",
|
||||
"author_steam_id": str(item_data.get("creator", "")),
|
||||
"description": item_data.get("description", ""),
|
||||
"tags": [
|
||||
tag.get("tag", "")
|
||||
for tag in item_data.get("tags", [])
|
||||
],
|
||||
"order_index": i + j,
|
||||
}
|
||||
|
||||
# Get author name if available
|
||||
if item_info["author_steam_id"]:
|
||||
try:
|
||||
author_info = self._get_user_info(
|
||||
item_info["author_steam_id"]
|
||||
)
|
||||
if author_info:
|
||||
item_info["author_name"] = author_info.get(
|
||||
"personaname", ""
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"Could not fetch item author info: {e}"
|
||||
)
|
||||
|
||||
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')}"
|
||||
)
|
||||
|
||||
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:
|
||||
"""
|
||||
Convenience function to fetch Steam collection data
|
||||
|
||||
Args:
|
||||
url: Steam Workshop collection URL
|
||||
|
||||
Returns:
|
||||
Dictionary containing collection data
|
||||
"""
|
||||
fetcher = SteamCollectionFetcher()
|
||||
return fetcher.fetch_collection_data(url)
|
||||
|
||||
|
||||
def create_or_update_collection(url: str) -> Tuple[SteamCollection, bool]:
|
||||
"""
|
||||
Create or update a Steam collection in the database
|
||||
|
||||
Args:
|
||||
url: Steam Workshop collection URL
|
||||
|
||||
Returns:
|
||||
Tuple of (SteamCollection instance, created_flag)
|
||||
|
||||
Raises:
|
||||
ValueError: If collection cannot be fetched or parsed
|
||||
"""
|
||||
from .models import SteamCollection, SteamCollectionItem
|
||||
|
||||
# Fetch data from Steam
|
||||
data = fetch_steam_collection(url)
|
||||
|
||||
# Create or update collection
|
||||
collection, created = SteamCollection.objects.update_or_create(
|
||||
steam_id=data["steam_id"],
|
||||
defaults={
|
||||
"url": data["url"],
|
||||
"title": data["title"],
|
||||
"description": data["description"],
|
||||
"author_name": data["author_name"],
|
||||
"author_steam_id": data["author_steam_id"],
|
||||
"total_items": data["total_items"],
|
||||
"unique_visitors": data["unique_visitors"],
|
||||
"current_favorites": data["current_favorites"],
|
||||
"total_favorites": data["total_favorites"],
|
||||
"steam_created_date": data["steam_created_date"],
|
||||
"steam_updated_date": data["steam_updated_date"],
|
||||
"last_fetched": timezone.now(),
|
||||
"fetch_error": "", # Clear any previous errors
|
||||
},
|
||||
)
|
||||
|
||||
# Update collection items
|
||||
# First, remove existing items
|
||||
collection.items.all().delete()
|
||||
|
||||
# Add new items
|
||||
for item_data in data["items"]:
|
||||
SteamCollectionItem.objects.create(
|
||||
collection=collection,
|
||||
steam_item_id=item_data["steam_item_id"],
|
||||
title=item_data["title"],
|
||||
author_name=item_data["author_name"],
|
||||
author_steam_id=item_data["author_steam_id"],
|
||||
description=item_data["description"],
|
||||
tags=item_data["tags"],
|
||||
order_index=item_data["order_index"],
|
||||
)
|
||||
|
||||
return collection, created
|
||||
|
||||
|
||||
def verify_ocr_data_for_file(file: str) -> tuple[str, int, int, int]:
|
||||
# Convert GIF to JPG
|
||||
with Image.open(file) as img:
|
||||
# width, height = img.size
|
||||
img.seek(0)
|
||||
rgb_img = img.convert("RGB")
|
||||
rgb_img.save("temp.jpg", "JPEG")
|
||||
|
||||
# Read image from which text needs to be extracted
|
||||
img = cv2.imread("temp.jpg")
|
||||
|
||||
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
# Optional: resize for better OCR
|
||||
gray = cv2.resize(gray, None, fx=1, fy=1, interpolation=cv2.INTER_CUBIC)
|
||||
|
||||
# Manually crop regions based on known layout (x, y, w, h)
|
||||
regions = [
|
||||
(15, 600, 330, 28), # PUZZLE NAME
|
||||
(412, 603, 65, 22), # COST
|
||||
(577, 603, 65, 22), # CYCLES
|
||||
(739, 603, 65, 22), # AREA
|
||||
]
|
||||
|
||||
output = img.copy()
|
||||
|
||||
def find_text(dims, gray, output, content):
|
||||
x, y, w, h = dims
|
||||
roi = gray[y : y + h, x : x + w]
|
||||
roi = cv2.bitwise_not(roi)
|
||||
if content == "digits" or content == "digits_with_6":
|
||||
config = "--oem 3 --psm 7 -c tessedit_char_whitelist=0123456789"
|
||||
|
||||
else:
|
||||
config = "--oem 3 --psm 7"
|
||||
|
||||
text = pytesseract.image_to_string(roi, config=config).strip()
|
||||
|
||||
# Remove the extra 6 (actually the G for Gold) for cost value
|
||||
if content == "digits_with_6":
|
||||
text = text[:-1]
|
||||
|
||||
cv2.rectangle(output, (x, y), (x + w, y + h), (0, 255, 0), 2)
|
||||
return text
|
||||
|
||||
puzzle = find_text(regions[0], gray, output, "letters")
|
||||
cost = find_text(regions[1], gray, output, "digits_with_6")
|
||||
cycles = find_text(regions[2], gray, output, "digits")
|
||||
area = find_text(regions[3], gray, output, "digits")
|
||||
|
||||
# Save image with green rectangles around the considered zones, for debug purposes
|
||||
# cv2.imwrite("output_debug.jpg", output)
|
||||
|
||||
os.remove("temp.jpg")
|
||||
return puzzle, int(cost), int(cycles), int(area)
|
||||
|
||||
|
||||
def verify_and_validate_ocr_date_for_submission(file: SubmissionFile):
|
||||
ocr_data = verify_ocr_data_for_file(file.file.path)
|
||||
r = file.response
|
||||
|
||||
print(
|
||||
f"{r.submission.user}: ({r.cost: >4} {r.cycles: >4} {r.area: >4}) -> ({ocr_data[1]: >4} {ocr_data[2]: >4} {ocr_data[3]: >4})"
|
||||
)
|
||||
|
||||
if puzzle := SteamCollectionItem.objects.filter(title=ocr_data[0]).first():
|
||||
# print(f"{r.puzzle.title} -> {ocr_data[0]}")
|
||||
r.puzzle = puzzle
|
||||
|
||||
valid_count = 0
|
||||
for index, field in enumerate(["cost", "cycles", "area"]):
|
||||
value = getattr(r, field, -1)
|
||||
|
||||
# print(f"{value} -> {ocr_data[index + 1]}")
|
||||
if value == ocr_data[index + 1]:
|
||||
setattr(r, f"validated_{field}", value)
|
||||
valid_count += 1
|
||||
|
||||
else:
|
||||
setattr(r, field, ocr_data[index + 1])
|
||||
|
||||
r.needs_manual_validation = valid_count != 3
|
||||
r.save()
|
||||
@@ -0,0 +1 @@
|
||||
# Create your views here.
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
{% load django_vite %}
|
||||
<html lang="en" data-theme="dim">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Opus Magnum Puzzle Submitter</title>
|
||||
{% vite_hmr_client %}
|
||||
{% vite_asset 'src/main.ts' %}
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" data-collection-title="{{ collection.title }}" data-collection-url="{{ collection.url }}" data-collection-description="{{ collection.description }}"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
/* Vue 3 specific */
|
||||
"types": [
|
||||
"vite/client"
|
||||
],
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.vue"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"root":["./src/main.ts","./src/services/apiService.ts","./src/services/ocrService.ts","./src/stores/index.ts","./src/stores/puzzles.ts","./src/stores/submissions.ts","./src/stores/uploads.ts","./src/types/index.ts","./src/App.vue","./src/components/AdminPanel.vue","./src/components/FileUpload.vue","./src/components/PuzzleCard.vue","./src/components/Results.vue","./src/components/SubmissionForm.vue"],"version":"5.9.3"}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { resolve } from 'path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
base: '/static/',
|
||||
plugins: [
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
manifest: 'manifest.json',
|
||||
outDir: resolve("./static_source/vite"),
|
||||
rollupOptions: {
|
||||
input: { main: resolve('./src/main.ts') }
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
base: '/static/',
|
||||
plugins: [
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
manifest: 'manifest.json',
|
||||
outDir: resolve("./static_source/vite"),
|
||||
rollupOptions: {
|
||||
input:
|
||||
{ main: resolve('./src/main.ts') }
|
||||
}
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user