Compare commits

...

6 Commits

Author SHA1 Message Date
f98145d6db some fixes 2025-10-31 01:05:57 +01:00
0e1e77c2dd ruff + submissions validation 2025-10-30 14:43:19 +01:00
15de496501 try for better ocr puzzle 2025-10-30 14:29:50 +01:00
8960f551e6 add manual validation request on the submission form 2025-10-30 12:12:33 +01:00
2260c7cc27 change for ocr confidence 2025-10-30 12:01:49 +01:00
b5f31a8c72 small ui tweaks 2025-10-30 11:32:00 +01:00
54 changed files with 2078 additions and 798 deletions

View File

@ -1,231 +0,0 @@
# Opus Magnum Submission API Usage
## Overview
The API is built with Django Ninja and provides endpoints for managing puzzle submissions with OCR validation and S3 file storage.
## Base URL
- Development: `http://localhost:8000/api/`
- API Documentation: `http://localhost:8000/api/docs/`
## Authentication
Most endpoints support both authenticated and anonymous submissions. Admin endpoints require staff permissions.
## Environment Variables
### Required for S3 Storage
```bash
USE_S3=true
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
AWS_STORAGE_BUCKET_NAME=your_bucket_name
AWS_S3_REGION_NAME=us-east-1
```
### Optional
```bash
STEAM_API_KEY=your_steam_api_key
```
## API Endpoints
### 1. Get Available Puzzles
```http
GET /api/submissions/puzzles
```
Response:
```json
[
{
"id": 1,
"steam_item_id": "3479143948",
"title": "P41-FLOC",
"author_name": "Flame Legrems",
"description": "A challenging puzzle...",
"tags": ["puzzle", "chemistry"],
"order_index": 0,
"steam_url": "https://steamcommunity.com/workshop/filedetails/?id=3479143948",
"created_at": "2025-05-29T11:19:24Z",
"updated_at": "2025-05-30T22:15:09Z"
}
]
```
### 2. Create Submission
```http
POST /api/submissions/submissions
Content-Type: multipart/form-data
```
Form Data:
- `data`: JSON with submission data
- `files`: Array of uploaded files
Example data:
```json
{
"notes": "My best solutions so far",
"responses": [
{
"puzzle_id": 1,
"puzzle_name": "P41-FLOC",
"cost": "150",
"cycles": "89",
"area": "12",
"needs_manual_validation": false,
"ocr_confidence_score": 0.95
}
]
}
```
Response:
```json
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"user": null,
"notes": "My best solutions so far",
"responses": [
{
"id": 1,
"puzzle": 1,
"puzzle_name": "P41-FLOC",
"cost": "150",
"cycles": "89",
"area": "12",
"needs_manual_validation": false,
"files": [
{
"id": 1,
"original_filename": "solution.gif",
"file_size": 1024000,
"content_type": "image/gif",
"file_url": "https://bucket.s3.amazonaws.com/media/submissions/123.../file.gif",
"ocr_processed": false,
"created_at": "2025-10-29T00:00:00Z"
}
],
"final_cost": "150",
"final_cycles": "89",
"final_area": "12"
}
],
"total_responses": 1,
"needs_validation": false,
"is_validated": false,
"created_at": "2025-10-29T00:00:00Z"
}
```
### 3. List Submissions
```http
GET /api/submissions/submissions?limit=20&offset=0
```
### 4. Get Submission Details
```http
GET /api/submissions/submissions/{submission_id}
```
### 5. Admin: Validate Response (Staff Only)
```http
PUT /api/submissions/responses/{response_id}/validate
Content-Type: application/json
```
Body:
```json
{
"validated_cost": "150",
"validated_cycles": "89",
"validated_area": "12"
}
```
### 6. Admin: List Responses Needing Validation (Staff Only)
```http
GET /api/submissions/responses/needs-validation
```
### 7. Admin: Validate Entire Submission (Staff Only)
```http
POST /api/submissions/submissions/{submission_id}/validate
```
### 8. Get Statistics
```http
GET /api/submissions/stats
```
Response:
```json
{
"total_submissions": 150,
"total_responses": 300,
"needs_validation": 25,
"validated_submissions": 120,
"validation_rate": 0.8
}
```
## OCR Validation Logic
The system automatically flags responses for manual validation when:
1. **Incomplete OCR Data**: Missing cost, cycles, or area values
2. **Low Confidence**: OCR confidence score below threshold
3. **Manual Flag**: Explicitly marked by frontend OCR processing
### Manual Validation Workflow
1. Admin views responses needing validation: `GET /responses/needs-validation`
2. Admin reviews the uploaded files and OCR results
3. Admin provides corrected values: `PUT /responses/{id}/validate`
4. System updates `validated_*` fields and clears validation flag
5. Optional: Mark entire submission as validated: `POST /submissions/{id}/validate`
## File Storage
- **Development**: Files stored locally in `media/submissions/`
- **Production**: Files stored in S3 with path structure: `submissions/{submission_id}/{uuid}_{filename}`
- **Supported Formats**: JPEG, PNG, GIF, MP4, WebM
- **Size Limit**: 10MB per file
## Error Handling
The API returns standard HTTP status codes:
- `200`: Success
- `400`: Bad Request (validation errors)
- `401`: Unauthorized
- `403`: Forbidden (admin required)
- `404`: Not Found
- `500`: Internal Server Error
Error Response Format:
```json
{
"detail": "Error message",
"code": "error_code"
}
```
## Frontend Integration
The Vue frontend should:
1. Upload files with OCR data extracted client-side
2. Group files by detected puzzle name
3. Create one submission with multiple responses
4. Handle validation flags and display admin feedback
5. Show file upload progress and S3 URLs
## Admin Interface
Django Admin provides:
- **Submission Management**: View, validate, and manage submissions
- **Response Validation**: Bulk actions for validation workflow
- **File Management**: View uploaded files and OCR data
- **Statistics Dashboard**: Track validation rates and submission metrics

View File

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

View File

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

View File

