chore: opus-submitter -> polylan-submitter

This commit is contained in:
2026-05-09 23:25:34 +02:00
parent eb1eed852b
commit 404af4f90d
101 changed files with 14 additions and 48 deletions
+24
View File
@@ -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
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+5
View File
@@ -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).
+36
View File
@@ -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"
+6
View File
@@ -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()),
],
),
]
+58
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
# Create your tests here.
+1
View File
@@ -0,0 +1 @@
# Create your views here.
+59
View File
@@ -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",),
},
),
)
+36
View File
@@ -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,
}
+6
View File
@@ -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),
),
]
+24
View File
@@ -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)
+37
View File
@@ -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]]
+1
View File
@@ -0,0 +1 @@
# Create your tests here.
+1
View File
@@ -0,0 +1 @@
# Create your views here.
+23
View File
@@ -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()
+32
View File
@@ -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"
}
}
+1547
View File
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()
+1
View File
@@ -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

+92
View File
@@ -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
+50
View File
@@ -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)
+250
View File
@@ -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>
+11
View File
@@ -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();
+3
View File
@@ -0,0 +1,3 @@
import { createPinia } from 'pinia'
export const pinia = createPinia()
+78
View File
@@ -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
}
})
+146
View File
@@ -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
}
})
+103
View File
@@ -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
}
})
+39
View File
@@ -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;
}
+102
View File
@@ -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
@@ -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

+388
View File
@@ -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"
+293
View File
@@ -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,
}
+6
View File
@@ -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"],
},
),
]
@@ -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
),
),
]
@@ -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,
),
),
]
@@ -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",
),
),
]
@@ -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"
),
),
]
@@ -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"),
),
]
@@ -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",
),
),
]
+495
View File
@@ -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)
+182
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
# Create your tests here.
+516
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
# Create your views here.
+15
View File
@@ -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>
+43
View File
@@ -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"
}
]
}
+11
View File
@@ -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
+1
View File
@@ -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"}
+2
View File
@@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;
+25
View File
@@ -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') }
}
},
});
+27
View File
@@ -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') }
}
},
})