@ -7,41 +7,131 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('auth', '0012_alter_user_first_name_max_length'), ("auth", "0012_alter_user_first_name_max_length"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='CustomUser', name="CustomUser",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('password', models.CharField(max_length=128, verbose_name='password')), "id",
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), models.BigAutoField(
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), auto_created=True,
('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')), primary_key=True,
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), serialize=False,
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), verbose_name="ID",
('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')), ("password", models.CharField(max_length=128, verbose_name="password")),
('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)), "last_login",
('cas_groups', models.JSONField(blank=True, default=list)), models.DateTimeField(
('cas_attributes', models.JSONField(blank=True, default=dict)), blank=True, null=True, verbose_name="last login"
('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')), ),
(
"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={ options={
'verbose_name': 'user', "verbose_name": "user",
'verbose_name_plural': 'users', "verbose_name_plural": "users",
'abstract': False, "abstract": False,
}, },
managers=[ managers=[
('objects', django.contrib.auth.models.UserManager()), ("objects", django.contrib.auth.models.UserManager()),
], ],
), ),
] ]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -28,7 +28,15 @@ from .api import api
@login_required @login_required
def home(request: HttpRequest): def home(request: HttpRequest):
return render(request, "index.html", {}) from submissions.models import SteamCollection
return render(
request,
"index.html",
{
"collection": SteamCollection.objects.filter(is_active=True).last(),
},
)
urlpatterns = [ urlpatterns = [

View File

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

View File

@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.16", "@tailwindcss/vite": "^4.1.16",
"install": "^0.13.0", "install": "^0.13.0",
"pinia": "^3.0.3",
"tailwindcss": "^4.1.16", "tailwindcss": "^4.1.16",
"tesseract.js": "^5.1.1", "tesseract.js": "^5.1.1",
"vue": "^3.5.22" "vue": "^3.5.22"

View File

@ -14,6 +14,9 @@ importers:
install: install:
specifier: ^0.13.0 specifier: ^0.13.0
version: 0.13.0 version: 0.13.0
pinia:
specifier: ^3.0.3
version: 3.0.3(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3))
tailwindcss: tailwindcss:
specifier: ^4.1.16 specifier: ^4.1.16
version: 4.1.16 version: 4.1.16
@ -480,6 +483,15 @@ packages:
'@vue/compiler-ssr@3.5.22': '@vue/compiler-ssr@3.5.22':
resolution: {integrity: sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww==} resolution: {integrity: sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww==}
'@vue/devtools-api@7.7.7':
resolution: {integrity: sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==}
'@vue/devtools-kit@7.7.7':
resolution: {integrity: sha512-wgoZtxcTta65cnZ1Q6MbAfePVFxfM+gq0saaeytoph7nEa7yMXoi6sCPy4ufO111B9msnw0VOWjPEFCXuAKRHA==}
'@vue/devtools-shared@7.7.7':
resolution: {integrity: sha512-+udSj47aRl5aKb0memBvcUG9koarqnxNM5yjuREvqwK6T3ap4mn3Zqqc17QrBFTqSMjr3HK1cvStEZpMDpfdyw==}
'@vue/language-core@3.1.2': '@vue/language-core@3.1.2':
resolution: {integrity: sha512-PyFDCqpdfYUT+oMLqcc61oHfJlC6yjhybaefwQjRdkchIihToOEpJ2Wu/Ebq2yrnJdd1EsaAvZaXVAqcxtnDxQ==} resolution: {integrity: sha512-PyFDCqpdfYUT+oMLqcc61oHfJlC6yjhybaefwQjRdkchIihToOEpJ2Wu/Ebq2yrnJdd1EsaAvZaXVAqcxtnDxQ==}
peerDependencies: peerDependencies:
@ -519,9 +531,16 @@ packages:
alien-signals@3.0.3: alien-signals@3.0.3:
resolution: {integrity: sha512-2JXjom6R7ZwrISpUphLhf4htUq1aKRCennTJ6u9kFfr3sLmC9+I4CxxVi+McoFnIg+p1HnVrfLT/iCt4Dlz//Q==} resolution: {integrity: sha512-2JXjom6R7ZwrISpUphLhf4htUq1aKRCennTJ6u9kFfr3sLmC9+I4CxxVi+McoFnIg+p1HnVrfLT/iCt4Dlz//Q==}
birpc@2.6.1:
resolution: {integrity: sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==}
bmp-js@0.1.0: bmp-js@0.1.0:
resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==} resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==}
copy-anything@4.0.5:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'}
csstype@3.1.3: csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
@ -565,6 +584,9 @@ packages:
graceful-fs@4.2.11: graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
idb-keyval@6.2.2: idb-keyval@6.2.2:
resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==} resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==}
@ -578,6 +600,10 @@ packages:
is-url@1.2.4: is-url@1.2.4:
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==} resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
is-what@5.5.0:
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
engines: {node: '>=18'}
jiti@2.6.1: jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true hasBin: true
@ -655,6 +681,9 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
muggle-string@0.4.1: muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
@ -679,6 +708,9 @@ packages:
path-browserify@1.0.1: path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@ -686,6 +718,15 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'} engines: {node: '>=12'}
pinia@3.0.3:
resolution: {integrity: sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==}
peerDependencies:
typescript: '>=4.4.4'
vue: ^2.7.0 || ^3.5.11
peerDependenciesMeta:
typescript:
optional: true
postcss@8.5.6: postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
@ -693,6 +734,9 @@ packages:
regenerator-runtime@0.13.11: regenerator-runtime@0.13.11:
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
rollup@4.52.5: rollup@4.52.5:
resolution: {integrity: sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==} resolution: {integrity: sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'} engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@ -702,6 +746,14 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
speakingurl@14.0.1:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
superjson@2.2.5:
resolution: {integrity: sha512-zWPTX96LVsA/eVYnqOM2+ofcdPqdS1dAF1LN4TS2/MWuUpfitd9ctTa87wt4xrYnZnkLtS69xpBdSxVBP5Rm6w==}
engines: {node: '>=16'}
tailwindcss@4.1.16: tailwindcss@4.1.16:
resolution: {integrity: sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==} resolution: {integrity: sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==}
@ -1103,6 +1155,24 @@ snapshots:
'@vue/compiler-dom': 3.5.22 '@vue/compiler-dom': 3.5.22
'@vue/shared': 3.5.22 '@vue/shared': 3.5.22
'@vue/devtools-api@7.7.7':
dependencies:
'@vue/devtools-kit': 7.7.7
'@vue/devtools-kit@7.7.7':
dependencies:
'@vue/devtools-shared': 7.7.7
birpc: 2.6.1
hookable: 5.5.3
mitt: 3.0.1
perfect-debounce: 1.0.0
speakingurl: 14.0.1
superjson: 2.2.5
'@vue/devtools-shared@7.7.7':
dependencies:
rfdc: 1.4.1
'@vue/language-core@3.1.2(typescript@5.9.3)': '@vue/language-core@3.1.2(typescript@5.9.3)':
dependencies: dependencies:
'@volar/language-core': 2.4.23 '@volar/language-core': 2.4.23
@ -1146,8 +1216,14 @@ snapshots:
alien-signals@3.0.3: {} alien-signals@3.0.3: {}
birpc@2.6.1: {}
bmp-js@0.1.0: {} bmp-js@0.1.0: {}
copy-anything@4.0.5:
dependencies:
is-what: 5.5.0
csstype@3.1.3: {} csstype@3.1.3: {}
daisyui@5.3.10: {} daisyui@5.3.10: {}
@ -1201,6 +1277,8 @@ snapshots:
graceful-fs@4.2.11: {} graceful-fs@4.2.11: {}
hookable@5.5.3: {}
idb-keyval@6.2.2: {} idb-keyval@6.2.2: {}
install@0.13.0: {} install@0.13.0: {}
@ -1209,6 +1287,8 @@ snapshots:
is-url@1.2.4: {} is-url@1.2.4: {}
is-what@5.5.0: {}
jiti@2.6.1: {} jiti@2.6.1: {}
lightningcss-android-arm64@1.30.2: lightningcss-android-arm64@1.30.2:
@ -1264,6 +1344,8 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
mitt@3.0.1: {}
muggle-string@0.4.1: {} muggle-string@0.4.1: {}
nanoid@3.3.11: {} nanoid@3.3.11: {}
@ -1276,10 +1358,19 @@ snapshots:
path-browserify@1.0.1: {} path-browserify@1.0.1: {}
perfect-debounce@1.0.0: {}
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@4.0.3: {} picomatch@4.0.3: {}
pinia@3.0.3(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3)):
dependencies:
'@vue/devtools-api': 7.7.7
vue: 3.5.22(typescript@5.9.3)
optionalDependencies:
typescript: 5.9.3
postcss@8.5.6: postcss@8.5.6:
dependencies: dependencies:
nanoid: 3.3.11 nanoid: 3.3.11
@ -1288,6 +1379,8 @@ snapshots:
regenerator-runtime@0.13.11: {} regenerator-runtime@0.13.11: {}
rfdc@1.4.1: {}
rollup@4.52.5: rollup@4.52.5:
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
@ -1318,6 +1411,12 @@ snapshots:
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
speakingurl@14.0.1: {}
superjson@2.2.5:
dependencies:
copy-anything: 4.0.5
tailwindcss@4.1.16: {} tailwindcss@4.1.16: {}
tapable@2.3.0: {} tapable@2.3.0: {}

View File

@ -27,10 +27,10 @@ class SimpleCASBackend(BaseBackend):
print(f"CAS Attributes: {attributes}") print(f"CAS Attributes: {attributes}")
User = get_user_model() User = get_user_model()
# Try to find user by CAS user ID first, then by username # Try to find user by CAS user ID first, then by username
username = attributes.get("username", cas_user_id).lower() username = attributes.get("username", cas_user_id).lower()
try: try:
# First try to find by CAS user ID # First try to find by CAS user ID
user = User.objects.get(cas_user_id=cas_user_id) user = User.objects.get(cas_user_id=cas_user_id)
@ -50,10 +50,10 @@ class SimpleCASBackend(BaseBackend):
last_name=attributes.get("lastname", ""), last_name=attributes.get("lastname", ""),
email=attributes.get("email", ""), email=attributes.get("email", ""),
) )
# Always update CAS data on login # Always update CAS data on login
user.update_cas_data(cas_user_id, attributes) user.update_cas_data(cas_user_id, attributes)
return user return user
def validate_ticket(self, ticket, service): def validate_ticket(self, ticket, service):

View File

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

View File

@ -1,18 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed, defineProps } from 'vue'
import PuzzleCard from './components/PuzzleCard.vue' import PuzzleCard from '@/components/PuzzleCard.vue'
import SubmissionForm from './components/SubmissionForm.vue' import SubmissionForm from '@/components/SubmissionForm.vue'
import AdminPanel from './components/AdminPanel.vue' import AdminPanel from '@/components/AdminPanel.vue'
import { puzzleHelpers, submissionHelpers, errorHelpers, apiService } from './services/apiService' import { apiService, errorHelpers } from '@/services/apiService'
import type { SteamCollection, SteamCollectionItem, Submission, PuzzleResponse, UserInfo } from './types' import { usePuzzlesStore } from '@/stores/puzzles'
import { useSubmissionsStore } from '@/stores/submissions'
import type { SteamCollection, PuzzleResponse, UserInfo } from '@/types'
// API data const props = defineProps<{collectionTitle: string, collectionUrl: string, collectionDescription: string}>()
const collections = ref<SteamCollection[]>([])
const puzzles = ref<SteamCollectionItem[]>([]) // Pinia stores
const submissions = ref<Submission[]>([]) const puzzlesStore = usePuzzlesStore()
const submissionsStore = useSubmissionsStore()
// Local state
const userInfo = ref<UserInfo | null>(null) const userInfo = ref<UserInfo | null>(null)
const isLoading = ref(true) const isLoading = ref(true)
const showSubmissionModal = ref(false)
const error = ref<string>('') const error = ref<string>('')
// Mock data removed - using API data only // Mock data removed - using API data only
@ -25,7 +29,7 @@ const isSuperuser = computed(() => {
// Computed property to get responses grouped by puzzle // Computed property to get responses grouped by puzzle
const responsesByPuzzle = computed(() => { const responsesByPuzzle = computed(() => {
const grouped: Record<number, PuzzleResponse[]> = {} const grouped: Record<number, PuzzleResponse[]> = {}
submissions.value.forEach(submission => { submissionsStore.submissions.forEach(submission => {
submission.responses.forEach(response => { submission.responses.forEach(response => {
// Handle both number and object types for puzzle field // Handle both number and object types for puzzle field
const puzzleId = typeof response.puzzle === 'number' ? response.puzzle : response.puzzle.id const puzzleId = typeof response.puzzle === 'number' ? response.puzzle : response.puzzle.id
@ -55,34 +59,15 @@ onMounted(async () => {
console.warn('User info error:', userResponse.error) console.warn('User info error:', userResponse.error)
} }
// Load puzzles from API // Load puzzles from API using store
console.log('Loading puzzles...') console.log('Loading puzzles...')
const loadedPuzzles = await puzzleHelpers.loadPuzzles() await puzzlesStore.loadPuzzles()
puzzles.value = loadedPuzzles console.log('Puzzles loaded:', puzzlesStore.puzzles.length)
console.log('Puzzles loaded:', loadedPuzzles.length)
// Create mock collection from loaded puzzles for display // Load existing submissions using store
if (loadedPuzzles.length > 0) {
collections.value = [{
id: 1,
steam_id: '3479142989',
title: 'PolyLAN 41',
description: 'Puzzle collection for PolyLAN 41 fil rouge',
author_name: 'Flame Legrems',
total_items: loadedPuzzles.length,
unique_visitors: 31,
current_favorites: 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}]
console.log('Collection created')
}
// Load existing submissions
console.log('Loading submissions...') console.log('Loading submissions...')
const loadedSubmissions = await submissionHelpers.loadSubmissions() await submissionsStore.loadSubmissions()
submissions.value = loadedSubmissions console.log('Submissions loaded:', submissionsStore.submissions.length)
console.log('Submissions loaded:', loadedSubmissions.length)
console.log('Data load complete!') console.log('Data load complete!')
@ -97,37 +82,31 @@ onMounted(async () => {
const handleSubmission = async (submissionData: { const handleSubmission = async (submissionData: {
files: any[], files: any[],
notes?: string notes?: string,
manualValidationRequested?: boolean
}) => { }) => {
try { try {
isLoading.value = true isLoading.value = true
error.value = '' error.value = ''
// Create submission via API // Create submission via store
const response = await submissionHelpers.createFromFiles( const submission = await submissionsStore.createSubmission(
submissionData.files, submissionData.files,
puzzles.value, submissionData.notes,
submissionData.notes submissionData.manualValidationRequested
) )
if (response.error) {
error.value = response.error
alert(`Submission failed: ${response.error}`)
return
}
if (response.data) {
// Add to local submissions list
submissions.value.unshift(response.data)
// Show success message // Show success message
const puzzleNames = response.data.responses.map(r => r.puzzle_name).join(', ') if (submission) {
const puzzleNames = submission.responses.map(r => r.puzzle_name).join(', ')
alert(`Solutions submitted successfully for puzzles: ${puzzleNames}`) alert(`Solutions submitted successfully for puzzles: ${puzzleNames}`)
} else {
// Close modal alert('Submission created successfully!')
showSubmissionModal.value = false
} }
// Close modal
submissionsStore.closeSubmissionModal()
} catch (err) { } catch (err) {
const errorMessage = errorHelpers.getErrorMessage(err) const errorMessage = errorHelpers.getErrorMessage(err)
error.value = errorMessage error.value = errorMessage
@ -139,16 +118,16 @@ const handleSubmission = async (submissionData: {
} }
const openSubmissionModal = () => { const openSubmissionModal = () => {
showSubmissionModal.value = true submissionsStore.openSubmissionModal()
} }
const closeSubmissionModal = () => { const closeSubmissionModal = () => {
showSubmissionModal.value = false submissionsStore.closeSubmissionModal()
} }
// Function to match puzzle name from OCR to actual puzzle // Function to match puzzle name from OCR to actual puzzle
const findPuzzleByName = (ocrPuzzleName: string): SteamCollectionItem | null => { const findPuzzleByName = (ocrPuzzleName: string) => {
return puzzleHelpers.findPuzzleByName(puzzles.value, ocrPuzzleName) return puzzlesStore.findPuzzleByName(ocrPuzzleName)
} }
const reloadPage = () => { const reloadPage = () => {
@ -164,7 +143,7 @@ const reloadPage = () => {
<div class="flex-1"> <div class="flex-1">
<h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</h1> <h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</h1>
</div> </div>
<div class="flex-none"> <div class="flex items-start justify-between">
<div v-if="userInfo?.is_authenticated" class="flex items-center gap-2"> <div v-if="userInfo?.is_authenticated" class="flex items-center gap-2">
<div class="text-sm"> <div class="text-sm">
<span class="font-medium">{{ userInfo.username }}</span> <span class="font-medium">{{ userInfo.username }}</span>
@ -174,6 +153,11 @@ const reloadPage = () => {
<div v-else class="text-sm text-base-content/70"> <div v-else class="text-sm text-base-content/70">
Not logged in Not logged in
</div> </div>
<div class="flex flex-col items-end gap-2">
<a href="/admin" class="btn btn-xs btn-warning">
Admin django
</a>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -204,11 +188,11 @@ const reloadPage = () => {
<!-- Main Content --> <!-- Main Content -->
<div v-else class="space-y-8"> <div v-else class="space-y-8">
<!-- Collection Info --> <!-- Collection Info -->
<div v-if="collections.length > 0" class="mb-8"> <div class="mb-8">
<div class="card bg-base-100 shadow-lg"> <div class="card bg-base-100 shadow-lg">
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-2xl">{{ collections[0].title }}</h2> <h2 class="card-title text-2xl">{{ props.collectionTitle }}</h2>
<p class="text-base-content/70">{{ collections[0].description }}</p> <p class="text-base-content/70">{{ props.collectionDescription }}</p>
<div class="flex flex-wrap gap-4 mt-4"> <div class="flex flex-wrap gap-4 mt-4">
<button <button
@click="openSubmissionModal" @click="openSubmissionModal"
@ -230,7 +214,7 @@ const reloadPage = () => {
<!-- Puzzles Grid --> <!-- Puzzles Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<PuzzleCard <PuzzleCard
v-for="puzzle in puzzles" v-for="puzzle in puzzlesStore.puzzles"
:key="puzzle.id" :key="puzzle.id"
:puzzle="puzzle" :puzzle="puzzle"
:responses="responsesByPuzzle[puzzle.id] || []" :responses="responsesByPuzzle[puzzle.id] || []"
@ -238,7 +222,7 @@ const reloadPage = () => {
</div> </div>
<!-- Empty State --> <!-- Empty State -->
<div v-if="puzzles.length === 0" class="text-center py-12"> <div v-if="puzzlesStore.puzzles.length === 0" class="text-center py-12">
<div class="text-6xl mb-4">🧩</div> <div class="text-6xl mb-4">🧩</div>
<h3 class="text-xl font-bold mb-2">No Puzzles Available</h3> <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> <p class="text-base-content/70">Check back later for new puzzle collections!</p>
@ -247,7 +231,7 @@ const reloadPage = () => {
</div> </div>
<!-- Submission Modal --> <!-- Submission Modal -->
<div v-if="showSubmissionModal" class="modal modal-open"> <div v-if="submissionsStore.isSubmissionModalOpen" class="modal modal-open">
<div class="modal-box max-w-4xl"> <div class="modal-box max-w-4xl">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-lg">Submit Solution</h3> <h3 class="font-bold text-lg">Submit Solution</h3>
@ -260,7 +244,7 @@ const reloadPage = () => {
</div> </div>
<SubmissionForm <SubmissionForm
:puzzles="puzzles" :puzzles="puzzlesStore.puzzles"
:find-puzzle-by-name="findPuzzleByName" :find-puzzle-by-name="findPuzzleByName"
@submit="handleSubmission" @submit="handleSubmission"
/> />

View File

@ -43,19 +43,46 @@
<tbody> <tbody>
<tr v-for="response in responsesNeedingValidation" :key="response.id"> <tr v-for="response in responsesNeedingValidation" :key="response.id">
<td> <td>
<div class="font-bold">{{ response.puzzle_name }}</div> <div class="font-bold">{{ response.puzzle_title }}</div>
<div class="text-sm opacity-50">ID: {{ response.id }}</div> <div class="text-sm opacity-50">ID: {{ response.id }}</div>
</td> </td>
<td> <td>
<div class="text-sm space-y-1"> <div class="text-sm space-y-1">
<div>Cost: {{ response.cost || '-' }}</div> <div class="flex justify-between items-center">
<div>Cycles: {{ response.cycles || '-' }}</div> <span>Cost: {{ response.cost || '-' }}</span>
<div>Area: {{ response.area || '-' }}</div> <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> </div>
</td> </td>
<td> <td>
<div class="badge badge-warning badge-sm"> <div class="badge badge-warning badge-sm">
{{ response.ocr_confidence_score ? Math.round(response.ocr_confidence_score * 100) + '%' : 'Low' }} {{ getOverallConfidence(response) }}%
</div> </div>
</td> </td>
<td> <td>
@ -83,19 +110,43 @@
<!-- Validation Modal --> <!-- Validation Modal -->
<div v-if="validationModal.show" class="modal modal-open"> <div v-if="validationModal.show" class="modal modal-open">
<div class="modal-box"> <div class="modal-box w-11/12 max-w-5xl">
<h3 class="font-bold text-lg mb-4">Validate Response</h3> <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 v-if="validationModal.response" class="space-y-4">
<div class="alert alert-info"> <div class="alert alert-info">
<i class="mdi mdi-information-outline"></i> <i class="mdi mdi-information-outline"></i>
<div> <div>
<div class="font-bold">{{ validationModal.response.puzzle_name }}</div> <div class="font-bold">{{ validationModal.response.puzzle_title }}</div>
<div class="text-sm">Review and correct the OCR data below</div> <div class="text-sm">Review and correct the OCR data below</div>
</div> </div>
</div> </div>
<div class="grid grid-cols-3 gap-4"> <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"> <div class="form-control">
<label class="label"> <label class="label">
<span class="label-text">Cost</span> <span class="label-text">Cost</span>
@ -152,8 +203,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { apiService } from '../services/apiService' import { apiService } from '@/services/apiService'
import type { PuzzleResponse } from '../types' import type { PuzzleResponse } from '@/types'
import {usePuzzlesStore} from '@/stores/puzzles'
const puzzlesStore = usePuzzlesStore()
// Reactive data // Reactive data
const stats = ref({ const stats = ref({
@ -172,6 +225,7 @@ const validationModal = ref({
show: false, show: false,
response: null as PuzzleResponse | null, response: null as PuzzleResponse | null,
data: { data: {
puzzle_title: '',
validated_cost: '', validated_cost: '',
validated_cycles: '', validated_cycles: '',
validated_area: '' validated_area: ''
@ -217,6 +271,7 @@ const loadData = async () => {
const openValidationModal = (response: PuzzleResponse) => { const openValidationModal = (response: PuzzleResponse) => {
validationModal.value.response = response validationModal.value.response = response
validationModal.value.data = { validationModal.value.data = {
puzzle: response.puzzle || '',
validated_cost: response.cost || '', validated_cost: response.cost || '',
validated_cycles: response.cycles || '', validated_cycles: response.cycles || '',
validated_area: response.area || '' validated_area: response.area || ''
@ -228,6 +283,7 @@ const closeValidationModal = () => {
validationModal.value.show = false validationModal.value.show = false
validationModal.value.response = null validationModal.value.response = null
validationModal.value.data = { validationModal.value.data = {
puzzle: '',
validated_cost: '', validated_cost: '',
validated_cycles: '', validated_cycles: '',
validated_area: '' validated_area: ''
@ -273,6 +329,26 @@ onMounted(() => {
loadData() 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 // Expose refresh method
defineExpose({ defineExpose({
refresh: loadData refresh: loadData

View File

@ -83,7 +83,17 @@
<div v-else-if="file.ocrData" class="mt-1 space-y-1"> <div v-else-if="file.ocrData" class="mt-1 space-y-1">
<div class="text-xs flex items-center justify-between"> <div class="text-xs flex items-center justify-between">
<span class="font-medium text-success"> OCR Complete</span> <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 <button
@click="retryOCR(file)" @click="retryOCR(file)"
class="btn btn-xs btn-ghost" class="btn btn-xs btn-ghost"
@ -95,19 +105,74 @@
<div class="text-xs space-y-1 bg-base-200 p-2 rounded"> <div class="text-xs space-y-1 bg-base-200 p-2 rounded">
<div v-if="file.ocrData.puzzle"> <div v-if="file.ocrData.puzzle">
<strong>Puzzle:</strong> {{ 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>
<div v-if="file.ocrData.cost"> <div v-if="file.ocrData.cost">
<strong>Cost:</strong> {{ 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>
<div v-if="file.ocrData.cycles"> <div v-if="file.ocrData.cycles">
<strong>Cycles:</strong> {{ 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>
<div v-if="file.ocrData.area"> <div v-if="file.ocrData.area">
<strong>Area:</strong> {{ 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> </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 --> <!-- Manual OCR trigger for non-auto detected files -->
<div v-else-if="!file.ocrProcessing && !file.ocrError && !file.ocrData" class="mt-1"> <div v-else-if="!file.ocrProcessing && !file.ocrError && !file.ocrData" class="mt-1">
<button <button
@ -142,7 +207,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, nextTick } from 'vue' import { ref, watch, nextTick } from 'vue'
import { ocrService } from '../services/ocrService' import { ocrService } from '@/services/ocrService'
import { usePuzzlesStore } from '@/stores/puzzles'
import type { SubmissionFile, SteamCollectionItem } from '@/types' import type { SubmissionFile, SteamCollectionItem } from '@/types'
interface Props { interface Props {
@ -157,6 +223,9 @@ interface Emits {
const props = defineProps<Props>() const props = defineProps<Props>()
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
// Pinia store
const puzzlesStore = usePuzzlesStore()
const fileInput = ref<HTMLInputElement>() const fileInput = ref<HTMLInputElement>()
const isDragOver = ref(false) const isDragOver = ref(false)
const error = ref('') const error = ref('')
@ -173,10 +242,9 @@ watch(files, (newFiles) => {
}, { deep: true }) }, { deep: true })
// Watch for puzzle changes and update OCR service // Watch for puzzle changes and update OCR service
watch(() => props.puzzles, (newPuzzles) => { watch(() => puzzlesStore.puzzles, (newPuzzles) => {
if (newPuzzles && newPuzzles.length > 0) { if (newPuzzles && newPuzzles.length > 0) {
const puzzleNames = newPuzzles.map(puzzle => puzzle.title) ocrService.setAvailablePuzzleNames(puzzlesStore.puzzleNames)
ocrService.setAvailablePuzzleNames(puzzleNames)
} }
}, { immediate: true }) }, { immediate: true })
@ -294,6 +362,15 @@ const processOCR = async (submissionFile: SubmissionFile) => {
// Force reactivity update // Force reactivity update
await nextTick() await nextTick()
files.value[fileIndex].ocrData = ocrData files.value[fileIndex].ocrData = ocrData
// Check if puzzle confidence is below 80% and needs manual selection
if (ocrData.confidence.puzzle < 0.8) {
files.value[fileIndex].needsManualPuzzleSelection = true
console.log(`Low puzzle confidence (${Math.round(ocrData.confidence.puzzle * 100)}%) for ${submissionFile.file.name}, requiring manual selection`)
} else {
files.value[fileIndex].needsManualPuzzleSelection = false
}
await nextTick() await nextTick()
} catch (error) { } catch (error) {
console.error('OCR processing failed:', error) console.error('OCR processing failed:', error)
@ -306,4 +383,22 @@ const processOCR = async (submissionFile: SubmissionFile) => {
const retryOCR = (submissionFile: SubmissionFile) => { const retryOCR = (submissionFile: SubmissionFile) => {
processOCR(submissionFile) processOCR(submissionFile)
} }
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 = files.value.findIndex(f => f.file === submissionFile.file)
if (fileIndex === -1) return
// Clear the manual selection requirement once user has selected
if (files.value[fileIndex].manualPuzzleSelection) {
files.value[fileIndex].needsManualPuzzleSelection = false
console.log(`Manual puzzle selection: ${submissionFile.file.name} -> ${files.value[fileIndex].manualPuzzleSelection}`)
}
}
</script> </script>

View File

@ -15,7 +15,7 @@
<div class="text-sm space-y-1 mt-1"> <div class="text-sm space-y-1 mt-1">
<div v-for="(data, puzzleName) in responsesByPuzzle" :key="puzzleName" class="flex justify-between"> <div v-for="(data, puzzleName) in responsesByPuzzle" :key="puzzleName" class="flex justify-between">
<span>{{ puzzleName }}</span> <span>{{ puzzleName }}</span>
<span class="badge badge-ghost badge-sm">{{ data.files.length }} file(s)</span> <span class="badge badge-ghost badge-sm ml-2">{{ data.files.length }} file(s)</span>
</div> </div>
</div> </div>
</div> </div>
@ -23,19 +23,52 @@
<!-- File Upload --> <!-- File Upload -->
<FileUpload v-model="submissionFiles" :puzzles="puzzles" /> <FileUpload v-model="submissionFiles" :puzzles="puzzles" />
<!-- Manual Selection Warning -->
<div v-if="filesNeedingManualSelection.length > 0" class="alert alert-warning">
<i class="mdi mdi-alert-circle text-xl"></i>
<div class="flex-1">
<div class="font-bold">Manual Puzzle Selection Required</div>
<div class="text-sm">
{{ filesNeedingManualSelection.length }} file(s) have low OCR confidence for puzzle names.
Please select the correct puzzle for each file before submitting.
</div>
</div>
</div>
<!-- Notes --> <!-- Notes -->
<div class="form-control"> <div class="form-control">
<label class="label"> <div class="flex-1">
<span class="label-text font-medium">Notes (Optional)</span> <label class="flex label">
<span class="label-text-alt">{{ notesLength }}/500</span> <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"
/>
<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 50%.</em>
</div>
</div>
</label> </label>
<textarea
v-model="notes"
class="textarea textarea-bordered h-24 resize-none"
placeholder="Add any notes about your solution, approach, or interesting findings..."
maxlength="500"
></textarea>
</div> </div>
<!-- Submit Button --> <!-- Submit Button -->
@ -43,10 +76,14 @@
<button <button
type="submit" type="submit"
class="btn btn-primary" class="btn btn-primary"
:disabled="isSubmitting" :disabled="!canSubmit"
> >
<span v-if="isSubmitting" class="loading loading-spinner loading-sm"></span> <span v-if="isSubmitting" class="loading loading-spinner loading-sm"></span>
{{ isSubmitting ? 'Submitting...' : 'Submit Solution' }} <span v-if="isSubmitting">Submitting...</span>
<span v-else-if="filesNeedingManualSelection.length > 0">
Select Puzzles ({{ filesNeedingManualSelection.length }} remaining)
</span>
<span v-else>Submit Solution</span>
</button> </button>
</div> </div>
</form> </form>
@ -55,8 +92,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed, watch } from 'vue'
import FileUpload from './FileUpload.vue' import FileUpload from '@/components/FileUpload.vue'
import type { SteamCollectionItem, SubmissionFile } from '@/types' import type { SteamCollectionItem, SubmissionFile } from '@/types'
interface Props { interface Props {
@ -65,7 +102,7 @@ interface Props {
} }
interface Emits { interface Emits {
submit: [submissionData: { files: SubmissionFile[], notes?: string }] submit: [submissionData: { files: SubmissionFile[], notes?: string, manualValidationRequested?: boolean }]
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@ -73,13 +110,18 @@ const emit = defineEmits<Emits>()
const submissionFiles = ref<SubmissionFile[]>([]) const submissionFiles = ref<SubmissionFile[]>([])
const notes = ref('') const notes = ref('')
const manualValidationRequested = ref(false)
const isSubmitting = ref(false) const isSubmitting = ref(false)
const notesLength = computed(() => notes.value.length) const notesLength = computed(() => notes.value.length)
const canSubmit = computed(() => { const canSubmit = computed(() => {
return submissionFiles.value.length > 0 && const hasFiles = submissionFiles.value.length > 0
!isSubmitting.value const noManualSelectionNeeded = !submissionFiles.value.some(file => file.needsManualPuzzleSelection)
return hasFiles &&
!isSubmitting.value &&
noManualSelectionNeeded
}) })
// Group files by detected puzzle // Group files by detected puzzle
@ -87,8 +129,10 @@ const responsesByPuzzle = computed(() => {
const grouped: Record<string, { puzzle: SteamCollectionItem | null, files: SubmissionFile[] }> = {} const grouped: Record<string, { puzzle: SteamCollectionItem | null, files: SubmissionFile[] }> = {}
submissionFiles.value.forEach(file => { submissionFiles.value.forEach(file => {
if (file.ocrData?.puzzle) { // Use manual puzzle selection if available, otherwise fall back to OCR
const puzzleName = file.ocrData.puzzle const puzzleName = file.manualPuzzleSelection || file.ocrData?.puzzle
if (puzzleName) {
if (!grouped[puzzleName]) { if (!grouped[puzzleName]) {
grouped[puzzleName] = { grouped[puzzleName] = {
puzzle: props.findPuzzleByName(puzzleName), puzzle: props.findPuzzleByName(puzzleName),
@ -102,6 +146,28 @@ const responsesByPuzzle = computed(() => {
return grouped return grouped
}) })
// Count files that need manual puzzle selection
const filesNeedingManualSelection = computed(() => {
return submissionFiles.value.filter(file => file.needsManualPuzzleSelection)
})
// Check if any OCR confidence is below 50%
const hasLowConfidence = computed(() => {
return submissionFiles.value.some(file => {
if (!file.ocrData?.confidence) return false
return file.ocrData.confidence.cost < 0.5 ||
file.ocrData.confidence.cycles < 0.5 ||
file.ocrData.confidence.area < 0.5
})
})
// Auto-check manual validation when confidence is low
watch(hasLowConfidence, (newValue) => {
if (newValue && !manualValidationRequested.value) {
manualValidationRequested.value = true
}
}, { immediate: true })
const handleSubmit = async () => { const handleSubmit = async () => {
if (!canSubmit.value) return if (!canSubmit.value) return
@ -111,12 +177,14 @@ const handleSubmit = async () => {
// Emit the files and notes for the parent to handle API submission // Emit the files and notes for the parent to handle API submission
emit('submit', { emit('submit', {
files: submissionFiles.value, files: submissionFiles.value,
notes: notes.value.trim() || undefined notes: notes.value.trim() || undefined,
manualValidationRequested: manualValidationRequested.value
}) })
// Reset form // Reset form
submissionFiles.value = [] submissionFiles.value = []
notes.value = '' notes.value = ''
manualValidationRequested.value = false
} catch (error) { } catch (error) {
console.error('Submission error:', error) console.error('Submission error:', error)

View File

@ -1,5 +1,11 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import App from '@/App.vue' import App from '@/App.vue'
import './style.css' import { pinia } from '@/stores'
import '@/style.css'
createApp(App).mount('#app') // 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)

View File

@ -1,7 +1,7 @@
import type { import type {
SteamCollectionItem, SteamCollectionItem,
Submission, Submission,
PuzzleResponse, PuzzleResponse,
SubmissionFile, SubmissionFile,
UserInfo UserInfo
} from '../types' } from '../types'
@ -32,7 +32,7 @@ interface SubmissionStats {
// API Service Class // API Service Class
export class ApiService { export class ApiService {
private async request<T>( private async request<T>(
endpoint: string, endpoint: string,
options: RequestInit = {} options: RequestInit = {}
): Promise<ApiResponse<T>> { ): Promise<ApiResponse<T>> {
try { try {
@ -115,6 +115,7 @@ export class ApiService {
async createSubmission( async createSubmission(
submissionData: { submissionData: {
notes?: string notes?: string
manual_validation_requested?: boolean
responses: Array<{ responses: Array<{
puzzle_id: number puzzle_id: number
puzzle_name: string puzzle_name: string
@ -122,16 +123,18 @@ export class ApiService {
cycles?: string cycles?: string
area?: string area?: string
needs_manual_validation?: boolean needs_manual_validation?: boolean
ocr_confidence_score?: number ocr_confidence_cost?: number
ocr_confidence_cycles?: number
ocr_confidence_area?: number
}> }>
}, },
files: File[] files: File[]
): Promise<ApiResponse<Submission>> { ): Promise<ApiResponse<Submission>> {
const formData = new FormData() const formData = new FormData()
// Add JSON data // Add JSON data
formData.append('data', JSON.stringify(submissionData)) formData.append('data', JSON.stringify(submissionData))
// Add files // Add files
files.forEach((file) => { files.forEach((file) => {
formData.append('files', file) formData.append('files', file)
@ -203,20 +206,20 @@ export const puzzleHelpers = {
findPuzzleByName(puzzles: SteamCollectionItem[], name: string): SteamCollectionItem | null { findPuzzleByName(puzzles: SteamCollectionItem[], name: string): SteamCollectionItem | null {
if (!name) return null if (!name) return null
// Try exact match first // Try exact match first
let match = puzzles.find(p => let match = puzzles.find(p =>
p.title.toLowerCase() === name.toLowerCase() p.title.toLowerCase() === name.toLowerCase()
) )
if (!match) { if (!match) {
// Try partial match // Try partial match
match = puzzles.find(p => match = puzzles.find(p =>
p.title.toLowerCase().includes(name.toLowerCase()) || p.title.toLowerCase().includes(name.toLowerCase()) ||
name.toLowerCase().includes(p.title.toLowerCase()) name.toLowerCase().includes(p.title.toLowerCase())
) )
} }
return match || null return match || null
} }
} }
@ -225,17 +228,20 @@ export const submissionHelpers = {
async createFromFiles( async createFromFiles(
files: SubmissionFile[], files: SubmissionFile[],
puzzles: SteamCollectionItem[], puzzles: SteamCollectionItem[],
notes?: string notes?: string,
manualValidationRequested?: boolean
): Promise<ApiResponse<Submission>> { ): Promise<ApiResponse<Submission>> {
// Group files by detected puzzle // Group files by detected puzzle
const responsesByPuzzle: Record<string, { const responsesByPuzzle: Record<string, {
puzzle: SteamCollectionItem | null, puzzle: SteamCollectionItem | null,
files: SubmissionFile[] files: SubmissionFile[]
}> = {} }> = {}
files.forEach(file => { files.forEach(file => {
if (file.ocrData?.puzzle) { // Use manual puzzle selection if available, otherwise fall back to OCR
const puzzleName = file.ocrData.puzzle const puzzleName = file.manualPuzzleSelection || file.ocrData?.puzzle
if (puzzleName) {
if (!responsesByPuzzle[puzzleName]) { if (!responsesByPuzzle[puzzleName]) {
responsesByPuzzle[puzzleName] = { responsesByPuzzle[puzzleName] = {
puzzle: puzzleHelpers.findPuzzleByName(puzzles, puzzleName), puzzle: puzzleHelpers.findPuzzleByName(puzzles, puzzleName),
@ -251,14 +257,14 @@ export const submissionHelpers = {
.filter(([_, data]) => data.puzzle) // Only include matched puzzles .filter(([_, data]) => data.puzzle) // Only include matched puzzles
.map(([puzzleName, data]) => { .map(([puzzleName, data]) => {
// Get OCR data from the first file with complete data // Get OCR data from the first file with complete data
const fileWithOCR = data.files.find(f => const fileWithOCR = data.files.find(f =>
f.ocrData?.cost || f.ocrData?.cycles || f.ocrData?.area f.ocrData?.cost || f.ocrData?.cycles || f.ocrData?.area
) )
// Check if manual validation is needed // Check if manual validation is needed
const needsValidation = !fileWithOCR?.ocrData || const needsValidation = !fileWithOCR?.ocrData ||
!fileWithOCR.ocrData.cost || !fileWithOCR.ocrData.cost ||
!fileWithOCR.ocrData.cycles || !fileWithOCR.ocrData.cycles ||
!fileWithOCR.ocrData.area !fileWithOCR.ocrData.area
return { return {
@ -268,7 +274,9 @@ export const submissionHelpers = {
cycles: fileWithOCR?.ocrData?.cycles, cycles: fileWithOCR?.ocrData?.cycles,
area: fileWithOCR?.ocrData?.area, area: fileWithOCR?.ocrData?.area,
needs_manual_validation: needsValidation, needs_manual_validation: needsValidation,
ocr_confidence_score: needsValidation ? 0.5 : 0.9 // Rough estimate ocr_confidence_cost: fileWithOCR?.ocrData?.confidence?.cost || 0.0,
ocr_confidence_cycles: fileWithOCR?.ocrData?.confidence?.cycles || 0.0,
ocr_confidence_area: fileWithOCR?.ocrData?.confidence?.area || 0.0
} }
}) })
@ -282,7 +290,11 @@ export const submissionHelpers = {
// Extract actual File objects for upload // Extract actual File objects for upload
const fileObjects = files.map(f => f.file) const fileObjects = files.map(f => f.file)
return apiService.createSubmission({ notes, responses }, fileObjects) return apiService.createSubmission({
notes,
manual_validation_requested: manualValidationRequested,
responses
}, fileObjects)
}, },
async loadSubmissions(limit = 20, offset = 0): Promise<Submission[]> { async loadSubmissions(limit = 20, offset = 0): Promise<Submission[]> {

View File

@ -5,6 +5,13 @@ export interface OpusMagnumData {
cost: string; cost: string;
cycles: string; cycles: string;
area: string; area: string;
confidence: {
puzzle: number;
cost: number;
cycles: number;
area: number;
overall: number;
};
} }
export interface OCRRegion { export interface OCRRegion {
@ -41,6 +48,68 @@ export class OpusMagnumOCRService {
*/ */
setAvailablePuzzleNames(puzzleNames: string[]): void { setAvailablePuzzleNames(puzzleNames: string[]): void {
this.availablePuzzleNames = puzzleNames; 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> { async extractOpusMagnumData(imageFile: File): Promise<OpusMagnumData> {
@ -64,6 +133,7 @@ export class OpusMagnumOCRService {
// Extract text from each region // Extract text from each region
const results: Partial<OpusMagnumData> = {}; const results: Partial<OpusMagnumData> = {};
const confidenceScores: Record<string, number> = {};
for (const [key, region] of Object.entries(this.regions)) { for (const [key, region] of Object.entries(this.regions)) {
const regionCanvas = document.createElement('canvas'); const regionCanvas = document.createElement('canvas');
@ -96,10 +166,8 @@ export class OpusMagnumOCRService {
tessedit_char_whitelist: '0123456789' tessedit_char_whitelist: '0123456789'
}); });
} else if (key === 'puzzle') { } else if (key === 'puzzle') {
// Puzzle name - allow alphanumeric, spaces, and dashes // Puzzle name - use user words file for better matching
await this.worker!.setParameters({ await this.configurePuzzleOCR();
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 -'
});
} else { } else {
// Default - allow all characters // Default - allow all characters
await this.worker!.setParameters({ await this.worker!.setParameters({
@ -108,8 +176,11 @@ export class OpusMagnumOCRService {
} }
// Perform OCR on the region // Perform OCR on the region
const { data: { text } } = await this.worker!.recognize(regionCanvas); const { data: { text, confidence } } = await this.worker!.recognize(regionCanvas);
let cleanText = text.trim(); 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 // Post-process based on field type
if (key === 'cost') { if (key === 'cost') {
@ -130,20 +201,42 @@ export class OpusMagnumOCRService {
// Ensure only digits remain // Ensure only digits remain
cleanText = cleanText.replace(/[^0-9]/g, ''); cleanText = cleanText.replace(/[^0-9]/g, '');
} else if (key === 'puzzle') { } else if (key === 'puzzle') {
// Post-process puzzle names with fuzzy matching // Post-process puzzle names with aggressive matching to force selection from available puzzles
cleanText = this.findBestPuzzleMatch(cleanText); 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[key as keyof OpusMagnumData] = cleanText; (results as any)[key] = cleanText;
} }
URL.revokeObjectURL(imageUrl); 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({ resolve({
puzzle: results.puzzle || '', puzzle: results.puzzle || '',
cost: results.cost || '', cost: results.cost || '',
cycles: results.cycles || '', cycles: results.cycles || '',
area: results.area || '' area: results.area || '',
confidence: {
puzzle: confidenceScores.puzzle || 0,
cost: confidenceScores.cost || 0,
cycles: confidenceScores.cycles || 0,
area: confidenceScores.area || 0,
overall: overallConfidence
}
}); });
} catch (error) { } catch (error) {
URL.revokeObjectURL(imageUrl); URL.revokeObjectURL(imageUrl);
@ -202,7 +295,7 @@ export class OpusMagnumOCRService {
} }
/** /**
* Find the best matching puzzle name from available options * Find the best matching puzzle name from available options using multiple strategies
*/ */
private findBestPuzzleMatch(ocrText: string): string { private findBestPuzzleMatch(ocrText: string): string {
if (!this.availablePuzzleNames.length) { if (!this.availablePuzzleNames.length) {
@ -210,31 +303,155 @@ export class OpusMagnumOCRService {
} }
const cleanedOcr = ocrText.trim(); const cleanedOcr = ocrText.trim();
if (!cleanedOcr) return '';
// First try exact match (case insensitive) // Strategy 1: Exact match (case insensitive)
const exactMatch = this.availablePuzzleNames.find( const exactMatch = this.availablePuzzleNames.find(
name => name.toLowerCase() === cleanedOcr.toLowerCase() name => name.toLowerCase() === cleanedOcr.toLowerCase()
); );
if (exactMatch) return exactMatch; if (exactMatch) return exactMatch;
// Then try fuzzy matching // 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 bestMatch = cleanedOcr;
let bestScore = Infinity; let bestScore = 0;
for (const puzzleName of this.availablePuzzleNames) { for (const puzzleName of this.availablePuzzleNames) {
// Calculate similarity scores const scores = [
const distance = this.levenshteinDistance( this.calculateLevenshteinSimilarity(cleanedOcr, puzzleName),
cleanedOcr.toLowerCase(), this.calculateJaroWinklerSimilarity(cleanedOcr, puzzleName),
puzzleName.toLowerCase() this.calculateNGramSimilarity(cleanedOcr, puzzleName, 2)
); ];
// Normalize by length to get a similarity ratio // Use the maximum score from all algorithms
const maxLength = Math.max(cleanedOcr.length, puzzleName.length); const maxScore = Math.max(...scores);
const similarity = 1 - (distance / maxLength);
// Consider it a good match if similarity is above 70% // Lower threshold for better matching - force selection even with moderate confidence
if (similarity > 0.7 && distance < bestScore) { if (maxScore > bestScore && maxScore > 0.4) {
bestScore = distance; 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; bestMatch = puzzleName;
} }
} }
@ -242,6 +459,90 @@ export class OpusMagnumOCRService {
return bestMatch; 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> { async terminate(): Promise<void> {
if (this.worker) { if (this.worker) {

View File

@ -0,0 +1,3 @@
import { createPinia } from 'pinia'
export const pinia = createPinia()

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
}
})

View File

@ -0,0 +1,100 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Submission, SubmissionFile } from '@/types'
import { submissionHelpers } from '@/services/apiService'
import { usePuzzlesStore } from '@/stores/puzzles'
export const useSubmissionsStore = defineStore('submissions', () => {
// State
const submissions = ref<Submission[]>([])
const isLoading = ref(false)
const error = ref<string>('')
const isSubmissionModalOpen = ref(false)
// 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 puzzlesStore = usePuzzlesStore()
const response = await submissionHelpers.createFromFiles(
files,
puzzlesStore.puzzles,
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()
}
return {
// State
submissions,
isLoading,
error,
isSubmissionModalOpen,
// Actions
loadSubmissions,
createSubmission,
openSubmissionModal,
closeSubmissionModal,
refreshSubmissions
}
})

View File

@ -29,6 +29,13 @@ export interface OpusMagnumData {
cost: string cost: string
cycles: string cycles: string
area: string area: string
confidence: {
puzzle: number
cost: number
cycles: number
area: number
overall: number
}
} }
export interface SubmissionFile { export interface SubmissionFile {
@ -39,6 +46,8 @@ export interface SubmissionFile {
ocrProcessing?: boolean ocrProcessing?: boolean
ocrError?: string ocrError?: string
original_filename?: string original_filename?: string
manualPuzzleSelection?: string
needsManualPuzzleSelection?: boolean
} }
export interface PuzzleResponse { export interface PuzzleResponse {
@ -49,7 +58,9 @@ export interface PuzzleResponse {
cycles?: string cycles?: string
area?: string area?: string
needs_manual_validation?: boolean needs_manual_validation?: boolean
ocr_confidence_score?: number ocr_confidence_cost?: number
ocr_confidence_cycles?: number
ocr_confidence_area?: number
validated_cost?: string validated_cost?: string
validated_cycles?: string validated_cycles?: string
validated_area?: string validated_area?: string
@ -69,6 +80,7 @@ export interface Submission {
is_validated?: boolean is_validated?: boolean
validated_by?: number | null validated_by?: number | null
validated_at?: string | null validated_at?: string | null
manual_validation_requested?: boolean
total_responses?: number total_responses?: number
needs_validation?: boolean needs_validation?: boolean
created_at?: string created_at?: string

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@ -2,8 +2,12 @@ from django.contrib import admin
from django.utils.html import format_html from django.utils.html import format_html
from django.utils import timezone from django.utils import timezone
from .models import ( from .models import (
SteamAPIKey, SteamCollection, SteamCollectionItem, SteamAPIKey,
Submission, PuzzleResponse, SubmissionFile SteamCollection,
SteamCollectionItem,
Submission,
PuzzleResponse,
SubmissionFile,
) )
@ -151,7 +155,14 @@ class SubmissionFileInline(admin.TabularInline):
model = SubmissionFile model = SubmissionFile
extra = 0 extra = 0
readonly_fields = ["file_size", "content_type", "ocr_processed", "created_at"] readonly_fields = ["file_size", "content_type", "ocr_processed", "created_at"]
fields = ["file", "original_filename", "file_size", "content_type", "ocr_processed", "ocr_error"] fields = [
"file",
"original_filename",
"file_size",
"content_type",
"ocr_processed",
"ocr_error",
]
class PuzzleResponseInline(admin.TabularInline): class PuzzleResponseInline(admin.TabularInline):
@ -159,43 +170,73 @@ class PuzzleResponseInline(admin.TabularInline):
extra = 0 extra = 0
readonly_fields = ["created_at", "updated_at"] readonly_fields = ["created_at", "updated_at"]
fields = [ fields = [
"puzzle", "puzzle_name", "cost", "cycles", "area", "puzzle",
"needs_manual_validation", "ocr_confidence_score" "puzzle_name",
"cost",
"cycles",
"area",
"needs_manual_validation",
"ocr_confidence_cost",
"ocr_confidence_cycles",
"ocr_confidence_area",
] ]
@admin.register(Submission) @admin.register(Submission)
class SubmissionAdmin(admin.ModelAdmin): class SubmissionAdmin(admin.ModelAdmin):
list_display = [ list_display = [
"id", "user", "total_responses", "needs_validation", "id",
"is_validated", "created_at" "user",
"total_responses",
"needs_validation",
"manual_validation_requested",
"is_validated",
"created_at",
] ]
list_filter = [ list_filter = [
"is_validated", "created_at", "updated_at" "is_validated",
"manual_validation_requested",
"created_at",
"updated_at",
] ]
search_fields = ["id", "user__username", "notes"] search_fields = ["id", "user__username", "notes"]
readonly_fields = ["id", "created_at", "updated_at", "total_responses", "needs_validation"] readonly_fields = [
"id",
"created_at",
"updated_at",
"total_responses",
"needs_validation",
]
inlines = [PuzzleResponseInline] inlines = [PuzzleResponseInline]
fieldsets = ( fieldsets = (
("Basic Information", { ("Basic Information", {"fields": ("id", "user", "notes")}),
"fields": ("id", "user", "notes") (
}), "Validation",
("Validation", { {
"fields": ("is_validated", "validated_by", "validated_at") "fields": (
}), "manual_validation_requested",
("Statistics", { "is_validated",
"fields": ("total_responses", "needs_validation"), "validated_by",
"classes": ("collapse",) "validated_at",
}), )
("Timestamps", { },
"fields": ("created_at", "updated_at"), ),
"classes": ("collapse",) (
}), "Statistics",
{
"fields": ("total_responses", "needs_validation"),
"classes": ("collapse",),
},
),
(
"Timestamps",
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
),
) )
actions = ["mark_as_validated"] actions = ["mark_as_validated"]
def mark_as_validated(self, request, queryset): def mark_as_validated(self, request, queryset):
"""Mark selected submissions as validated""" """Mark selected submissions as validated"""
updated = 0 updated = 0
@ -208,59 +249,82 @@ class SubmissionAdmin(admin.ModelAdmin):
# Also mark all responses as not needing validation # Also mark all responses as not needing validation
submission.responses.update(needs_manual_validation=False) submission.responses.update(needs_manual_validation=False)
updated += 1 updated += 1
self.message_user(request, f"{updated} submissions marked as validated.") self.message_user(request, f"{updated} submissions marked as validated.")
mark_as_validated.short_description = "Mark selected submissions as validated" mark_as_validated.short_description = "Mark selected submissions as validated"
@admin.register(PuzzleResponse) @admin.register(PuzzleResponse)
class PuzzleResponseAdmin(admin.ModelAdmin): class PuzzleResponseAdmin(admin.ModelAdmin):
list_display = [ list_display = [
"puzzle_name", "submission", "puzzle", "cost", "cycles", "area", "puzzle_name",
"needs_manual_validation", "created_at" "submission",
] "puzzle",
list_filter = [ "cost",
"needs_manual_validation", "puzzle__collection", "created_at" "cycles",
"area",
"needs_manual_validation",
"created_at",
] ]
list_filter = ["needs_manual_validation", "puzzle__collection", "created_at"]
search_fields = [ search_fields = [
"puzzle_name", "submission__id", "puzzle__title", "puzzle_name",
"cost", "cycles", "area" "submission__id",
"puzzle__title",
"cost",
"cycles",
"area",
] ]
readonly_fields = ["created_at", "updated_at"] readonly_fields = ["created_at", "updated_at"]
inlines = [SubmissionFileInline] inlines = [SubmissionFileInline]
fieldsets = ( fieldsets = (
("Basic Information", { ("Basic Information", {"fields": ("submission", "puzzle", "puzzle_name")}),
"fields": ("submission", "puzzle", "puzzle_name") (
}), "OCR Data",
("OCR Data", { {
"fields": ("cost", "cycles", "area", "ocr_confidence_score") "fields": (
}), "cost",
("Validation", { "cycles",
"fields": ( "area",
"needs_manual_validation", "ocr_confidence_cost",
"validated_cost", "validated_cycles", "validated_area" "ocr_confidence_cycles",
) "ocr_confidence_area",
}), )
("Timestamps", { },
"fields": ("created_at", "updated_at"), ),
"classes": ("collapse",) (
}), "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"] actions = ["mark_for_validation", "clear_validation_flag"]
def mark_for_validation(self, request, queryset): def mark_for_validation(self, request, queryset):
"""Mark selected responses as needing validation""" """Mark selected responses as needing validation"""
updated = queryset.update(needs_manual_validation=True) updated = queryset.update(needs_manual_validation=True)
self.message_user(request, f"{updated} responses marked for validation.") self.message_user(request, f"{updated} responses marked for validation.")
def clear_validation_flag(self, request, queryset): def clear_validation_flag(self, request, queryset):
"""Clear validation flag for selected responses""" """Clear validation flag for selected responses"""
updated = queryset.update(needs_manual_validation=False) updated = queryset.update(needs_manual_validation=False)
self.message_user(request, f"{updated} responses cleared from validation queue.") self.message_user(
request, f"{updated} responses cleared from validation queue."
)
mark_for_validation.short_description = "Mark as needing validation" mark_for_validation.short_description = "Mark as needing validation"
clear_validation_flag.short_description = "Clear validation flag" clear_validation_flag.short_description = "Clear validation flag"
@ -268,37 +332,49 @@ class PuzzleResponseAdmin(admin.ModelAdmin):
@admin.register(SubmissionFile) @admin.register(SubmissionFile)
class SubmissionFileAdmin(admin.ModelAdmin): class SubmissionFileAdmin(admin.ModelAdmin):
list_display = [ list_display = [
"original_filename", "response", "file_size_display", "original_filename",
"content_type", "ocr_processed", "created_at" "response",
] "file_size_display",
list_filter = [ "content_type",
"content_type", "ocr_processed", "created_at" "ocr_processed",
"created_at",
] ]
list_filter = ["content_type", "ocr_processed", "created_at"]
search_fields = [ search_fields = [
"original_filename", "response__puzzle_name", "original_filename",
"response__submission__id" "response__puzzle_name",
"response__submission__id",
] ]
readonly_fields = [ readonly_fields = [
"file_size", "content_type", "ocr_processed", "file_size",
"created_at", "updated_at", "file_url" "content_type",
"ocr_processed",
"created_at",
"updated_at",
"file_url",
] ]
fieldsets = ( fieldsets = (
("File Information", { (
"fields": ("file", "original_filename", "file_size", "content_type", "file_url") "File Information",
}), {
("OCR Processing", { "fields": (
"fields": ("ocr_processed", "ocr_raw_data", "ocr_error") "file",
}), "original_filename",
("Relationships", { "file_size",
"fields": ("response",) "content_type",
}), "file_url",
("Timestamps", { )
"fields": ("created_at", "updated_at"), },
"classes": ("collapse",) ),
}), ("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): def file_size_display(self, obj):
"""Display file size in human readable format""" """Display file size in human readable format"""
if obj.file_size < 1024: if obj.file_size < 1024:
@ -307,5 +383,5 @@ class SubmissionFileAdmin(admin.ModelAdmin):
return f"{obj.file_size / 1024:.1f} KB" return f"{obj.file_size / 1024:.1f} KB"
else: else:
return f"{obj.file_size / (1024 * 1024):.1f} MB" return f"{obj.file_size / (1024 * 1024):.1f} MB"
file_size_display.short_description = "File Size" file_size_display.short_description = "File Size"

View File

@ -23,16 +23,20 @@ router = Router()
@router.get("/puzzles", response=List[SteamCollectionItemOut]) @router.get("/puzzles", response=List[SteamCollectionItemOut])
def list_puzzles(request): def list_puzzles(request):
"""Get list of available puzzles""" """Get list of available puzzles"""
return SteamCollectionItem.objects.select_related("collection").all() return SteamCollectionItem.objects.select_related("collection").filter(
collection__is_active=True
)
@router.get("/submissions", response=List[SubmissionOut]) @router.get("/submissions", response=List[SubmissionOut])
@paginate @paginate
def list_submissions(request): def list_submissions(request):
"""Get paginated list of submissions""" """Get paginated list of submissions"""
return Submission.objects.prefetch_related( return (
"responses__files", "responses__puzzle" Submission.objects.prefetch_related("responses__files", "responses__puzzle")
).filter(user=request.user) .filter(user=request.user)
.filter()
)
@router.get("/submissions/{submission_id}", response=SubmissionOut) @router.get("/submissions/{submission_id}", response=SubmissionOut)
@ -65,10 +69,29 @@ def create_submission(
try: try:
with transaction.atomic(): with transaction.atomic():
# Check if any confidence score is below 50% to auto-request validation
auto_request_validation = any(
(
response_data.ocr_confidence_cost is not None
and response_data.ocr_confidence_cost < 0.5
)
or (
response_data.ocr_confidence_cycles is not None
and response_data.ocr_confidence_cycles < 0.5
)
or (
response_data.ocr_confidence_area is not None
and response_data.ocr_confidence_area < 0.5
)
for response_data in data.responses
)
# Create the submission # Create the submission
submission = Submission.objects.create( submission = Submission.objects.create(
user=request.user if request.user.is_authenticated else None, user=request.user if request.user.is_authenticated else None,
notes=data.notes, notes=data.notes,
manual_validation_requested=data.manual_validation_requested
or auto_request_validation,
) )
file_index = 0 file_index = 0
@ -89,8 +112,10 @@ def create_submission(
cost=response_data.cost, cost=response_data.cost,
cycles=response_data.cycles, cycles=response_data.cycles,
area=response_data.area, area=response_data.area,
needs_manual_validation=response_data.needs_manual_validation, needs_manual_validation=data.manual_validation_requested,
ocr_confidence_score=response_data.ocr_confidence_score, ocr_confidence_cost=response_data.ocr_confidence_cost,
ocr_confidence_cycles=response_data.ocr_confidence_cycles,
ocr_confidence_area=response_data.ocr_confidence_area,
) )
# Process files for this response # Process files for this response
@ -149,27 +174,29 @@ def validate_response(request, response_id: int, data: ValidationIn):
if not request.user.is_authenticated or not request.user.is_staff: if not request.user.is_authenticated or not request.user.is_staff:
return 403, {"detail": "Admin access required"} return 403, {"detail": "Admin access required"}
try: response = get_object_or_404(PuzzleResponse, id=response_id)
response = PuzzleResponse.objects.select_related("puzzle").get(id=response_id)
# Update validated values if data.puzzle is not None:
if data.validated_cost is not None: puzzle = get_object_or_404(SteamCollectionItem, id=data.puzzle)
response.validated_cost = data.validated_cost response.puzzle = puzzle
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 # Update validated values
if all([response.final_cost, response.final_cycles, response.final_area]): if data.validated_cost is not None:
response.needs_manual_validation = False response.validated_cost = data.validated_cost
response.save() if data.validated_cycles is not None:
response.validated_cycles = data.validated_cycles
return response if data.validated_area is not None:
response.validated_area = data.validated_area
except PuzzleResponse.DoesNotExist: # Mark as no longer needing validation if we have all values
raise Http404("Response not found") if all([response.final_cost, response.final_cycles, response.final_area]):
response.needs_manual_validation = False
response.save()
return response
@router.get("/responses/needs-validation", response=List[PuzzleResponseOut]) @router.get("/responses/needs-validation", response=List[PuzzleResponseOut])
@ -181,6 +208,7 @@ def list_responses_needing_validation(request):
return ( return (
PuzzleResponse.objects.filter(needs_manual_validation=True) PuzzleResponse.objects.filter(needs_manual_validation=True)
.filter(puzzle__collection__is_active=True)
.select_related("puzzle", "submission") .select_related("puzzle", "submission")
.prefetch_related("files") .prefetch_related("files")
) )
@ -193,26 +221,22 @@ def validate_submission(request, submission_id: str):
if not request.user.is_authenticated or not request.user.is_staff: if not request.user.is_authenticated or not request.user.is_staff:
return 403, {"detail": "Admin access required"} return 403, {"detail": "Admin access required"}
try: submission = get_object_or_404(Submission, id=submission_id)
submission = Submission.objects.get(id=submission_id)
submission.is_validated = True submission.is_validated = True
submission.validated_by = request.user submission.validated_by = request.user
submission.validated_at = timezone.now() submission.validated_at = timezone.now()
submission.save() submission.save()
# Also mark all responses as not needing validation # Also mark all responses as not needing validation
submission.responses.update(needs_manual_validation=False) submission.responses.update(needs_manual_validation=False)
# Reload with relations # Reload with relations
submission = Submission.objects.prefetch_related( submission = Submission.objects.prefetch_related(
"responses__files", "responses__puzzle" "responses__files", "responses__puzzle"
).get(id=submission.id) ).get(id=submission.id)
return submission return submission
except Submission.DoesNotExist:
raise Http404("Submission not found")
@router.delete("/submissions/{submission_id}") @router.delete("/submissions/{submission_id}")
@ -222,13 +246,9 @@ def delete_submission(request, submission_id: str):
if not request.user.is_authenticated or not request.user.is_staff: if not request.user.is_authenticated or not request.user.is_staff:
return 403, {"detail": "Admin access required"} return 403, {"detail": "Admin access required"}
try: submission = get_object_or_404(Submission, id=submission_id)
submission = Submission.objects.get(id=submission_id) submission.delete()
submission.delete() return {"detail": "Submission deleted successfully"}
return {"detail": "Submission deleted successfully"}
except Submission.DoesNotExist:
raise Http404("Submission not found")
@router.get("/stats") @router.get("/stats")
@ -247,7 +267,7 @@ def get_stats(request):
"total_responses": total_responses, "total_responses": total_responses,
"needs_validation": needs_validation, "needs_validation": needs_validation,
"validated_submissions": validated_submissions, "validated_submissions": validated_submissions,
"validation_rate": validated_submissions / total_submissions "validation_rate": (total_responses - needs_validation) / total_responses
if total_submissions > 0 if total_responses
else 0, else 0,
} }

View File

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

View File

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

View File

@ -5,68 +5,215 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = []
]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Collection', name="Collection",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('url', models.URLField()), "id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("url", models.URLField()),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='SteamCollection', name="SteamCollection",
fields=[ 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)), "id",
('url', models.URLField(help_text='Full Steam Workshop collection URL')), models.BigAutoField(
('title', models.CharField(blank=True, help_text='Collection title', max_length=255)), auto_created=True,
('description', models.TextField(blank=True, help_text='Collection description')), primary_key=True,
('author_name', models.CharField(blank=True, help_text='Steam username of collection creator', max_length=100)), serialize=False,
('author_steam_id', models.CharField(blank=True, help_text='Steam ID of collection creator', max_length=50)), verbose_name="ID",
('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_id",
('steam_created_date', models.DateTimeField(blank=True, help_text='When collection was created on Steam', null=True)), models.CharField(
('steam_updated_date', models.DateTimeField(blank=True, help_text='When collection was last updated on Steam', null=True)), help_text="Steam collection ID from URL",
('created_at', models.DateTimeField(auto_now_add=True)), max_length=50,
('updated_at', models.DateTimeField(auto_now=True)), unique=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')), (
"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={ options={
'verbose_name': 'Steam Collection', "verbose_name": "Steam Collection",
'verbose_name_plural': 'Steam Collections', "verbose_name_plural": "Steam Collections",
'ordering': ['-created_at'], "ordering": ["-created_at"],
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='SteamCollectionItem', name="SteamCollectionItem",
fields=[ 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)), "id",
('title', models.CharField(blank=True, help_text='Item title', max_length=255)), models.BigAutoField(
('author_name', models.CharField(blank=True, help_text='Steam username of item creator', max_length=100)), auto_created=True,
('author_steam_id', models.CharField(blank=True, help_text='Steam ID of item creator', max_length=50)), primary_key=True,
('description', models.TextField(blank=True, help_text='Item description')), serialize=False,
('tags', models.JSONField(blank=True, default=list, help_text='Item tags as JSON array')), verbose_name="ID",
('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')), "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={ options={
'verbose_name': 'Steam Collection Item', "verbose_name": "Steam Collection Item",
'verbose_name_plural': 'Steam Collection Items', "verbose_name_plural": "Steam Collection Items",
'ordering': ['collection', 'order_index'], "ordering": ["collection", "order_index"],
'unique_together': {('collection', 'steam_item_id')}, "unique_together": {("collection", "steam_item_id")},
}, },
), ),
] ]

View File

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

View File

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

View File

@ -8,75 +8,245 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('submissions', '0003_steamapikey'), ("submissions", "0003_steamapikey"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Submission', name="Submission",
fields=[ 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')), "id",
('is_validated', models.BooleanField(default=False, help_text='Whether this submission has been manually validated')), models.UUIDField(
('validated_at', models.DateTimeField(blank=True, help_text='When this submission was validated', null=True)), default=uuid.uuid4,
('created_at', models.DateTimeField(auto_now_add=True)), editable=False,
('updated_at', models.DateTimeField(auto_now=True)), primary_key=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)), serialize=False,
('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)), ),
),
(
"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={ options={
'verbose_name': 'Submission', "verbose_name": "Submission",
'verbose_name_plural': 'Submissions', "verbose_name_plural": "Submissions",
'ordering': ['-created_at'], "ordering": ["-created_at"],
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='PuzzleResponse', name="PuzzleResponse",
fields=[ 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)), "id",
('cost', models.CharField(blank=True, help_text='Cost value from OCR', max_length=20)), models.BigAutoField(
('cycles', models.CharField(blank=True, help_text='Cycles value from OCR', max_length=20)), auto_created=True,
('area', models.CharField(blank=True, help_text='Area value from OCR', max_length=20)), primary_key=True,
('needs_manual_validation', models.BooleanField(default=False, help_text='Whether OCR failed and manual validation is needed')), serialize=False,
('ocr_confidence_score', models.FloatField(blank=True, help_text='OCR confidence score (0.0 to 1.0)', null=True)), verbose_name="ID",
('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)), "puzzle_name",
('updated_at', models.DateTimeField(auto_now=True)), models.CharField(
('puzzle', models.ForeignKey(help_text='The puzzle this response is for', on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='submissions.steamcollectionitem')), help_text="Puzzle name as detected by OCR", max_length=255
('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='submissions.submission')), ),
),
(
"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={ options={
'verbose_name': 'Puzzle Response', "verbose_name": "Puzzle Response",
'verbose_name_plural': 'Puzzle Responses', "verbose_name_plural": "Puzzle Responses",
'ordering': ['submission', 'puzzle__order_index'], "ordering": ["submission", "puzzle__order_index"],
'unique_together': {('submission', 'puzzle')}, "unique_together": {("submission", "puzzle")},
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='SubmissionFile', name="SubmissionFile",
fields=[ 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)), "id",
('original_filename', models.CharField(help_text='Original filename as uploaded by user', max_length=255)), models.BigAutoField(
('file_size', models.PositiveIntegerField(help_text='File size in bytes')), auto_created=True,
('content_type', models.CharField(help_text='MIME type of the file', max_length=100)), primary_key=True,
('ocr_processed', models.BooleanField(default=False, help_text='Whether OCR has been processed for this file')), serialize=False,
('ocr_raw_data', models.JSONField(blank=True, help_text='Raw OCR data as JSON', null=True)), verbose_name="ID",
('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')), "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={ options={
'verbose_name': 'Submission File', "verbose_name": "Submission File",
'verbose_name_plural': 'Submission Files', "verbose_name_plural": "Submission Files",
'ordering': ['response', 'created_at'], "ordering": ["response", "created_at"],
}, },
), ),
] ]

View File

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

View File

@ -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,
),
),
]

View File

@ -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",
),
),
]

View File

@ -0,0 +1,17 @@
# 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(),
),
]

View File

@ -1,9 +1,7 @@
from django.db import models from django.db import models
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.utils import timezone
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
import uuid import uuid
import os
User = get_user_model() User = get_user_model()
@ -243,6 +241,12 @@ class Submission(models.Model):
null=True, blank=True, help_text="When this submission was validated" 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 # Timestamps
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@ -295,8 +299,15 @@ class PuzzleResponse(models.Model):
needs_manual_validation = models.BooleanField( needs_manual_validation = models.BooleanField(
default=False, help_text="Whether OCR failed and manual validation is needed" default=False, help_text="Whether OCR failed and manual validation is needed"
) )
ocr_confidence_score = models.FloatField(
null=True, blank=True, help_text="OCR confidence score (0.0 to 1.0)" 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 # Manual validation overrides
@ -316,7 +327,6 @@ class PuzzleResponse(models.Model):
class Meta: class Meta:
ordering = ["submission", "puzzle__order_index"] ordering = ["submission", "puzzle__order_index"]
unique_together = ["submission", "puzzle"]
verbose_name = "Puzzle Response" verbose_name = "Puzzle Response"
verbose_name_plural = "Puzzle Responses" verbose_name_plural = "Puzzle Responses"

View File

@ -1,5 +1,4 @@
from ninja import Schema, ModelSchema, File from ninja import Schema, ModelSchema
from ninja.files import UploadedFile
from typing import List, Optional from typing import List, Optional
from datetime import datetime from datetime import datetime
from uuid import UUID from uuid import UUID
@ -25,13 +24,16 @@ class PuzzleResponseIn(Schema):
cycles: Optional[str] = None cycles: Optional[str] = None
area: Optional[str] = None area: Optional[str] = None
needs_manual_validation: bool = False needs_manual_validation: bool = False
ocr_confidence_score: Optional[float] = None ocr_confidence_cost: Optional[float] = None
ocr_confidence_cycles: Optional[float] = None
ocr_confidence_area: Optional[float] = None
class SubmissionIn(Schema): class SubmissionIn(Schema):
"""Schema for creating a submission""" """Schema for creating a submission"""
notes: Optional[str] = None notes: Optional[str] = None
manual_validation_requested: bool = False
responses: List[PuzzleResponseIn] responses: List[PuzzleResponseIn]
@ -73,7 +75,9 @@ class PuzzleResponseOut(ModelSchema):
"cycles", "cycles",
"area", "area",
"needs_manual_validation", "needs_manual_validation",
"ocr_confidence_score", "ocr_confidence_cost",
"ocr_confidence_cycles",
"ocr_confidence_area",
"validated_cost", "validated_cost",
"validated_cycles", "validated_cycles",
"validated_area", "validated_area",
@ -98,6 +102,7 @@ class SubmissionOut(ModelSchema):
"is_validated", "is_validated",
"validated_by", "validated_by",
"validated_at", "validated_at",
"manual_validation_requested",
"created_at", "created_at",
"updated_at", "updated_at",
] ]
@ -120,6 +125,7 @@ class SubmissionListOut(Schema):
class ValidationIn(Schema): class ValidationIn(Schema):
"""Schema for manual validation input""" """Schema for manual validation input"""
puzzle: Optional[int] = None
validated_cost: Optional[str] = None validated_cost: Optional[str] = None
validated_cycles: Optional[str] = None validated_cycles: Optional[str] = None
validated_area: Optional[str] = None validated_area: Optional[str] = None

View File

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

View File

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

View File

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

View File

@ -10,6 +10,6 @@
{% vite_asset 'src/main.ts' %} {% vite_asset 'src/main.ts' %}
</head> </head>
<body> <body>
<div id="app"></div> <div id="app" data-collection-title="{{ collection.title }}" data-collection-url="{{ collection.url }}" data-collection-description="{{ collection.description }}"></div>
</body> </body>
</html> </html>

View File

@ -1 +1 @@
{"root":["./src/main.ts","./src/services/apiService.ts","./src/services/ocrService.ts","./src/types/index.ts","./src/App.vue","./src/components/AdminPanel.vue","./src/components/FileUpload.vue","./src/components/PuzzleCard.vue","./src/components/SubmissionForm.vue"],"version":"5.9.3"} {"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/types/index.ts","./src/App.vue","./src/components/AdminPanel.vue","./src/components/FileUpload.vue","./src/components/PuzzleCard.vue","./src/components/SubmissionForm.vue"],"version":"5.9.3"}

View File

@ -23,6 +23,7 @@ dev = [
"django-types>=0.22.0", "django-types>=0.22.0",
"ipython>=8.37.0", "ipython>=8.37.0",
"pre-commit>=4.3.0", "pre-commit>=4.3.0",
"pyjwt>=2.10.1",
"pyright>=1.1.407", "pyright>=1.1.407",
"ruff>=0.14.2", "ruff>=0.14.2",
] ]

11
uv.lock
View File

@ -426,6 +426,7 @@ dev = [
{ name = "ipython", version = "8.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "ipython", version = "8.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "ipython", version = "9.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "ipython", version = "9.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "pre-commit" }, { name = "pre-commit" },
{ name = "pyjwt" },
{ name = "pyright" }, { name = "pyright" },
{ name = "ruff" }, { name = "ruff" },
] ]
@ -447,6 +448,7 @@ dev = [
{ name = "django-types", specifier = ">=0.22.0" }, { name = "django-types", specifier = ">=0.22.0" },
{ name = "ipython", specifier = ">=8.37.0" }, { name = "ipython", specifier = ">=8.37.0" },
{ name = "pre-commit", specifier = ">=4.3.0" }, { name = "pre-commit", specifier = ">=4.3.0" },
{ name = "pyjwt", specifier = ">=2.10.1" },
{ name = "pyright", specifier = ">=1.1.407" }, { name = "pyright", specifier = ">=1.1.407" },
{ name = "ruff", specifier = ">=0.14.2" }, { name = "ruff", specifier = ">=0.14.2" },
] ]
@ -772,6 +774,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
] ]
[[package]]
name = "pyjwt"
version = "2.10.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
]
[[package]] [[package]]
name = "pyright" name = "pyright"
version = "1.1.407" version = "1.1.407"