Compare commits
6 Commits
6a882ce39a
...
f98145d6db
| Author | SHA1 | Date | |
|---|---|---|---|
| f98145d6db | |||
| 0e1e77c2dd | |||
| 15de496501 | |||
| 8960f551e6 | |||
| 2260c7cc27 | |||
| b5f31a8c72 |
@ -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
|
||||
@ -6,27 +6,31 @@ from .models import CustomUser
|
||||
@admin.register(CustomUser)
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
"""Admin interface for CustomUser."""
|
||||
|
||||
|
||||
# Add custom fields to the user admin
|
||||
fieldsets = UserAdmin.fieldsets + (
|
||||
('CAS Information', {
|
||||
'fields': ('cas_user_id', 'cas_groups', 'cas_attributes'),
|
||||
}),
|
||||
(
|
||||
"CAS Information",
|
||||
{
|
||||
"fields": ("cas_user_id", "cas_groups", "cas_attributes"),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# Add custom fields to the list display
|
||||
list_display = UserAdmin.list_display + ('cas_user_id', 'get_cas_groups_display')
|
||||
|
||||
list_display = UserAdmin.list_display + ("cas_user_id", "get_cas_groups_display")
|
||||
|
||||
# Add search fields
|
||||
search_fields = UserAdmin.search_fields + ('cas_user_id',)
|
||||
|
||||
search_fields = UserAdmin.search_fields + ("cas_user_id",)
|
||||
|
||||
# Add filters
|
||||
list_filter = UserAdmin.list_filter + ('cas_groups',)
|
||||
|
||||
list_filter = UserAdmin.list_filter + ("cas_groups",)
|
||||
|
||||
# Make CAS fields readonly in admin
|
||||
readonly_fields = ('cas_user_id', 'cas_groups', 'cas_attributes')
|
||||
|
||||
readonly_fields = ("cas_user_id", "cas_groups", "cas_attributes")
|
||||
|
||||
def get_cas_groups_display(self, obj):
|
||||
"""Display CAS groups in admin list."""
|
||||
return obj.get_cas_groups_display()
|
||||
get_cas_groups_display.short_description = 'CAS Groups'
|
||||
|
||||
get_cas_groups_display.short_description = "CAS Groups"
|
||||
|
||||
@ -2,5 +2,5 @@ from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'accounts'
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "accounts"
|
||||
|
||||
@ -7,41 +7,131 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CustomUser',
|
||||
name="CustomUser",
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('cas_user_id', models.CharField(blank=True, max_length=50, null=True, unique=True)),
|
||||
('cas_groups', models.JSONField(blank=True, default=list)),
|
||||
('cas_attributes', models.JSONField(blank=True, default=dict)),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||
(
|
||||
"last_login",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="last login"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_superuser",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Designates that this user has all permissions without explicitly assigning them.",
|
||||
verbose_name="superuser status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"username",
|
||||
models.CharField(
|
||||
error_messages={
|
||||
"unique": "A user with that username already exists."
|
||||
},
|
||||
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||
max_length=150,
|
||||
unique=True,
|
||||
validators=[
|
||||
django.contrib.auth.validators.UnicodeUsernameValidator()
|
||||
],
|
||||
verbose_name="username",
|
||||
),
|
||||
),
|
||||
(
|
||||
"first_name",
|
||||
models.CharField(
|
||||
blank=True, max_length=150, verbose_name="first name"
|
||||
),
|
||||
),
|
||||
(
|
||||
"last_name",
|
||||
models.CharField(
|
||||
blank=True, max_length=150, verbose_name="last name"
|
||||
),
|
||||
),
|
||||
(
|
||||
"email",
|
||||
models.EmailField(
|
||||
blank=True, max_length=254, verbose_name="email address"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_staff",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Designates whether the user can log into this admin site.",
|
||||
verbose_name="staff status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_active",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
|
||||
verbose_name="active",
|
||||
),
|
||||
),
|
||||
(
|
||||
"date_joined",
|
||||
models.DateTimeField(
|
||||
default=django.utils.timezone.now, verbose_name="date joined"
|
||||
),
|
||||
),
|
||||
(
|
||||
"cas_user_id",
|
||||
models.CharField(blank=True, max_length=50, null=True, unique=True),
|
||||
),
|
||||
("cas_groups", models.JSONField(blank=True, default=list)),
|
||||
("cas_attributes", models.JSONField(blank=True, default=dict)),
|
||||
(
|
||||
"groups",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.group",
|
||||
verbose_name="groups",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user_permissions",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Specific permissions for this user.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.permission",
|
||||
verbose_name="user permissions",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'abstract': False,
|
||||
"verbose_name": "user",
|
||||
"verbose_name_plural": "users",
|
||||
"abstract": False,
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
("objects", django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
import json
|
||||
|
||||
|
||||
class CustomUser(AbstractUser):
|
||||
@ -57,4 +56,3 @@ class CustomUser(AbstractUser):
|
||||
self.cas_attributes = attributes
|
||||
|
||||
self.save()
|
||||
|
||||
|
||||
@ -1,3 +1 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
|
||||
@ -1,3 +1 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'opus_submitter.settings')
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "opus_submitter.settings")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
@ -18,5 +19,5 @@ def main():
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
from ninja import NinjaAPI
|
||||
from ninja.security import django_auth
|
||||
from submissions.api import router as submissions_router
|
||||
from submissions.schemas import UserInfoOut
|
||||
|
||||
@ -47,7 +46,7 @@ def api_info(request):
|
||||
def get_user_info(request):
|
||||
"""Get current user information"""
|
||||
user = request.user
|
||||
|
||||
|
||||
if user.is_authenticated:
|
||||
return {
|
||||
"id": user.id,
|
||||
@ -58,7 +57,7 @@ def get_user_info(request):
|
||||
"is_authenticated": True,
|
||||
"is_staff": user.is_staff,
|
||||
"is_superuser": user.is_superuser,
|
||||
"cas_groups": getattr(user, 'cas_groups', [])
|
||||
"cas_groups": getattr(user, "cas_groups", []),
|
||||
}
|
||||
else:
|
||||
return {
|
||||
|
||||
@ -11,6 +11,6 @@ import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'opus_submitter.settings')
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "opus_submitter.settings")
|
||||
|
||||
application = get_asgi_application()
|
||||
|
||||
@ -176,4 +176,4 @@ STATICFILES_DIRS = [
|
||||
os.path.join(BASE_DIR, "static_source/vite"),
|
||||
]
|
||||
|
||||
from opus_submitter.settingsLocal import *
|
||||
from opus_submitter.settingsLocal import * # noqa
|
||||
|
||||
0
opus_submitter/opus_submitter/settingsLocal.py.dist
Normal file
0
opus_submitter/opus_submitter/settingsLocal.py.dist
Normal file
@ -28,7 +28,15 @@ from .api import api
|
||||
|
||||
@login_required
|
||||
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 = [
|
||||
|
||||
@ -11,6 +11,6 @@ import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'opus_submitter.settings')
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "opus_submitter.settings")
|
||||
|
||||
application = get_wsgi_application()
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"install": "^0.13.0",
|
||||
"pinia": "^3.0.3",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"tesseract.js": "^5.1.1",
|
||||
"vue": "^3.5.22"
|
||||
|
||||
@ -14,6 +14,9 @@ importers:
|
||||
install:
|
||||
specifier: ^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:
|
||||
specifier: ^4.1.16
|
||||
version: 4.1.16
|
||||
@ -480,6 +483,15 @@ packages:
|
||||
'@vue/compiler-ssr@3.5.22':
|
||||
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':
|
||||
resolution: {integrity: sha512-PyFDCqpdfYUT+oMLqcc61oHfJlC6yjhybaefwQjRdkchIihToOEpJ2Wu/Ebq2yrnJdd1EsaAvZaXVAqcxtnDxQ==}
|
||||
peerDependencies:
|
||||
@ -519,9 +531,16 @@ packages:
|
||||
alien-signals@3.0.3:
|
||||
resolution: {integrity: sha512-2JXjom6R7ZwrISpUphLhf4htUq1aKRCennTJ6u9kFfr3sLmC9+I4CxxVi+McoFnIg+p1HnVrfLT/iCt4Dlz//Q==}
|
||||
|
||||
birpc@2.6.1:
|
||||
resolution: {integrity: sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==}
|
||||
|
||||
bmp-js@0.1.0:
|
||||
resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==}
|
||||
|
||||
copy-anything@4.0.5:
|
||||
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
csstype@3.1.3:
|
||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||
|
||||
@ -565,6 +584,9 @@ packages:
|
||||
graceful-fs@4.2.11:
|
||||
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:
|
||||
resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==}
|
||||
|
||||
@ -578,6 +600,10 @@ packages:
|
||||
is-url@1.2.4:
|
||||
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
|
||||
|
||||
is-what@5.5.0:
|
||||
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
jiti@2.6.1:
|
||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
@ -655,6 +681,9 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
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:
|
||||
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
||||
|
||||
@ -679,6 +708,9 @@ packages:
|
||||
path-browserify@1.0.1:
|
||||
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
|
||||
|
||||
perfect-debounce@1.0.0:
|
||||
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@ -686,6 +718,15 @@ packages:
|
||||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||
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:
|
||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@ -693,6 +734,9 @@ packages:
|
||||
regenerator-runtime@0.13.11:
|
||||
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
|
||||
|
||||
rfdc@1.4.1:
|
||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||
|
||||
rollup@4.52.5:
|
||||
resolution: {integrity: sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
@ -702,6 +746,14 @@ packages:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==}
|
||||
|
||||
@ -1103,6 +1155,24 @@ snapshots:
|
||||
'@vue/compiler-dom': 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)':
|
||||
dependencies:
|
||||
'@volar/language-core': 2.4.23
|
||||
@ -1146,8 +1216,14 @@ snapshots:
|
||||
|
||||
alien-signals@3.0.3: {}
|
||||
|
||||
birpc@2.6.1: {}
|
||||
|
||||
bmp-js@0.1.0: {}
|
||||
|
||||
copy-anything@4.0.5:
|
||||
dependencies:
|
||||
is-what: 5.5.0
|
||||
|
||||
csstype@3.1.3: {}
|
||||
|
||||
daisyui@5.3.10: {}
|
||||
@ -1201,6 +1277,8 @@ snapshots:
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
hookable@5.5.3: {}
|
||||
|
||||
idb-keyval@6.2.2: {}
|
||||
|
||||
install@0.13.0: {}
|
||||
@ -1209,6 +1287,8 @@ snapshots:
|
||||
|
||||
is-url@1.2.4: {}
|
||||
|
||||
is-what@5.5.0: {}
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
lightningcss-android-arm64@1.30.2:
|
||||
@ -1264,6 +1344,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
mitt@3.0.1: {}
|
||||
|
||||
muggle-string@0.4.1: {}
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
@ -1276,10 +1358,19 @@ snapshots:
|
||||
|
||||
path-browserify@1.0.1: {}
|
||||
|
||||
perfect-debounce@1.0.0: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
@ -1288,6 +1379,8 @@ snapshots:
|
||||
|
||||
regenerator-runtime@0.13.11: {}
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
|
||||
rollup@4.52.5:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@ -1318,6 +1411,12 @@ snapshots:
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
speakingurl@14.0.1: {}
|
||||
|
||||
superjson@2.2.5:
|
||||
dependencies:
|
||||
copy-anything: 4.0.5
|
||||
|
||||
tailwindcss@4.1.16: {}
|
||||
|
||||
tapable@2.3.0: {}
|
||||
|
||||
@ -27,10 +27,10 @@ class SimpleCASBackend(BaseBackend):
|
||||
print(f"CAS Attributes: {attributes}")
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
# Try to find user by CAS user ID first, then by username
|
||||
username = attributes.get("username", cas_user_id).lower()
|
||||
|
||||
|
||||
try:
|
||||
# First try to find by CAS user ID
|
||||
user = User.objects.get(cas_user_id=cas_user_id)
|
||||
@ -50,10 +50,10 @@ class SimpleCASBackend(BaseBackend):
|
||||
last_name=attributes.get("lastname", ""),
|
||||
email=attributes.get("email", ""),
|
||||
)
|
||||
|
||||
|
||||
# Always update CAS data on login
|
||||
user.update_cas_data(cas_user_id, attributes)
|
||||
|
||||
|
||||
return user
|
||||
|
||||
def validate_ticket(self, ticket, service):
|
||||
|
||||
@ -12,35 +12,39 @@ import urllib.parse
|
||||
|
||||
class SimpleCASLoginView(View):
|
||||
"""Simple CAS login view."""
|
||||
|
||||
|
||||
def get(self, request):
|
||||
ticket = request.GET.get('ticket')
|
||||
|
||||
ticket = request.GET.get("ticket")
|
||||
|
||||
if ticket:
|
||||
# Coming back from CAS with ticket - validate it
|
||||
service_url = request.build_absolute_uri().split('?')[0] # Remove query params
|
||||
|
||||
service_url = request.build_absolute_uri().split("?")[
|
||||
0
|
||||
] # Remove query params
|
||||
|
||||
user = authenticate(request=request, ticket=ticket, service=service_url)
|
||||
|
||||
|
||||
if user:
|
||||
login(request, user)
|
||||
return redirect(settings.LOGIN_REDIRECT_URL)
|
||||
else:
|
||||
return HttpResponse("Authentication failed", status=401)
|
||||
|
||||
|
||||
else:
|
||||
# No ticket - redirect to CAS
|
||||
service_url = request.build_absolute_uri().split('?')[0] # Remove query params
|
||||
service_url = request.build_absolute_uri().split("?")[
|
||||
0
|
||||
] # Remove query params
|
||||
cas_login_url = f"{settings.CAS_SERVER_URL.rstrip('/')}/login?service={urllib.parse.quote(service_url)}"
|
||||
return redirect(cas_login_url)
|
||||
|
||||
|
||||
class SimpleCASLogoutView(View):
|
||||
"""Simple CAS logout view."""
|
||||
|
||||
|
||||
def get(self, request):
|
||||
logout(request)
|
||||
|
||||
|
||||
# Redirect to CAS logout
|
||||
cas_logout_url = f"{settings.CAS_SERVER_URL.rstrip('/')}/logout"
|
||||
return redirect(cas_logout_url)
|
||||
|
||||
@ -1,18 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import PuzzleCard from './components/PuzzleCard.vue'
|
||||
import SubmissionForm from './components/SubmissionForm.vue'
|
||||
import AdminPanel from './components/AdminPanel.vue'
|
||||
import { puzzleHelpers, submissionHelpers, errorHelpers, apiService } from './services/apiService'
|
||||
import type { SteamCollection, SteamCollectionItem, Submission, PuzzleResponse, UserInfo } from './types'
|
||||
import { ref, onMounted, computed, defineProps } from 'vue'
|
||||
import PuzzleCard from '@/components/PuzzleCard.vue'
|
||||
import SubmissionForm from '@/components/SubmissionForm.vue'
|
||||
import AdminPanel from '@/components/AdminPanel.vue'
|
||||
import { apiService, errorHelpers } from '@/services/apiService'
|
||||
import { usePuzzlesStore } from '@/stores/puzzles'
|
||||
import { useSubmissionsStore } from '@/stores/submissions'
|
||||
import type { SteamCollection, PuzzleResponse, UserInfo } from '@/types'
|
||||
|
||||
// API data
|
||||
const collections = ref<SteamCollection[]>([])
|
||||
const puzzles = ref<SteamCollectionItem[]>([])
|
||||
const submissions = ref<Submission[]>([])
|
||||
const props = defineProps<{collectionTitle: string, collectionUrl: string, collectionDescription: string}>()
|
||||
|
||||
// Pinia stores
|
||||
const puzzlesStore = usePuzzlesStore()
|
||||
const submissionsStore = useSubmissionsStore()
|
||||
|
||||
// Local state
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
const isLoading = ref(true)
|
||||
const showSubmissionModal = ref(false)
|
||||
const error = ref<string>('')
|
||||
|
||||
// Mock data removed - using API data only
|
||||
@ -25,7 +29,7 @@ const isSuperuser = computed(() => {
|
||||
// Computed property to get responses grouped by puzzle
|
||||
const responsesByPuzzle = computed(() => {
|
||||
const grouped: Record<number, PuzzleResponse[]> = {}
|
||||
submissions.value.forEach(submission => {
|
||||
submissionsStore.submissions.forEach(submission => {
|
||||
submission.responses.forEach(response => {
|
||||
// Handle both number and object types for puzzle field
|
||||
const puzzleId = typeof response.puzzle === 'number' ? response.puzzle : response.puzzle.id
|
||||
@ -55,34 +59,15 @@ onMounted(async () => {
|
||||
console.warn('User info error:', userResponse.error)
|
||||
}
|
||||
|
||||
// Load puzzles from API
|
||||
// Load puzzles from API using store
|
||||
console.log('Loading puzzles...')
|
||||
const loadedPuzzles = await puzzleHelpers.loadPuzzles()
|
||||
puzzles.value = loadedPuzzles
|
||||
console.log('Puzzles loaded:', loadedPuzzles.length)
|
||||
await puzzlesStore.loadPuzzles()
|
||||
console.log('Puzzles loaded:', puzzlesStore.puzzles.length)
|
||||
|
||||
// Create mock collection from loaded puzzles for display
|
||||
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
|
||||
// Load existing submissions using store
|
||||
console.log('Loading submissions...')
|
||||
const loadedSubmissions = await submissionHelpers.loadSubmissions()
|
||||
submissions.value = loadedSubmissions
|
||||
console.log('Submissions loaded:', loadedSubmissions.length)
|
||||
await submissionsStore.loadSubmissions()
|
||||
console.log('Submissions loaded:', submissionsStore.submissions.length)
|
||||
|
||||
console.log('Data load complete!')
|
||||
|
||||
@ -97,37 +82,31 @@ onMounted(async () => {
|
||||
|
||||
const handleSubmission = async (submissionData: {
|
||||
files: any[],
|
||||
notes?: string
|
||||
notes?: string,
|
||||
manualValidationRequested?: boolean
|
||||
}) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
|
||||
// Create submission via API
|
||||
const response = await submissionHelpers.createFromFiles(
|
||||
// Create submission via store
|
||||
const submission = await submissionsStore.createSubmission(
|
||||
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
|
||||
const puzzleNames = response.data.responses.map(r => r.puzzle_name).join(', ')
|
||||
// Show success message
|
||||
if (submission) {
|
||||
const puzzleNames = submission.responses.map(r => r.puzzle_name).join(', ')
|
||||
alert(`Solutions submitted successfully for puzzles: ${puzzleNames}`)
|
||||
|
||||
// Close modal
|
||||
showSubmissionModal.value = false
|
||||
} else {
|
||||
alert('Submission created successfully!')
|
||||
}
|
||||
|
||||
// Close modal
|
||||
submissionsStore.closeSubmissionModal()
|
||||
|
||||
} catch (err) {
|
||||
const errorMessage = errorHelpers.getErrorMessage(err)
|
||||
error.value = errorMessage
|
||||
@ -139,16 +118,16 @@ const handleSubmission = async (submissionData: {
|
||||
}
|
||||
|
||||
const openSubmissionModal = () => {
|
||||
showSubmissionModal.value = true
|
||||
submissionsStore.openSubmissionModal()
|
||||
}
|
||||
|
||||
const closeSubmissionModal = () => {
|
||||
showSubmissionModal.value = false
|
||||
submissionsStore.closeSubmissionModal()
|
||||
}
|
||||
|
||||
// Function to match puzzle name from OCR to actual puzzle
|
||||
const findPuzzleByName = (ocrPuzzleName: string): SteamCollectionItem | null => {
|
||||
return puzzleHelpers.findPuzzleByName(puzzles.value, ocrPuzzleName)
|
||||
const findPuzzleByName = (ocrPuzzleName: string) => {
|
||||
return puzzlesStore.findPuzzleByName(ocrPuzzleName)
|
||||
}
|
||||
|
||||
const reloadPage = () => {
|
||||
@ -164,7 +143,7 @@ const reloadPage = () => {
|
||||
<div class="flex-1">
|
||||
<h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</h1>
|
||||
</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 class="text-sm">
|
||||
<span class="font-medium">{{ userInfo.username }}</span>
|
||||
@ -174,6 +153,11 @@ const reloadPage = () => {
|
||||
<div v-else class="text-sm text-base-content/70">
|
||||
Not logged in
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<a href="/admin" class="btn btn-xs btn-warning">
|
||||
Admin django
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -204,11 +188,11 @@ const reloadPage = () => {
|
||||
<!-- Main Content -->
|
||||
<div v-else class="space-y-8">
|
||||
<!-- 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-body">
|
||||
<h2 class="card-title text-2xl">{{ collections[0].title }}</h2>
|
||||
<p class="text-base-content/70">{{ collections[0].description }}</p>
|
||||
<h2 class="card-title text-2xl">{{ props.collectionTitle }}</h2>
|
||||
<p class="text-base-content/70">{{ props.collectionDescription }}</p>
|
||||
<div class="flex flex-wrap gap-4 mt-4">
|
||||
<button
|
||||
@click="openSubmissionModal"
|
||||
@ -230,7 +214,7 @@ const reloadPage = () => {
|
||||
<!-- Puzzles Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<PuzzleCard
|
||||
v-for="puzzle in puzzles"
|
||||
v-for="puzzle in puzzlesStore.puzzles"
|
||||
:key="puzzle.id"
|
||||
:puzzle="puzzle"
|
||||
:responses="responsesByPuzzle[puzzle.id] || []"
|
||||
@ -238,7 +222,7 @@ const reloadPage = () => {
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
@ -247,7 +231,7 @@ const reloadPage = () => {
|
||||
</div>
|
||||
|
||||
<!-- 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="flex justify-between items-center mb-4">
|
||||
<h3 class="font-bold text-lg">Submit Solution</h3>
|
||||
@ -260,7 +244,7 @@ const reloadPage = () => {
|
||||
</div>
|
||||
|
||||
<SubmissionForm
|
||||
:puzzles="puzzles"
|
||||
:puzzles="puzzlesStore.puzzles"
|
||||
:find-puzzle-by-name="findPuzzleByName"
|
||||
@submit="handleSubmission"
|
||||
/>
|
||||
|
||||
@ -43,19 +43,46 @@
|
||||
<tbody>
|
||||
<tr v-for="response in responsesNeedingValidation" :key="response.id">
|
||||
<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>
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-sm space-y-1">
|
||||
<div>Cost: {{ response.cost || '-' }}</div>
|
||||
<div>Cycles: {{ response.cycles || '-' }}</div>
|
||||
<div>Area: {{ response.area || '-' }}</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span>Cost: {{ response.cost || '-' }}</span>
|
||||
<span
|
||||
v-if="response.ocr_confidence_cost"
|
||||
class="badge badge-xs"
|
||||
:class="getConfidenceBadgeClass(response.ocr_confidence_cost)"
|
||||
>
|
||||
{{ Math.round(response.ocr_confidence_cost * 100) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span>Cycles: {{ response.cycles || '-' }}</span>
|
||||
<span
|
||||
v-if="response.ocr_confidence_cycles"
|
||||
class="badge badge-xs"
|
||||
:class="getConfidenceBadgeClass(response.ocr_confidence_cycles)"
|
||||
>
|
||||
{{ Math.round(response.ocr_confidence_cycles * 100) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span>Area: {{ response.area || '-' }}</span>
|
||||
<span
|
||||
v-if="response.ocr_confidence_area"
|
||||
class="badge badge-xs"
|
||||
:class="getConfidenceBadgeClass(response.ocr_confidence_area)"
|
||||
>
|
||||
{{ Math.round(response.ocr_confidence_area * 100) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="badge badge-warning badge-sm">
|
||||
{{ response.ocr_confidence_score ? Math.round(response.ocr_confidence_score * 100) + '%' : 'Low' }}
|
||||
{{ getOverallConfidence(response) }}%
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@ -83,19 +110,43 @@
|
||||
|
||||
<!-- Validation Modal -->
|
||||
<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>
|
||||
|
||||
<div v-for="file in validationModal.response.files">
|
||||
<img :src="file.file_url">
|
||||
</div>
|
||||
|
||||
<div v-if="validationModal.response" class="space-y-4">
|
||||
<div class="alert alert-info">
|
||||
<i class="mdi mdi-information-outline"></i>
|
||||
<div>
|
||||
<div class="font-bold">{{ validationModal.response.puzzle_name }}</div>
|
||||
<div class="font-bold">{{ validationModal.response.puzzle_title }}</div>
|
||||
<div class="text-sm">Review and correct the OCR data below</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">
|
||||
<label class="label">
|
||||
<span class="label-text">Cost</span>
|
||||
@ -152,8 +203,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { apiService } from '../services/apiService'
|
||||
import type { PuzzleResponse } from '../types'
|
||||
import { apiService } from '@/services/apiService'
|
||||
import type { PuzzleResponse } from '@/types'
|
||||
import {usePuzzlesStore} from '@/stores/puzzles'
|
||||
const puzzlesStore = usePuzzlesStore()
|
||||
|
||||
// Reactive data
|
||||
const stats = ref({
|
||||
@ -172,6 +225,7 @@ const validationModal = ref({
|
||||
show: false,
|
||||
response: null as PuzzleResponse | null,
|
||||
data: {
|
||||
puzzle_title: '',
|
||||
validated_cost: '',
|
||||
validated_cycles: '',
|
||||
validated_area: ''
|
||||
@ -217,6 +271,7 @@ const loadData = async () => {
|
||||
const openValidationModal = (response: PuzzleResponse) => {
|
||||
validationModal.value.response = response
|
||||
validationModal.value.data = {
|
||||
puzzle: response.puzzle || '',
|
||||
validated_cost: response.cost || '',
|
||||
validated_cycles: response.cycles || '',
|
||||
validated_area: response.area || ''
|
||||
@ -228,6 +283,7 @@ const closeValidationModal = () => {
|
||||
validationModal.value.show = false
|
||||
validationModal.value.response = null
|
||||
validationModal.value.data = {
|
||||
puzzle: '',
|
||||
validated_cost: '',
|
||||
validated_cycles: '',
|
||||
validated_area: ''
|
||||
@ -273,6 +329,26 @@ onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
// Helper functions for confidence display
|
||||
const getConfidenceBadgeClass = (confidence: number): string => {
|
||||
if (confidence >= 0.8) return 'badge-success'
|
||||
if (confidence >= 0.6) return 'badge-warning'
|
||||
return 'badge-error'
|
||||
}
|
||||
|
||||
const getOverallConfidence = (response: PuzzleResponse): number => {
|
||||
const confidences = [
|
||||
response.ocr_confidence_cost,
|
||||
response.ocr_confidence_cycles,
|
||||
response.ocr_confidence_area
|
||||
].filter(conf => conf !== undefined && conf !== null) as number[]
|
||||
|
||||
if (confidences.length === 0) return 0
|
||||
|
||||
const average = confidences.reduce((sum, conf) => sum + conf, 0) / confidences.length
|
||||
return Math.round(average * 100)
|
||||
}
|
||||
|
||||
// Expose refresh method
|
||||
defineExpose({
|
||||
refresh: loadData
|
||||
|
||||
@ -83,7 +83,17 @@
|
||||
|
||||
<div v-else-if="file.ocrData" class="mt-1 space-y-1">
|
||||
<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
|
||||
@click="retryOCR(file)"
|
||||
class="btn btn-xs btn-ghost"
|
||||
@ -95,19 +105,74 @@
|
||||
<div class="text-xs space-y-1 bg-base-200 p-2 rounded">
|
||||
<div v-if="file.ocrData.puzzle">
|
||||
<strong>Puzzle:</strong> {{ file.ocrData.puzzle }}
|
||||
<span
|
||||
v-if="file.ocrData.confidence?.puzzle"
|
||||
class="ml-2 opacity-60"
|
||||
:title="`Puzzle confidence: ${Math.round(file.ocrData.confidence.puzzle * 100)}%`"
|
||||
>
|
||||
({{ Math.round(file.ocrData.confidence.puzzle * 100) }}%)
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="file.ocrData.cost">
|
||||
<strong>Cost:</strong> {{ file.ocrData.cost }}
|
||||
<span
|
||||
v-if="file.ocrData.confidence?.cost"
|
||||
class="ml-2 opacity-60"
|
||||
:title="`Cost confidence: ${Math.round(file.ocrData.confidence.cost * 100)}%`"
|
||||
>
|
||||
({{ Math.round(file.ocrData.confidence.cost * 100) }}%)
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="file.ocrData.cycles">
|
||||
<strong>Cycles:</strong> {{ file.ocrData.cycles }}
|
||||
<span
|
||||
v-if="file.ocrData.confidence?.cycles"
|
||||
class="ml-2 opacity-60"
|
||||
:title="`Cycles confidence: ${Math.round(file.ocrData.confidence.cycles * 100)}%`"
|
||||
>
|
||||
({{ Math.round(file.ocrData.confidence.cycles * 100) }}%)
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="file.ocrData.area">
|
||||
<strong>Area:</strong> {{ file.ocrData.area }}
|
||||
<span
|
||||
v-if="file.ocrData.confidence?.area"
|
||||
class="ml-2 opacity-60"
|
||||
:title="`Area confidence: ${Math.round(file.ocrData.confidence.area * 100)}%`"
|
||||
>
|
||||
({{ Math.round(file.ocrData.confidence.area * 100) }}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual Puzzle Selection (when OCR confidence is low) -->
|
||||
<div v-if="file.needsManualPuzzleSelection" class="mt-2">
|
||||
<div class="alert alert-warning alert-sm">
|
||||
<i class="mdi mdi-alert-circle text-lg"></i>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">Low OCR Confidence</div>
|
||||
<div class="text-xs">Please select the correct puzzle manually</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<select
|
||||
v-model="file.manualPuzzleSelection"
|
||||
class="select select-bordered select-sm w-full"
|
||||
@change="onManualPuzzleSelection(file)"
|
||||
>
|
||||
<option value="">Select puzzle...</option>
|
||||
<option
|
||||
v-for="puzzle in puzzlesStore.puzzles"
|
||||
:key="puzzle.id"
|
||||
:value="puzzle.title"
|
||||
>
|
||||
{{ puzzle.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual OCR trigger for non-auto detected files -->
|
||||
<div v-else-if="!file.ocrProcessing && !file.ocrError && !file.ocrData" class="mt-1">
|
||||
<button
|
||||
@ -142,7 +207,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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'
|
||||
|
||||
interface Props {
|
||||
@ -157,6 +223,9 @@ interface Emits {
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// Pinia store
|
||||
const puzzlesStore = usePuzzlesStore()
|
||||
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const isDragOver = ref(false)
|
||||
const error = ref('')
|
||||
@ -173,10 +242,9 @@ watch(files, (newFiles) => {
|
||||
}, { deep: true })
|
||||
|
||||
// Watch for puzzle changes and update OCR service
|
||||
watch(() => props.puzzles, (newPuzzles) => {
|
||||
watch(() => puzzlesStore.puzzles, (newPuzzles) => {
|
||||
if (newPuzzles && newPuzzles.length > 0) {
|
||||
const puzzleNames = newPuzzles.map(puzzle => puzzle.title)
|
||||
ocrService.setAvailablePuzzleNames(puzzleNames)
|
||||
ocrService.setAvailablePuzzleNames(puzzlesStore.puzzleNames)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
@ -294,6 +362,15 @@ const processOCR = async (submissionFile: SubmissionFile) => {
|
||||
// Force reactivity update
|
||||
await nextTick()
|
||||
files.value[fileIndex].ocrData = ocrData
|
||||
|
||||
// Check if puzzle confidence is below 80% and needs manual selection
|
||||
if (ocrData.confidence.puzzle < 0.8) {
|
||||
files.value[fileIndex].needsManualPuzzleSelection = true
|
||||
console.log(`Low puzzle confidence (${Math.round(ocrData.confidence.puzzle * 100)}%) for ${submissionFile.file.name}, requiring manual selection`)
|
||||
} else {
|
||||
files.value[fileIndex].needsManualPuzzleSelection = false
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
} catch (error) {
|
||||
console.error('OCR processing failed:', error)
|
||||
@ -306,4 +383,22 @@ const processOCR = async (submissionFile: SubmissionFile) => {
|
||||
const retryOCR = (submissionFile: 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>
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
<div class="text-sm space-y-1 mt-1">
|
||||
<div v-for="(data, puzzleName) in responsesByPuzzle" :key="puzzleName" class="flex justify-between">
|
||||
<span>{{ puzzleName }}</span>
|
||||
<span class="badge badge-ghost badge-sm">{{ data.files.length }} file(s)</span>
|
||||
<span class="badge badge-ghost badge-sm ml-2">{{ data.files.length }} file(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -23,19 +23,52 @@
|
||||
|
||||
<!-- File Upload -->
|
||||
<FileUpload v-model="submissionFiles" :puzzles="puzzles" />
|
||||
|
||||
<!-- Manual Selection Warning -->
|
||||
<div v-if="filesNeedingManualSelection.length > 0" class="alert alert-warning">
|
||||
<i class="mdi mdi-alert-circle text-xl"></i>
|
||||
<div class="flex-1">
|
||||
<div class="font-bold">Manual Puzzle Selection Required</div>
|
||||
<div class="text-sm">
|
||||
{{ filesNeedingManualSelection.length }} file(s) have low OCR confidence for puzzle names.
|
||||
Please select the correct puzzle for each file before submitting.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Notes (Optional)</span>
|
||||
<span class="label-text-alt">{{ notesLength }}/500</span>
|
||||
<div class="flex-1">
|
||||
<label class="flex label">
|
||||
<span class="label-text font-medium">Notes (Optional)</span>
|
||||
<span class="label-text-alt">{{ notesLength }}/500</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="notes"
|
||||
class="flex textarea textarea-bordered h-24 w-full resize-none"
|
||||
placeholder="Add any notes about your solution, approach, or interesting findings..."
|
||||
maxlength="500"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual Validation Request -->
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="manualValidationRequested"
|
||||
class="checkbox checkbox-primary"
|
||||
/>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- Submit Button -->
|
||||
@ -43,10 +76,14 @@
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="isSubmitting"
|
||||
:disabled="!canSubmit"
|
||||
>
|
||||
<span v-if="isSubmitting" class="loading loading-spinner loading-sm"></span>
|
||||
{{ isSubmitting ? 'Submitting...' : 'Submit Solution' }}
|
||||
<span v-if="isSubmitting">Submitting...</span>
|
||||
<span v-else-if="filesNeedingManualSelection.length > 0">
|
||||
Select Puzzles ({{ filesNeedingManualSelection.length }} remaining)
|
||||
</span>
|
||||
<span v-else>Submit Solution</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@ -55,8 +92,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import FileUpload from './FileUpload.vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import FileUpload from '@/components/FileUpload.vue'
|
||||
import type { SteamCollectionItem, SubmissionFile } from '@/types'
|
||||
|
||||
interface Props {
|
||||
@ -65,7 +102,7 @@ interface Props {
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
submit: [submissionData: { files: SubmissionFile[], notes?: string }]
|
||||
submit: [submissionData: { files: SubmissionFile[], notes?: string, manualValidationRequested?: boolean }]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@ -73,13 +110,18 @@ const emit = defineEmits<Emits>()
|
||||
|
||||
const submissionFiles = ref<SubmissionFile[]>([])
|
||||
const notes = ref('')
|
||||
const manualValidationRequested = ref(false)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const notesLength = computed(() => notes.value.length)
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
return submissionFiles.value.length > 0 &&
|
||||
!isSubmitting.value
|
||||
const hasFiles = submissionFiles.value.length > 0
|
||||
const noManualSelectionNeeded = !submissionFiles.value.some(file => file.needsManualPuzzleSelection)
|
||||
|
||||
return hasFiles &&
|
||||
!isSubmitting.value &&
|
||||
noManualSelectionNeeded
|
||||
})
|
||||
|
||||
// Group files by detected puzzle
|
||||
@ -87,8 +129,10 @@ const responsesByPuzzle = computed(() => {
|
||||
const grouped: Record<string, { puzzle: SteamCollectionItem | null, files: SubmissionFile[] }> = {}
|
||||
|
||||
submissionFiles.value.forEach(file => {
|
||||
if (file.ocrData?.puzzle) {
|
||||
const puzzleName = file.ocrData.puzzle
|
||||
// Use manual puzzle selection if available, otherwise fall back to OCR
|
||||
const puzzleName = file.manualPuzzleSelection || file.ocrData?.puzzle
|
||||
|
||||
if (puzzleName) {
|
||||
if (!grouped[puzzleName]) {
|
||||
grouped[puzzleName] = {
|
||||
puzzle: props.findPuzzleByName(puzzleName),
|
||||
@ -102,6 +146,28 @@ const responsesByPuzzle = computed(() => {
|
||||
return grouped
|
||||
})
|
||||
|
||||
// Count files that need manual puzzle selection
|
||||
const filesNeedingManualSelection = computed(() => {
|
||||
return submissionFiles.value.filter(file => file.needsManualPuzzleSelection)
|
||||
})
|
||||
|
||||
// Check if any OCR confidence is below 50%
|
||||
const hasLowConfidence = computed(() => {
|
||||
return submissionFiles.value.some(file => {
|
||||
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 () => {
|
||||
if (!canSubmit.value) return
|
||||
|
||||
@ -111,12 +177,14 @@ const handleSubmit = async () => {
|
||||
// Emit the files and notes for the parent to handle API submission
|
||||
emit('submit', {
|
||||
files: submissionFiles.value,
|
||||
notes: notes.value.trim() || undefined
|
||||
notes: notes.value.trim() || undefined,
|
||||
manualValidationRequested: manualValidationRequested.value
|
||||
})
|
||||
|
||||
// Reset form
|
||||
submissionFiles.value = []
|
||||
notes.value = ''
|
||||
manualValidationRequested.value = false
|
||||
|
||||
} catch (error) {
|
||||
console.error('Submission error:', error)
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
import { createApp } from '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)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type {
|
||||
SteamCollectionItem,
|
||||
Submission,
|
||||
PuzzleResponse,
|
||||
import type {
|
||||
SteamCollectionItem,
|
||||
Submission,
|
||||
PuzzleResponse,
|
||||
SubmissionFile,
|
||||
UserInfo
|
||||
} from '../types'
|
||||
@ -32,7 +32,7 @@ interface SubmissionStats {
|
||||
// API Service Class
|
||||
export class ApiService {
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
@ -115,6 +115,7 @@ export class ApiService {
|
||||
async createSubmission(
|
||||
submissionData: {
|
||||
notes?: string
|
||||
manual_validation_requested?: boolean
|
||||
responses: Array<{
|
||||
puzzle_id: number
|
||||
puzzle_name: string
|
||||
@ -122,16 +123,18 @@ export class ApiService {
|
||||
cycles?: string
|
||||
area?: string
|
||||
needs_manual_validation?: boolean
|
||||
ocr_confidence_score?: number
|
||||
ocr_confidence_cost?: number
|
||||
ocr_confidence_cycles?: number
|
||||
ocr_confidence_area?: number
|
||||
}>
|
||||
},
|
||||
files: File[]
|
||||
): Promise<ApiResponse<Submission>> {
|
||||
const formData = new FormData()
|
||||
|
||||
|
||||
// Add JSON data
|
||||
formData.append('data', JSON.stringify(submissionData))
|
||||
|
||||
|
||||
// Add files
|
||||
files.forEach((file) => {
|
||||
formData.append('files', file)
|
||||
@ -203,20 +206,20 @@ export const puzzleHelpers = {
|
||||
|
||||
findPuzzleByName(puzzles: SteamCollectionItem[], name: string): SteamCollectionItem | null {
|
||||
if (!name) return null
|
||||
|
||||
|
||||
// Try exact match first
|
||||
let match = puzzles.find(p =>
|
||||
let match = puzzles.find(p =>
|
||||
p.title.toLowerCase() === name.toLowerCase()
|
||||
)
|
||||
|
||||
|
||||
if (!match) {
|
||||
// Try partial match
|
||||
match = puzzles.find(p =>
|
||||
match = puzzles.find(p =>
|
||||
p.title.toLowerCase().includes(name.toLowerCase()) ||
|
||||
name.toLowerCase().includes(p.title.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
return match || null
|
||||
}
|
||||
}
|
||||
@ -225,17 +228,20 @@ export const submissionHelpers = {
|
||||
async createFromFiles(
|
||||
files: SubmissionFile[],
|
||||
puzzles: SteamCollectionItem[],
|
||||
notes?: string
|
||||
notes?: string,
|
||||
manualValidationRequested?: boolean
|
||||
): Promise<ApiResponse<Submission>> {
|
||||
// Group files by detected puzzle
|
||||
const responsesByPuzzle: Record<string, {
|
||||
puzzle: SteamCollectionItem | null,
|
||||
files: SubmissionFile[]
|
||||
const responsesByPuzzle: Record<string, {
|
||||
puzzle: SteamCollectionItem | null,
|
||||
files: SubmissionFile[]
|
||||
}> = {}
|
||||
|
||||
|
||||
files.forEach(file => {
|
||||
if (file.ocrData?.puzzle) {
|
||||
const puzzleName = file.ocrData.puzzle
|
||||
// Use manual puzzle selection if available, otherwise fall back to OCR
|
||||
const puzzleName = file.manualPuzzleSelection || file.ocrData?.puzzle
|
||||
|
||||
if (puzzleName) {
|
||||
if (!responsesByPuzzle[puzzleName]) {
|
||||
responsesByPuzzle[puzzleName] = {
|
||||
puzzle: puzzleHelpers.findPuzzleByName(puzzles, puzzleName),
|
||||
@ -251,14 +257,14 @@ export const submissionHelpers = {
|
||||
.filter(([_, data]) => data.puzzle) // Only include matched puzzles
|
||||
.map(([puzzleName, 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
|
||||
)
|
||||
|
||||
|
||||
// Check if manual validation is needed
|
||||
const needsValidation = !fileWithOCR?.ocrData ||
|
||||
!fileWithOCR.ocrData.cost ||
|
||||
!fileWithOCR.ocrData.cycles ||
|
||||
const needsValidation = !fileWithOCR?.ocrData ||
|
||||
!fileWithOCR.ocrData.cost ||
|
||||
!fileWithOCR.ocrData.cycles ||
|
||||
!fileWithOCR.ocrData.area
|
||||
|
||||
return {
|
||||
@ -268,7 +274,9 @@ export const submissionHelpers = {
|
||||
cycles: fileWithOCR?.ocrData?.cycles,
|
||||
area: fileWithOCR?.ocrData?.area,
|
||||
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
|
||||
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[]> {
|
||||
|
||||
@ -5,6 +5,13 @@ export interface OpusMagnumData {
|
||||
cost: string;
|
||||
cycles: string;
|
||||
area: string;
|
||||
confidence: {
|
||||
puzzle: number;
|
||||
cost: number;
|
||||
cycles: number;
|
||||
area: number;
|
||||
overall: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OCRRegion {
|
||||
@ -41,6 +48,68 @@ export class OpusMagnumOCRService {
|
||||
*/
|
||||
setAvailablePuzzleNames(puzzleNames: string[]): void {
|
||||
this.availablePuzzleNames = puzzleNames;
|
||||
console.log('OCR service updated with puzzle names:', puzzleNames);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure OCR specifically for puzzle name recognition
|
||||
* Uses aggressive character whitelisting and dictionary constraints
|
||||
*/
|
||||
private async configurePuzzleOCR(): Promise<void> {
|
||||
if (!this.worker) return;
|
||||
|
||||
// Configure Tesseract for maximum constraint to our puzzle names
|
||||
await this.worker.setParameters({
|
||||
// Disable all system dictionaries to prevent interference
|
||||
load_system_dawg: '0',
|
||||
load_freq_dawg: '0',
|
||||
load_punc_dawg: '0',
|
||||
load_number_dawg: '0',
|
||||
load_unambig_dawg: '0',
|
||||
load_bigram_dawg: '0',
|
||||
load_fixed_length_dawgs: '0',
|
||||
|
||||
// Use only characters from our puzzle names
|
||||
tessedit_char_whitelist: this.getPuzzleCharacterSet(),
|
||||
|
||||
// Optimize for single words/short phrases
|
||||
tessedit_pageseg_mode: 8 as any, // Single word
|
||||
|
||||
// Increase penalties for non-dictionary words
|
||||
segment_penalty_dict_nonword: '2.0',
|
||||
segment_penalty_dict_frequent_word: '0.001',
|
||||
segment_penalty_dict_case_ok: '0.001',
|
||||
segment_penalty_dict_case_bad: '0.1',
|
||||
|
||||
// Make OCR more conservative about character recognition
|
||||
classify_enable_learning: '0',
|
||||
classify_enable_adaptive_matcher: '1',
|
||||
|
||||
// Preserve word boundaries
|
||||
preserve_interword_spaces: '1'
|
||||
});
|
||||
|
||||
console.log('OCR configured for puzzle names with character set:', this.getPuzzleCharacterSet());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get character set from available puzzle names for more accurate OCR (fallback)
|
||||
*/
|
||||
private getPuzzleCharacterSet(): string {
|
||||
if (this.availablePuzzleNames.length === 0) {
|
||||
// Fallback to common characters
|
||||
return 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 -'
|
||||
}
|
||||
|
||||
// Extract unique characters from all puzzle names
|
||||
const chars = new Set<string>()
|
||||
this.availablePuzzleNames.forEach(name => {
|
||||
for (const char of name) {
|
||||
chars.add(char)
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(chars).join('')
|
||||
}
|
||||
|
||||
async extractOpusMagnumData(imageFile: File): Promise<OpusMagnumData> {
|
||||
@ -64,6 +133,7 @@ export class OpusMagnumOCRService {
|
||||
|
||||
// Extract text from each region
|
||||
const results: Partial<OpusMagnumData> = {};
|
||||
const confidenceScores: Record<string, number> = {};
|
||||
|
||||
for (const [key, region] of Object.entries(this.regions)) {
|
||||
const regionCanvas = document.createElement('canvas');
|
||||
@ -96,10 +166,8 @@ export class OpusMagnumOCRService {
|
||||
tessedit_char_whitelist: '0123456789'
|
||||
});
|
||||
} else if (key === 'puzzle') {
|
||||
// Puzzle name - allow alphanumeric, spaces, and dashes
|
||||
await this.worker!.setParameters({
|
||||
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 -'
|
||||
});
|
||||
// Puzzle name - use user words file for better matching
|
||||
await this.configurePuzzleOCR();
|
||||
} else {
|
||||
// Default - allow all characters
|
||||
await this.worker!.setParameters({
|
||||
@ -108,8 +176,11 @@ export class OpusMagnumOCRService {
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
// Store the confidence score for this field
|
||||
confidenceScores[key] = confidence / 100; // Tesseract returns 0-100, we want 0-1
|
||||
|
||||
// Post-process based on field type
|
||||
if (key === 'cost') {
|
||||
@ -130,20 +201,42 @@ export class OpusMagnumOCRService {
|
||||
// Ensure only digits remain
|
||||
cleanText = cleanText.replace(/[^0-9]/g, '');
|
||||
} 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);
|
||||
|
||||
// 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);
|
||||
|
||||
// Calculate overall confidence as the average of all field confidences
|
||||
const confidenceValues = Object.values(confidenceScores);
|
||||
const overallConfidence = confidenceValues.length > 0
|
||||
? confidenceValues.reduce((sum, conf) => sum + conf, 0) / confidenceValues.length
|
||||
: 0;
|
||||
|
||||
resolve({
|
||||
puzzle: results.puzzle || '',
|
||||
cost: results.cost || '',
|
||||
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) {
|
||||
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 {
|
||||
if (!this.availablePuzzleNames.length) {
|
||||
@ -210,31 +303,155 @@ export class OpusMagnumOCRService {
|
||||
}
|
||||
|
||||
const cleanedOcr = ocrText.trim();
|
||||
if (!cleanedOcr) return '';
|
||||
|
||||
// First try exact match (case insensitive)
|
||||
// Strategy 1: Exact match (case insensitive)
|
||||
const exactMatch = this.availablePuzzleNames.find(
|
||||
name => name.toLowerCase() === cleanedOcr.toLowerCase()
|
||||
);
|
||||
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 bestScore = Infinity;
|
||||
let bestScore = 0;
|
||||
|
||||
for (const puzzleName of this.availablePuzzleNames) {
|
||||
// Calculate similarity scores
|
||||
const distance = this.levenshteinDistance(
|
||||
cleanedOcr.toLowerCase(),
|
||||
puzzleName.toLowerCase()
|
||||
);
|
||||
const scores = [
|
||||
this.calculateLevenshteinSimilarity(cleanedOcr, puzzleName),
|
||||
this.calculateJaroWinklerSimilarity(cleanedOcr, puzzleName),
|
||||
this.calculateNGramSimilarity(cleanedOcr, puzzleName, 2)
|
||||
];
|
||||
|
||||
// Normalize by length to get a similarity ratio
|
||||
const maxLength = Math.max(cleanedOcr.length, puzzleName.length);
|
||||
const similarity = 1 - (distance / maxLength);
|
||||
// Use the maximum score from all algorithms
|
||||
const maxScore = Math.max(...scores);
|
||||
|
||||
// Consider it a good match if similarity is above 70%
|
||||
if (similarity > 0.7 && distance < bestScore) {
|
||||
bestScore = distance;
|
||||
// Lower threshold for better matching - force selection even with moderate confidence
|
||||
if (maxScore > bestScore && maxScore > 0.4) {
|
||||
bestScore = maxScore;
|
||||
bestMatch = puzzleName;
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 4: If no good match found, try character-based matching
|
||||
if (bestScore < 0.6) {
|
||||
const charMatch = this.findBestCharacterMatch(cleanedOcr);
|
||||
if (charMatch) {
|
||||
bestMatch = charMatch;
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Levenshtein similarity (normalized)
|
||||
*/
|
||||
private calculateLevenshteinSimilarity(str1: string, str2: string): number {
|
||||
const distance = this.levenshteinDistance(str1.toLowerCase(), str2.toLowerCase());
|
||||
const maxLength = Math.max(str1.length, str2.length);
|
||||
return maxLength === 0 ? 1 : 1 - (distance / maxLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Jaro-Winkler similarity
|
||||
*/
|
||||
private calculateJaroWinklerSimilarity(str1: string, str2: string): number {
|
||||
const s1 = str1.toLowerCase();
|
||||
const s2 = str2.toLowerCase();
|
||||
|
||||
if (s1 === s2) return 1;
|
||||
|
||||
const matchWindow = Math.floor(Math.max(s1.length, s2.length) / 2) - 1;
|
||||
if (matchWindow < 0) return 0;
|
||||
|
||||
const s1Matches = new Array(s1.length).fill(false);
|
||||
const s2Matches = new Array(s2.length).fill(false);
|
||||
|
||||
let matches = 0;
|
||||
let transpositions = 0;
|
||||
|
||||
// Find matches
|
||||
for (let i = 0; i < s1.length; i++) {
|
||||
const start = Math.max(0, i - matchWindow);
|
||||
const end = Math.min(i + matchWindow + 1, s2.length);
|
||||
|
||||
for (let j = start; j < end; j++) {
|
||||
if (s2Matches[j] || s1[i] !== s2[j]) continue;
|
||||
s1Matches[i] = true;
|
||||
s2Matches[j] = true;
|
||||
matches++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matches === 0) return 0;
|
||||
|
||||
// Count transpositions
|
||||
let k = 0;
|
||||
for (let i = 0; i < s1.length; i++) {
|
||||
if (!s1Matches[i]) continue;
|
||||
while (!s2Matches[k]) k++;
|
||||
if (s1[i] !== s2[k]) transpositions++;
|
||||
k++;
|
||||
}
|
||||
|
||||
const jaro = (matches / s1.length + matches / s2.length + (matches - transpositions / 2) / matches) / 3;
|
||||
|
||||
// Jaro-Winkler bonus for common prefix
|
||||
let prefix = 0;
|
||||
for (let i = 0; i < Math.min(s1.length, s2.length, 4); i++) {
|
||||
if (s1[i] === s2[i]) prefix++;
|
||||
else break;
|
||||
}
|
||||
|
||||
return jaro + (0.1 * prefix * (1 - jaro));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate N-gram similarity
|
||||
*/
|
||||
private calculateNGramSimilarity(str1: string, str2: string, n: number): number {
|
||||
const s1 = str1.toLowerCase();
|
||||
const s2 = str2.toLowerCase();
|
||||
|
||||
if (s1 === s2) return 1;
|
||||
if (s1.length < n || s2.length < n) return 0;
|
||||
|
||||
const ngrams1 = new Set<string>();
|
||||
const ngrams2 = new Set<string>();
|
||||
|
||||
for (let i = 0; i <= s1.length - n; i++) {
|
||||
ngrams1.add(s1.substr(i, n));
|
||||
}
|
||||
|
||||
for (let i = 0; i <= s2.length - n; i++) {
|
||||
ngrams2.add(s2.substr(i, n));
|
||||
}
|
||||
|
||||
const intersection = new Set([...ngrams1].filter(x => ngrams2.has(x)));
|
||||
const union = new Set([...ngrams1, ...ngrams2]);
|
||||
|
||||
return intersection.size / union.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find best match based on character frequency
|
||||
*/
|
||||
private findBestCharacterMatch(ocrText: string): string | null {
|
||||
let bestMatch = null;
|
||||
let bestScore = 0;
|
||||
|
||||
for (const puzzleName of this.availablePuzzleNames) {
|
||||
const score = this.calculateCharacterFrequencyScore(ocrText.toLowerCase(), puzzleName.toLowerCase());
|
||||
if (score > bestScore && score > 0.3) {
|
||||
bestScore = score;
|
||||
bestMatch = puzzleName;
|
||||
}
|
||||
}
|
||||
@ -242,6 +459,90 @@ export class OpusMagnumOCRService {
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate character frequency similarity
|
||||
*/
|
||||
private calculateCharacterFrequencyScore(str1: string, str2: string): number {
|
||||
const freq1 = new Map<string, number>();
|
||||
const freq2 = new Map<string, number>();
|
||||
|
||||
for (const char of str1) {
|
||||
freq1.set(char, (freq1.get(char) || 0) + 1);
|
||||
}
|
||||
|
||||
for (const char of str2) {
|
||||
freq2.set(char, (freq2.get(char) || 0) + 1);
|
||||
}
|
||||
|
||||
const allChars = new Set([...freq1.keys(), ...freq2.keys()]);
|
||||
let similarity = 0;
|
||||
let totalChars = 0;
|
||||
|
||||
for (const char of allChars) {
|
||||
const count1 = freq1.get(char) || 0;
|
||||
const count2 = freq2.get(char) || 0;
|
||||
similarity += Math.min(count1, count2);
|
||||
totalChars += Math.max(count1, count2);
|
||||
}
|
||||
|
||||
return totalChars === 0 ? 0 : similarity / totalChars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a match to available puzzle names - always returns a puzzle name
|
||||
* This is used as a last resort to ensure OCR always selects from available puzzles
|
||||
*/
|
||||
private findBestPuzzleMatchForced(ocrText: string): string | null {
|
||||
if (!this.availablePuzzleNames.length || !ocrText.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cleanedOcr = ocrText.trim().toLowerCase();
|
||||
let bestMatch = this.availablePuzzleNames[0]; // Default to first puzzle
|
||||
let bestScore = 0;
|
||||
|
||||
// Try all matching algorithms and pick the best overall score
|
||||
for (const puzzleName of this.availablePuzzleNames) {
|
||||
const scores = [
|
||||
this.calculateLevenshteinSimilarity(cleanedOcr, puzzleName),
|
||||
this.calculateJaroWinklerSimilarity(cleanedOcr, puzzleName),
|
||||
this.calculateNGramSimilarity(cleanedOcr, puzzleName, 2),
|
||||
this.calculateCharacterFrequencyScore(cleanedOcr, puzzleName.toLowerCase()),
|
||||
// Add length similarity bonus
|
||||
this.calculateLengthSimilarity(cleanedOcr, puzzleName.toLowerCase())
|
||||
];
|
||||
|
||||
// Use weighted average with emphasis on character frequency and length
|
||||
const weightedScore = (
|
||||
scores[0] * 0.25 + // Levenshtein
|
||||
scores[1] * 0.25 + // Jaro-Winkler
|
||||
scores[2] * 0.2 + // N-gram
|
||||
scores[3] * 0.2 + // Character frequency
|
||||
scores[4] * 0.1 // Length similarity
|
||||
);
|
||||
|
||||
if (weightedScore > bestScore) {
|
||||
bestScore = weightedScore;
|
||||
bestMatch = puzzleName;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Forced match for "${ocrText}": "${bestMatch}" (score: ${bestScore.toFixed(3)})`);
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate similarity based on string length
|
||||
*/
|
||||
private calculateLengthSimilarity(str1: string, str2: string): number {
|
||||
const len1 = str1.length;
|
||||
const len2 = str2.length;
|
||||
const maxLen = Math.max(len1, len2);
|
||||
const minLen = Math.min(len1, len2);
|
||||
|
||||
return maxLen === 0 ? 1 : minLen / maxLen;
|
||||
}
|
||||
|
||||
|
||||
async terminate(): Promise<void> {
|
||||
if (this.worker) {
|
||||
|
||||
3
opus_submitter/src/stores/index.ts
Normal file
3
opus_submitter/src/stores/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
export const pinia = createPinia()
|
||||
78
opus_submitter/src/stores/puzzles.ts
Normal file
78
opus_submitter/src/stores/puzzles.ts
Normal 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
|
||||
}
|
||||
})
|
||||
100
opus_submitter/src/stores/submissions.ts
Normal file
100
opus_submitter/src/stores/submissions.ts
Normal 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
|
||||
}
|
||||
})
|
||||
@ -29,6 +29,13 @@ export interface OpusMagnumData {
|
||||
cost: string
|
||||
cycles: string
|
||||
area: string
|
||||
confidence: {
|
||||
puzzle: number
|
||||
cost: number
|
||||
cycles: number
|
||||
area: number
|
||||
overall: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface SubmissionFile {
|
||||
@ -39,6 +46,8 @@ export interface SubmissionFile {
|
||||
ocrProcessing?: boolean
|
||||
ocrError?: string
|
||||
original_filename?: string
|
||||
manualPuzzleSelection?: string
|
||||
needsManualPuzzleSelection?: boolean
|
||||
}
|
||||
|
||||
export interface PuzzleResponse {
|
||||
@ -49,7 +58,9 @@ export interface PuzzleResponse {
|
||||
cycles?: string
|
||||
area?: string
|
||||
needs_manual_validation?: boolean
|
||||
ocr_confidence_score?: number
|
||||
ocr_confidence_cost?: number
|
||||
ocr_confidence_cycles?: number
|
||||
ocr_confidence_area?: number
|
||||
validated_cost?: string
|
||||
validated_cycles?: string
|
||||
validated_area?: string
|
||||
@ -69,6 +80,7 @@ export interface Submission {
|
||||
is_validated?: boolean
|
||||
validated_by?: number | null
|
||||
validated_at?: string | null
|
||||
manual_validation_requested?: boolean
|
||||
total_responses?: number
|
||||
needs_validation?: boolean
|
||||
created_at?: string
|
||||
|
||||
21
opus_submitter/static_source/vite/assets/main-B14l8Jy0.js
Normal file
21
opus_submitter/static_source/vite/assets/main-B14l8Jy0.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -16,12 +16,12 @@
|
||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2"
|
||||
},
|
||||
"src/main.ts": {
|
||||
"file": "assets/main-CNlI4PW6.js",
|
||||
"file": "assets/main-B14l8Jy0.js",
|
||||
"name": "main",
|
||||
"src": "src/main.ts",
|
||||
"isEntry": true,
|
||||
"css": [
|
||||
"assets/main-HDjkw-xK.css"
|
||||
"assets/main-COx9N9qO.css"
|
||||
],
|
||||
"assets": [
|
||||
"assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
||||
|
||||
@ -2,8 +2,12 @@ from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.utils import timezone
|
||||
from .models import (
|
||||
SteamAPIKey, SteamCollection, SteamCollectionItem,
|
||||
Submission, PuzzleResponse, SubmissionFile
|
||||
SteamAPIKey,
|
||||
SteamCollection,
|
||||
SteamCollectionItem,
|
||||
Submission,
|
||||
PuzzleResponse,
|
||||
SubmissionFile,
|
||||
)
|
||||
|
||||
|
||||
@ -151,7 +155,14 @@ class SubmissionFileInline(admin.TabularInline):
|
||||
model = SubmissionFile
|
||||
extra = 0
|
||||
readonly_fields = ["file_size", "content_type", "ocr_processed", "created_at"]
|
||||
fields = ["file", "original_filename", "file_size", "content_type", "ocr_processed", "ocr_error"]
|
||||
fields = [
|
||||
"file",
|
||||
"original_filename",
|
||||
"file_size",
|
||||
"content_type",
|
||||
"ocr_processed",
|
||||
"ocr_error",
|
||||
]
|
||||
|
||||
|
||||
class PuzzleResponseInline(admin.TabularInline):
|
||||
@ -159,43 +170,73 @@ class PuzzleResponseInline(admin.TabularInline):
|
||||
extra = 0
|
||||
readonly_fields = ["created_at", "updated_at"]
|
||||
fields = [
|
||||
"puzzle", "puzzle_name", "cost", "cycles", "area",
|
||||
"needs_manual_validation", "ocr_confidence_score"
|
||||
"puzzle",
|
||||
"puzzle_name",
|
||||
"cost",
|
||||
"cycles",
|
||||
"area",
|
||||
"needs_manual_validation",
|
||||
"ocr_confidence_cost",
|
||||
"ocr_confidence_cycles",
|
||||
"ocr_confidence_area",
|
||||
]
|
||||
|
||||
|
||||
@admin.register(Submission)
|
||||
class SubmissionAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id", "user", "total_responses", "needs_validation",
|
||||
"is_validated", "created_at"
|
||||
"id",
|
||||
"user",
|
||||
"total_responses",
|
||||
"needs_validation",
|
||||
"manual_validation_requested",
|
||||
"is_validated",
|
||||
"created_at",
|
||||
]
|
||||
list_filter = [
|
||||
"is_validated", "created_at", "updated_at"
|
||||
"is_validated",
|
||||
"manual_validation_requested",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
search_fields = ["id", "user__username", "notes"]
|
||||
readonly_fields = ["id", "created_at", "updated_at", "total_responses", "needs_validation"]
|
||||
readonly_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"total_responses",
|
||||
"needs_validation",
|
||||
]
|
||||
inlines = [PuzzleResponseInline]
|
||||
|
||||
|
||||
fieldsets = (
|
||||
("Basic Information", {
|
||||
"fields": ("id", "user", "notes")
|
||||
}),
|
||||
("Validation", {
|
||||
"fields": ("is_validated", "validated_by", "validated_at")
|
||||
}),
|
||||
("Statistics", {
|
||||
"fields": ("total_responses", "needs_validation"),
|
||||
"classes": ("collapse",)
|
||||
}),
|
||||
("Timestamps", {
|
||||
"fields": ("created_at", "updated_at"),
|
||||
"classes": ("collapse",)
|
||||
}),
|
||||
("Basic Information", {"fields": ("id", "user", "notes")}),
|
||||
(
|
||||
"Validation",
|
||||
{
|
||||
"fields": (
|
||||
"manual_validation_requested",
|
||||
"is_validated",
|
||||
"validated_by",
|
||||
"validated_at",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Statistics",
|
||||
{
|
||||
"fields": ("total_responses", "needs_validation"),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Timestamps",
|
||||
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
actions = ["mark_as_validated"]
|
||||
|
||||
|
||||
def mark_as_validated(self, request, queryset):
|
||||
"""Mark selected submissions as validated"""
|
||||
updated = 0
|
||||
@ -208,59 +249,82 @@ class SubmissionAdmin(admin.ModelAdmin):
|
||||
# Also mark all responses as not needing validation
|
||||
submission.responses.update(needs_manual_validation=False)
|
||||
updated += 1
|
||||
|
||||
|
||||
self.message_user(request, f"{updated} submissions marked as validated.")
|
||||
|
||||
|
||||
mark_as_validated.short_description = "Mark selected submissions as validated"
|
||||
|
||||
|
||||
@admin.register(PuzzleResponse)
|
||||
class PuzzleResponseAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"puzzle_name", "submission", "puzzle", "cost", "cycles", "area",
|
||||
"needs_manual_validation", "created_at"
|
||||
]
|
||||
list_filter = [
|
||||
"needs_manual_validation", "puzzle__collection", "created_at"
|
||||
"puzzle_name",
|
||||
"submission",
|
||||
"puzzle",
|
||||
"cost",
|
||||
"cycles",
|
||||
"area",
|
||||
"needs_manual_validation",
|
||||
"created_at",
|
||||
]
|
||||
list_filter = ["needs_manual_validation", "puzzle__collection", "created_at"]
|
||||
search_fields = [
|
||||
"puzzle_name", "submission__id", "puzzle__title",
|
||||
"cost", "cycles", "area"
|
||||
"puzzle_name",
|
||||
"submission__id",
|
||||
"puzzle__title",
|
||||
"cost",
|
||||
"cycles",
|
||||
"area",
|
||||
]
|
||||
readonly_fields = ["created_at", "updated_at"]
|
||||
inlines = [SubmissionFileInline]
|
||||
|
||||
|
||||
fieldsets = (
|
||||
("Basic Information", {
|
||||
"fields": ("submission", "puzzle", "puzzle_name")
|
||||
}),
|
||||
("OCR Data", {
|
||||
"fields": ("cost", "cycles", "area", "ocr_confidence_score")
|
||||
}),
|
||||
("Validation", {
|
||||
"fields": (
|
||||
"needs_manual_validation",
|
||||
"validated_cost", "validated_cycles", "validated_area"
|
||||
)
|
||||
}),
|
||||
("Timestamps", {
|
||||
"fields": ("created_at", "updated_at"),
|
||||
"classes": ("collapse",)
|
||||
}),
|
||||
("Basic Information", {"fields": ("submission", "puzzle", "puzzle_name")}),
|
||||
(
|
||||
"OCR Data",
|
||||
{
|
||||
"fields": (
|
||||
"cost",
|
||||
"cycles",
|
||||
"area",
|
||||
"ocr_confidence_cost",
|
||||
"ocr_confidence_cycles",
|
||||
"ocr_confidence_area",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Validation",
|
||||
{
|
||||
"fields": (
|
||||
"needs_manual_validation",
|
||||
"validated_cost",
|
||||
"validated_cycles",
|
||||
"validated_area",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Timestamps",
|
||||
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
actions = ["mark_for_validation", "clear_validation_flag"]
|
||||
|
||||
|
||||
def mark_for_validation(self, request, queryset):
|
||||
"""Mark selected responses as needing validation"""
|
||||
updated = queryset.update(needs_manual_validation=True)
|
||||
self.message_user(request, f"{updated} responses marked for validation.")
|
||||
|
||||
|
||||
def clear_validation_flag(self, request, queryset):
|
||||
"""Clear validation flag for selected responses"""
|
||||
updated = queryset.update(needs_manual_validation=False)
|
||||
self.message_user(request, f"{updated} responses cleared from validation queue.")
|
||||
|
||||
self.message_user(
|
||||
request, f"{updated} responses cleared from validation queue."
|
||||
)
|
||||
|
||||
mark_for_validation.short_description = "Mark as needing validation"
|
||||
clear_validation_flag.short_description = "Clear validation flag"
|
||||
|
||||
@ -268,37 +332,49 @@ class PuzzleResponseAdmin(admin.ModelAdmin):
|
||||
@admin.register(SubmissionFile)
|
||||
class SubmissionFileAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"original_filename", "response", "file_size_display",
|
||||
"content_type", "ocr_processed", "created_at"
|
||||
]
|
||||
list_filter = [
|
||||
"content_type", "ocr_processed", "created_at"
|
||||
"original_filename",
|
||||
"response",
|
||||
"file_size_display",
|
||||
"content_type",
|
||||
"ocr_processed",
|
||||
"created_at",
|
||||
]
|
||||
list_filter = ["content_type", "ocr_processed", "created_at"]
|
||||
search_fields = [
|
||||
"original_filename", "response__puzzle_name",
|
||||
"response__submission__id"
|
||||
"original_filename",
|
||||
"response__puzzle_name",
|
||||
"response__submission__id",
|
||||
]
|
||||
readonly_fields = [
|
||||
"file_size", "content_type", "ocr_processed",
|
||||
"created_at", "updated_at", "file_url"
|
||||
"file_size",
|
||||
"content_type",
|
||||
"ocr_processed",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"file_url",
|
||||
]
|
||||
|
||||
|
||||
fieldsets = (
|
||||
("File Information", {
|
||||
"fields": ("file", "original_filename", "file_size", "content_type", "file_url")
|
||||
}),
|
||||
("OCR Processing", {
|
||||
"fields": ("ocr_processed", "ocr_raw_data", "ocr_error")
|
||||
}),
|
||||
("Relationships", {
|
||||
"fields": ("response",)
|
||||
}),
|
||||
("Timestamps", {
|
||||
"fields": ("created_at", "updated_at"),
|
||||
"classes": ("collapse",)
|
||||
}),
|
||||
(
|
||||
"File Information",
|
||||
{
|
||||
"fields": (
|
||||
"file",
|
||||
"original_filename",
|
||||
"file_size",
|
||||
"content_type",
|
||||
"file_url",
|
||||
)
|
||||
},
|
||||
),
|
||||
("OCR Processing", {"fields": ("ocr_processed", "ocr_raw_data", "ocr_error")}),
|
||||
("Relationships", {"fields": ("response",)}),
|
||||
(
|
||||
"Timestamps",
|
||||
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def file_size_display(self, obj):
|
||||
"""Display file size in human readable format"""
|
||||
if obj.file_size < 1024:
|
||||
@ -307,5 +383,5 @@ class SubmissionFileAdmin(admin.ModelAdmin):
|
||||
return f"{obj.file_size / 1024:.1f} KB"
|
||||
else:
|
||||
return f"{obj.file_size / (1024 * 1024):.1f} MB"
|
||||
|
||||
|
||||
file_size_display.short_description = "File Size"
|
||||
|
||||
@ -23,16 +23,20 @@ router = Router()
|
||||
@router.get("/puzzles", response=List[SteamCollectionItemOut])
|
||||
def list_puzzles(request):
|
||||
"""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])
|
||||
@paginate
|
||||
def list_submissions(request):
|
||||
"""Get paginated list of submissions"""
|
||||
return Submission.objects.prefetch_related(
|
||||
"responses__files", "responses__puzzle"
|
||||
).filter(user=request.user)
|
||||
return (
|
||||
Submission.objects.prefetch_related("responses__files", "responses__puzzle")
|
||||
.filter(user=request.user)
|
||||
.filter()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/submissions/{submission_id}", response=SubmissionOut)
|
||||
@ -65,10 +69,29 @@ def create_submission(
|
||||
|
||||
try:
|
||||
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
|
||||
submission = Submission.objects.create(
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
notes=data.notes,
|
||||
manual_validation_requested=data.manual_validation_requested
|
||||
or auto_request_validation,
|
||||
)
|
||||
|
||||
file_index = 0
|
||||
@ -89,8 +112,10 @@ def create_submission(
|
||||
cost=response_data.cost,
|
||||
cycles=response_data.cycles,
|
||||
area=response_data.area,
|
||||
needs_manual_validation=response_data.needs_manual_validation,
|
||||
ocr_confidence_score=response_data.ocr_confidence_score,
|
||||
needs_manual_validation=data.manual_validation_requested,
|
||||
ocr_confidence_cost=response_data.ocr_confidence_cost,
|
||||
ocr_confidence_cycles=response_data.ocr_confidence_cycles,
|
||||
ocr_confidence_area=response_data.ocr_confidence_area,
|
||||
)
|
||||
|
||||
# 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:
|
||||
return 403, {"detail": "Admin access required"}
|
||||
|
||||
try:
|
||||
response = PuzzleResponse.objects.select_related("puzzle").get(id=response_id)
|
||||
response = get_object_or_404(PuzzleResponse, id=response_id)
|
||||
|
||||
# Update validated values
|
||||
if data.validated_cost is not None:
|
||||
response.validated_cost = data.validated_cost
|
||||
if data.validated_cycles is not None:
|
||||
response.validated_cycles = data.validated_cycles
|
||||
if data.validated_area is not None:
|
||||
response.validated_area = data.validated_area
|
||||
if data.puzzle is not None:
|
||||
puzzle = get_object_or_404(SteamCollectionItem, id=data.puzzle)
|
||||
response.puzzle = puzzle
|
||||
|
||||
# Mark as no longer needing validation if we have all values
|
||||
if all([response.final_cost, response.final_cycles, response.final_area]):
|
||||
response.needs_manual_validation = False
|
||||
# Update validated values
|
||||
if data.validated_cost is not None:
|
||||
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:
|
||||
raise Http404("Response not found")
|
||||
# Mark as no longer needing validation if we have all values
|
||||
if all([response.final_cost, response.final_cycles, response.final_area]):
|
||||
response.needs_manual_validation = False
|
||||
|
||||
response.save()
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/responses/needs-validation", response=List[PuzzleResponseOut])
|
||||
@ -181,6 +208,7 @@ def list_responses_needing_validation(request):
|
||||
|
||||
return (
|
||||
PuzzleResponse.objects.filter(needs_manual_validation=True)
|
||||
.filter(puzzle__collection__is_active=True)
|
||||
.select_related("puzzle", "submission")
|
||||
.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:
|
||||
return 403, {"detail": "Admin access required"}
|
||||
|
||||
try:
|
||||
submission = Submission.objects.get(id=submission_id)
|
||||
submission = get_object_or_404(Submission, id=submission_id)
|
||||
|
||||
submission.is_validated = True
|
||||
submission.validated_by = request.user
|
||||
submission.validated_at = timezone.now()
|
||||
submission.save()
|
||||
submission.is_validated = True
|
||||
submission.validated_by = request.user
|
||||
submission.validated_at = timezone.now()
|
||||
submission.save()
|
||||
|
||||
# Also mark all responses as not needing validation
|
||||
submission.responses.update(needs_manual_validation=False)
|
||||
# Also mark all responses as not needing validation
|
||||
submission.responses.update(needs_manual_validation=False)
|
||||
|
||||
# Reload with relations
|
||||
submission = Submission.objects.prefetch_related(
|
||||
"responses__files", "responses__puzzle"
|
||||
).get(id=submission.id)
|
||||
# Reload with relations
|
||||
submission = Submission.objects.prefetch_related(
|
||||
"responses__files", "responses__puzzle"
|
||||
).get(id=submission.id)
|
||||
|
||||
return submission
|
||||
|
||||
except Submission.DoesNotExist:
|
||||
raise Http404("Submission not found")
|
||||
return submission
|
||||
|
||||
|
||||
@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:
|
||||
return 403, {"detail": "Admin access required"}
|
||||
|
||||
try:
|
||||
submission = Submission.objects.get(id=submission_id)
|
||||
submission.delete()
|
||||
return {"detail": "Submission deleted successfully"}
|
||||
|
||||
except Submission.DoesNotExist:
|
||||
raise Http404("Submission not found")
|
||||
submission = get_object_or_404(Submission, id=submission_id)
|
||||
submission.delete()
|
||||
return {"detail": "Submission deleted successfully"}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
@ -247,7 +267,7 @@ def get_stats(request):
|
||||
"total_responses": total_responses,
|
||||
"needs_validation": needs_validation,
|
||||
"validated_submissions": validated_submissions,
|
||||
"validation_rate": validated_submissions / total_submissions
|
||||
if total_submissions > 0
|
||||
"validation_rate": (total_responses - needs_validation) / total_responses
|
||||
if total_responses
|
||||
else 0,
|
||||
}
|
||||
|
||||
@ -2,5 +2,5 @@ from django.apps import AppConfig
|
||||
|
||||
|
||||
class SubmissionsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'submissions'
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "submissions"
|
||||
|
||||
@ -8,40 +8,39 @@ from submissions.models import SteamCollection
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Fetch Steam Workshop collection data and save to database'
|
||||
|
||||
help = "Fetch Steam Workshop collection data and save to database"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("url", type=str, help="Steam Workshop collection URL")
|
||||
parser.add_argument(
|
||||
'url',
|
||||
"--api-key",
|
||||
type=str,
|
||||
help='Steam Workshop collection URL'
|
||||
help="Steam API key (optional, can also be set via STEAM_API_KEY environment variable)",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--api-key',
|
||||
type=str,
|
||||
help='Steam API key (optional, can also be set via STEAM_API_KEY environment variable)'
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Force refetch even if collection already exists",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--force',
|
||||
action='store_true',
|
||||
help='Force refetch even if collection already exists'
|
||||
)
|
||||
|
||||
|
||||
def handle(self, *args, **options):
|
||||
url = options['url']
|
||||
api_key = options.get('api_key')
|
||||
force = options['force']
|
||||
|
||||
url = options["url"]
|
||||
api_key = options.get("api_key")
|
||||
force = options["force"]
|
||||
|
||||
self.stdout.write(f"Fetching Steam collection from: {url}")
|
||||
|
||||
|
||||
try:
|
||||
# Check if collection already exists
|
||||
from submissions.utils import SteamCollectionFetcher
|
||||
|
||||
fetcher = SteamCollectionFetcher(api_key)
|
||||
collection_id = fetcher.extract_collection_id(url)
|
||||
|
||||
|
||||
if collection_id and not force:
|
||||
existing = SteamCollection.objects.filter(steam_id=collection_id).first()
|
||||
existing = SteamCollection.objects.filter(
|
||||
steam_id=collection_id
|
||||
).first()
|
||||
if existing:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
@ -50,10 +49,10 @@ class Command(BaseCommand):
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
# Fetch and create/update collection
|
||||
collection, created = create_or_update_collection(url)
|
||||
|
||||
|
||||
if created:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
@ -66,27 +65,33 @@ class Command(BaseCommand):
|
||||
f"Successfully updated collection: {collection.title} (ID: {collection.id})"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Display collection info
|
||||
self.stdout.write("\nCollection Details:")
|
||||
self.stdout.write(f" Steam ID: {collection.steam_id}")
|
||||
self.stdout.write(f" Title: {collection.title}")
|
||||
self.stdout.write(f" Author: {collection.author_name or 'Unknown'}")
|
||||
self.stdout.write(f" Description: {collection.description[:100]}{'...' if len(collection.description) > 100 else ''}")
|
||||
self.stdout.write(
|
||||
f" Description: {collection.description[:100]}{'...' if len(collection.description) > 100 else ''}"
|
||||
)
|
||||
self.stdout.write(f" Total Items: {collection.total_items}")
|
||||
self.stdout.write(f" Unique Visitors: {collection.unique_visitors}")
|
||||
self.stdout.write(f" Current Favorites: {collection.current_favorites}")
|
||||
self.stdout.write(f" Total Favorites: {collection.total_favorites}")
|
||||
|
||||
|
||||
if collection.items.exists():
|
||||
self.stdout.write(f"\nCollection Items ({collection.items.count()}):")
|
||||
for item in collection.items.all()[:10]: # Show first 10 items
|
||||
self.stdout.write(f" - {item.title} (Steam ID: {item.steam_item_id})")
|
||||
|
||||
self.stdout.write(
|
||||
f" - {item.title} (Steam ID: {item.steam_item_id})"
|
||||
)
|
||||
|
||||
if collection.items.count() > 10:
|
||||
self.stdout.write(f" ... and {collection.items.count() - 10} more items")
|
||||
self.stdout.write(
|
||||
f" ... and {collection.items.count() - 10} more items"
|
||||
)
|
||||
else:
|
||||
self.stdout.write("\nNo items found in collection.")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
raise CommandError(f"Failed to fetch collection: {e}")
|
||||
|
||||
@ -5,68 +5,215 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Collection',
|
||||
name="Collection",
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('url', models.URLField()),
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("url", models.URLField()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SteamCollection',
|
||||
name="SteamCollection",
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('steam_id', models.CharField(help_text='Steam collection ID from URL', max_length=50, unique=True)),
|
||||
('url', models.URLField(help_text='Full Steam Workshop collection URL')),
|
||||
('title', models.CharField(blank=True, help_text='Collection title', max_length=255)),
|
||||
('description', models.TextField(blank=True, help_text='Collection description')),
|
||||
('author_name', models.CharField(blank=True, help_text='Steam username of collection creator', max_length=100)),
|
||||
('author_steam_id', models.CharField(blank=True, help_text='Steam ID of collection creator', max_length=50)),
|
||||
('total_items', models.PositiveIntegerField(default=0, help_text='Number of items in collection')),
|
||||
('unique_visitors', models.PositiveIntegerField(default=0, help_text='Number of unique visitors')),
|
||||
('current_favorites', models.PositiveIntegerField(default=0, help_text='Current number of favorites')),
|
||||
('total_favorites', models.PositiveIntegerField(default=0, help_text='Total unique favorites')),
|
||||
('steam_created_date', models.DateTimeField(blank=True, help_text='When collection was created on Steam', null=True)),
|
||||
('steam_updated_date', models.DateTimeField(blank=True, help_text='When collection was last updated on Steam', null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('last_fetched', models.DateTimeField(blank=True, help_text='When data was last fetched from Steam', null=True)),
|
||||
('is_active', models.BooleanField(default=True, help_text='Whether this collection is actively tracked')),
|
||||
('fetch_error', models.TextField(blank=True, help_text='Last error encountered when fetching data')),
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"steam_id",
|
||||
models.CharField(
|
||||
help_text="Steam collection ID from URL",
|
||||
max_length=50,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"url",
|
||||
models.URLField(help_text="Full Steam Workshop collection URL"),
|
||||
),
|
||||
(
|
||||
"title",
|
||||
models.CharField(
|
||||
blank=True, help_text="Collection title", max_length=255
|
||||
),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
models.TextField(blank=True, help_text="Collection description"),
|
||||
),
|
||||
(
|
||||
"author_name",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Steam username of collection creator",
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
(
|
||||
"author_steam_id",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Steam ID of collection creator",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
(
|
||||
"total_items",
|
||||
models.PositiveIntegerField(
|
||||
default=0, help_text="Number of items in collection"
|
||||
),
|
||||
),
|
||||
(
|
||||
"unique_visitors",
|
||||
models.PositiveIntegerField(
|
||||
default=0, help_text="Number of unique visitors"
|
||||
),
|
||||
),
|
||||
(
|
||||
"current_favorites",
|
||||
models.PositiveIntegerField(
|
||||
default=0, help_text="Current number of favorites"
|
||||
),
|
||||
),
|
||||
(
|
||||
"total_favorites",
|
||||
models.PositiveIntegerField(
|
||||
default=0, help_text="Total unique favorites"
|
||||
),
|
||||
),
|
||||
(
|
||||
"steam_created_date",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="When collection was created on Steam",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"steam_updated_date",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="When collection was last updated on Steam",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"last_fetched",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="When data was last fetched from Steam",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_active",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text="Whether this collection is actively tracked",
|
||||
),
|
||||
),
|
||||
(
|
||||
"fetch_error",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="Last error encountered when fetching data",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Steam Collection',
|
||||
'verbose_name_plural': 'Steam Collections',
|
||||
'ordering': ['-created_at'],
|
||||
"verbose_name": "Steam Collection",
|
||||
"verbose_name_plural": "Steam Collections",
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SteamCollectionItem',
|
||||
name="SteamCollectionItem",
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('steam_item_id', models.CharField(help_text='Steam Workshop item ID', max_length=50)),
|
||||
('title', models.CharField(blank=True, help_text='Item title', max_length=255)),
|
||||
('author_name', models.CharField(blank=True, help_text='Steam username of item creator', max_length=100)),
|
||||
('author_steam_id', models.CharField(blank=True, help_text='Steam ID of item creator', max_length=50)),
|
||||
('description', models.TextField(blank=True, help_text='Item description')),
|
||||
('tags', models.JSONField(blank=True, default=list, help_text='Item tags as JSON array')),
|
||||
('order_index', models.PositiveIntegerField(default=0, help_text='Order of item in collection')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='submissions.steamcollection')),
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"steam_item_id",
|
||||
models.CharField(help_text="Steam Workshop item ID", max_length=50),
|
||||
),
|
||||
(
|
||||
"title",
|
||||
models.CharField(
|
||||
blank=True, help_text="Item title", max_length=255
|
||||
),
|
||||
),
|
||||
(
|
||||
"author_name",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Steam username of item creator",
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
(
|
||||
"author_steam_id",
|
||||
models.CharField(
|
||||
blank=True, help_text="Steam ID of item creator", max_length=50
|
||||
),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
models.TextField(blank=True, help_text="Item description"),
|
||||
),
|
||||
(
|
||||
"tags",
|
||||
models.JSONField(
|
||||
blank=True, default=list, help_text="Item tags as JSON array"
|
||||
),
|
||||
),
|
||||
(
|
||||
"order_index",
|
||||
models.PositiveIntegerField(
|
||||
default=0, help_text="Order of item in collection"
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"collection",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="items",
|
||||
to="submissions.steamcollection",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Steam Collection Item',
|
||||
'verbose_name_plural': 'Steam Collection Items',
|
||||
'ordering': ['collection', 'order_index'],
|
||||
'unique_together': {('collection', 'steam_item_id')},
|
||||
"verbose_name": "Steam Collection Item",
|
||||
"verbose_name_plural": "Steam Collection Items",
|
||||
"ordering": ["collection", "order_index"],
|
||||
"unique_together": {("collection", "steam_item_id")},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@ -4,13 +4,12 @@ from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('submissions', '0001_initial'),
|
||||
("submissions", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='Collection',
|
||||
name="Collection",
|
||||
),
|
||||
]
|
||||
|
||||
@ -4,28 +4,66 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('submissions', '0002_delete_collection'),
|
||||
("submissions", "0002_delete_collection"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SteamAPIKey',
|
||||
name="SteamAPIKey",
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text="Descriptive name for this API key (e.g., 'Production Key', 'Development Key')", max_length=100, unique=True)),
|
||||
('api_key', models.CharField(help_text='Steam Web API key from https://steamcommunity.com/dev/apikey', max_length=64)),
|
||||
('is_active', models.BooleanField(default=True, help_text='Whether this API key should be used')),
|
||||
('description', models.TextField(blank=True, help_text='Optional description or notes about this API key')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('last_used', models.DateTimeField(blank=True, help_text='When this API key was last used', null=True)),
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"name",
|
||||
models.CharField(
|
||||
help_text="Descriptive name for this API key (e.g., 'Production Key', 'Development Key')",
|
||||
max_length=100,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"api_key",
|
||||
models.CharField(
|
||||
help_text="Steam Web API key from https://steamcommunity.com/dev/apikey",
|
||||
max_length=64,
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_active",
|
||||
models.BooleanField(
|
||||
default=True, help_text="Whether this API key should be used"
|
||||
),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="Optional description or notes about this API key",
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"last_used",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="When this API key was last used",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Steam API Key',
|
||||
'verbose_name_plural': 'Steam API Keys',
|
||||
'ordering': ['-is_active', 'name'],
|
||||
"verbose_name": "Steam API Key",
|
||||
"verbose_name_plural": "Steam API Keys",
|
||||
"ordering": ["-is_active", "name"],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@ -8,75 +8,245 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('submissions', '0003_steamapikey'),
|
||||
("submissions", "0003_steamapikey"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Submission',
|
||||
name="Submission",
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('notes', models.TextField(blank=True, help_text='Optional notes about the submission')),
|
||||
('is_validated', models.BooleanField(default=False, help_text='Whether this submission has been manually validated')),
|
||||
('validated_at', models.DateTimeField(blank=True, help_text='When this submission was validated', null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.ForeignKey(blank=True, help_text='User who made the submission (null for anonymous)', null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
('validated_by', models.ForeignKey(blank=True, help_text='Admin user who validated this submission', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='validated_submissions', to=settings.AUTH_USER_MODEL)),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"notes",
|
||||
models.TextField(
|
||||
blank=True, help_text="Optional notes about the submission"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_validated",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this submission has been manually validated",
|
||||
),
|
||||
),
|
||||
(
|
||||
"validated_at",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="When this submission was validated",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="User who made the submission (null for anonymous)",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"validated_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Admin user who validated this submission",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="validated_submissions",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Submission',
|
||||
'verbose_name_plural': 'Submissions',
|
||||
'ordering': ['-created_at'],
|
||||
"verbose_name": "Submission",
|
||||
"verbose_name_plural": "Submissions",
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PuzzleResponse',
|
||||
name="PuzzleResponse",
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('puzzle_name', models.CharField(help_text='Puzzle name as detected by OCR', max_length=255)),
|
||||
('cost', models.CharField(blank=True, help_text='Cost value from OCR', max_length=20)),
|
||||
('cycles', models.CharField(blank=True, help_text='Cycles value from OCR', max_length=20)),
|
||||
('area', models.CharField(blank=True, help_text='Area value from OCR', max_length=20)),
|
||||
('needs_manual_validation', models.BooleanField(default=False, help_text='Whether OCR failed and manual validation is needed')),
|
||||
('ocr_confidence_score', models.FloatField(blank=True, help_text='OCR confidence score (0.0 to 1.0)', null=True)),
|
||||
('validated_cost', models.CharField(blank=True, help_text='Manually validated cost value', max_length=20)),
|
||||
('validated_cycles', models.CharField(blank=True, help_text='Manually validated cycles value', max_length=20)),
|
||||
('validated_area', models.CharField(blank=True, help_text='Manually validated area value', max_length=20)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('puzzle', models.ForeignKey(help_text='The puzzle this response is for', on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='submissions.steamcollectionitem')),
|
||||
('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='submissions.submission')),
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"puzzle_name",
|
||||
models.CharField(
|
||||
help_text="Puzzle name as detected by OCR", max_length=255
|
||||
),
|
||||
),
|
||||
(
|
||||
"cost",
|
||||
models.CharField(
|
||||
blank=True, help_text="Cost value from OCR", max_length=20
|
||||
),
|
||||
),
|
||||
(
|
||||
"cycles",
|
||||
models.CharField(
|
||||
blank=True, help_text="Cycles value from OCR", max_length=20
|
||||
),
|
||||
),
|
||||
(
|
||||
"area",
|
||||
models.CharField(
|
||||
blank=True, help_text="Area value from OCR", max_length=20
|
||||
),
|
||||
),
|
||||
(
|
||||
"needs_manual_validation",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether OCR failed and manual validation is needed",
|
||||
),
|
||||
),
|
||||
(
|
||||
"ocr_confidence_score",
|
||||
models.FloatField(
|
||||
blank=True,
|
||||
help_text="OCR confidence score (0.0 to 1.0)",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"validated_cost",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Manually validated cost value",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"validated_cycles",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Manually validated cycles value",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"validated_area",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Manually validated area value",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"puzzle",
|
||||
models.ForeignKey(
|
||||
help_text="The puzzle this response is for",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="responses",
|
||||
to="submissions.steamcollectionitem",
|
||||
),
|
||||
),
|
||||
(
|
||||
"submission",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="responses",
|
||||
to="submissions.submission",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Puzzle Response',
|
||||
'verbose_name_plural': 'Puzzle Responses',
|
||||
'ordering': ['submission', 'puzzle__order_index'],
|
||||
'unique_together': {('submission', 'puzzle')},
|
||||
"verbose_name": "Puzzle Response",
|
||||
"verbose_name_plural": "Puzzle Responses",
|
||||
"ordering": ["submission", "puzzle__order_index"],
|
||||
"unique_together": {("submission", "puzzle")},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SubmissionFile',
|
||||
name="SubmissionFile",
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('file', models.FileField(help_text='Uploaded file (image/gif)', upload_to=submissions.models.submission_file_upload_path)),
|
||||
('original_filename', models.CharField(help_text='Original filename as uploaded by user', max_length=255)),
|
||||
('file_size', models.PositiveIntegerField(help_text='File size in bytes')),
|
||||
('content_type', models.CharField(help_text='MIME type of the file', max_length=100)),
|
||||
('ocr_processed', models.BooleanField(default=False, help_text='Whether OCR has been processed for this file')),
|
||||
('ocr_raw_data', models.JSONField(blank=True, help_text='Raw OCR data as JSON', null=True)),
|
||||
('ocr_error', models.TextField(blank=True, help_text='OCR processing error message')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('response', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='submissions.puzzleresponse')),
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"file",
|
||||
models.FileField(
|
||||
help_text="Uploaded file (image/gif)",
|
||||
upload_to=submissions.models.submission_file_upload_path,
|
||||
),
|
||||
),
|
||||
(
|
||||
"original_filename",
|
||||
models.CharField(
|
||||
help_text="Original filename as uploaded by user",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
(
|
||||
"file_size",
|
||||
models.PositiveIntegerField(help_text="File size in bytes"),
|
||||
),
|
||||
(
|
||||
"content_type",
|
||||
models.CharField(help_text="MIME type of the file", max_length=100),
|
||||
),
|
||||
(
|
||||
"ocr_processed",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether OCR has been processed for this file",
|
||||
),
|
||||
),
|
||||
(
|
||||
"ocr_raw_data",
|
||||
models.JSONField(
|
||||
blank=True, help_text="Raw OCR data as JSON", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"ocr_error",
|
||||
models.TextField(
|
||||
blank=True, help_text="OCR processing error message"
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"response",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="files",
|
||||
to="submissions.puzzleresponse",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Submission File',
|
||||
'verbose_name_plural': 'Submission Files',
|
||||
'ordering': ['response', 'created_at'],
|
||||
"verbose_name": "Submission File",
|
||||
"verbose_name_plural": "Submission Files",
|
||||
"ordering": ["response", "created_at"],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@ -4,15 +4,16 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('submissions', '0004_submission_puzzleresponse_submissionfile'),
|
||||
("submissions", "0004_submission_puzzleresponse_submissionfile"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='submission',
|
||||
name='notes',
|
||||
field=models.TextField(blank=True, help_text='Optional notes about the submission', null=True),
|
||||
model_name="submission",
|
||||
name="notes",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="Optional notes about the submission", null=True
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@ -0,0 +1,43 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-30 10:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("submissions", "0005_alter_submission_notes"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="puzzleresponse",
|
||||
name="ocr_confidence_score",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="puzzleresponse",
|
||||
name="ocr_confidence_area",
|
||||
field=models.FloatField(
|
||||
blank=True,
|
||||
help_text="OCR confidence score for area (0.0 to 1.0)",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="puzzleresponse",
|
||||
name="ocr_confidence_cost",
|
||||
field=models.FloatField(
|
||||
blank=True,
|
||||
help_text="OCR confidence score for cost (0.0 to 1.0)",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="puzzleresponse",
|
||||
name="ocr_confidence_cycles",
|
||||
field=models.FloatField(
|
||||
blank=True,
|
||||
help_text="OCR confidence score for cycles (0.0 to 1.0)",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-30 11:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("submissions", "0006_remove_puzzleresponse_ocr_confidence_score_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="submission",
|
||||
name="manual_validation_requested",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether the user specifically requested manual validation",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,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(),
|
||||
),
|
||||
]
|
||||
@ -1,9 +1,7 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from django.core.exceptions import ValidationError
|
||||
import uuid
|
||||
import os
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@ -243,6 +241,12 @@ class Submission(models.Model):
|
||||
null=True, blank=True, help_text="When this submission was validated"
|
||||
)
|
||||
|
||||
# Manual validation request
|
||||
manual_validation_requested = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether the user specifically requested manual validation",
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
@ -295,8 +299,15 @@ class PuzzleResponse(models.Model):
|
||||
needs_manual_validation = models.BooleanField(
|
||||
default=False, help_text="Whether OCR failed and manual validation is needed"
|
||||
)
|
||||
ocr_confidence_score = models.FloatField(
|
||||
null=True, blank=True, help_text="OCR confidence score (0.0 to 1.0)"
|
||||
|
||||
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
|
||||
@ -316,7 +327,6 @@ class PuzzleResponse(models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ["submission", "puzzle__order_index"]
|
||||
unique_together = ["submission", "puzzle"]
|
||||
verbose_name = "Puzzle Response"
|
||||
verbose_name_plural = "Puzzle Responses"
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
from ninja import Schema, ModelSchema, File
|
||||
from ninja.files import UploadedFile
|
||||
from ninja import Schema, ModelSchema
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
@ -25,13 +24,16 @@ class PuzzleResponseIn(Schema):
|
||||
cycles: Optional[str] = None
|
||||
area: Optional[str] = None
|
||||
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):
|
||||
"""Schema for creating a submission"""
|
||||
|
||||
notes: Optional[str] = None
|
||||
manual_validation_requested: bool = False
|
||||
responses: List[PuzzleResponseIn]
|
||||
|
||||
|
||||
@ -73,7 +75,9 @@ class PuzzleResponseOut(ModelSchema):
|
||||
"cycles",
|
||||
"area",
|
||||
"needs_manual_validation",
|
||||
"ocr_confidence_score",
|
||||
"ocr_confidence_cost",
|
||||
"ocr_confidence_cycles",
|
||||
"ocr_confidence_area",
|
||||
"validated_cost",
|
||||
"validated_cycles",
|
||||
"validated_area",
|
||||
@ -98,6 +102,7 @@ class SubmissionOut(ModelSchema):
|
||||
"is_validated",
|
||||
"validated_by",
|
||||
"validated_at",
|
||||
"manual_validation_requested",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
@ -120,6 +125,7 @@ class SubmissionListOut(Schema):
|
||||
class ValidationIn(Schema):
|
||||
"""Schema for manual validation input"""
|
||||
|
||||
puzzle: Optional[int] = None
|
||||
validated_cost: Optional[str] = None
|
||||
validated_cycles: Optional[str] = None
|
||||
validated_area: Optional[str] = None
|
||||
|
||||
@ -1,3 +1 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
|
||||
@ -4,6 +4,7 @@ Utilities for fetching Steam Workshop collection data using Steam Web API
|
||||
|
||||
import re
|
||||
import requests
|
||||
from submissions.models import SteamCollection
|
||||
from datetime import datetime
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
@ -20,22 +21,28 @@ class SteamAPIClient:
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None):
|
||||
# Priority: parameter > database > settings > environment
|
||||
self.api_key = api_key or self._get_api_key_from_db() or getattr(settings, "STEAM_API_KEY", None)
|
||||
self.api_key = (
|
||||
api_key
|
||||
or self._get_api_key_from_db()
|
||||
or getattr(settings, "STEAM_API_KEY", None)
|
||||
)
|
||||
self.session = requests.Session()
|
||||
|
||||
if not self.api_key:
|
||||
logger.warning("No Steam API key provided. Some features may be limited.")
|
||||
|
||||
|
||||
def _get_api_key_from_db(self) -> Optional[str]:
|
||||
"""Get active API key from database"""
|
||||
try:
|
||||
from .models import SteamAPIKey
|
||||
|
||||
api_key_obj = SteamAPIKey.get_active_key()
|
||||
if api_key_obj:
|
||||
# Update last_used timestamp
|
||||
from django.utils import timezone
|
||||
|
||||
api_key_obj.last_used = timezone.now()
|
||||
api_key_obj.save(update_fields=['last_used'])
|
||||
api_key_obj.save(update_fields=["last_used"])
|
||||
return api_key_obj.api_key
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not fetch API key from database: {e}")
|
||||
@ -234,55 +241,59 @@ class SteamCollectionFetcher:
|
||||
def _fetch_collection_items_via_api(self, collection_id: str) -> List[Dict]:
|
||||
"""
|
||||
Fetch collection items using GetCollectionDetails API
|
||||
|
||||
|
||||
Args:
|
||||
collection_id: Steam collection ID
|
||||
|
||||
|
||||
Returns:
|
||||
List of item dictionaries
|
||||
"""
|
||||
items = []
|
||||
|
||||
|
||||
try:
|
||||
# Use GetCollectionDetails API to get collection items
|
||||
url = f"{self.api_client.BASE_URL}/ISteamRemoteStorage/GetCollectionDetails/v1/"
|
||||
data = {
|
||||
'collectioncount': 1,
|
||||
'publishedfileids[0]': collection_id
|
||||
}
|
||||
|
||||
data = {"collectioncount": 1, "publishedfileids[0]": collection_id}
|
||||
|
||||
response = self.api_client.session.post(url, data=data, timeout=30)
|
||||
if response.status_code == 200:
|
||||
collection_response = response.json()
|
||||
|
||||
if 'response' in collection_response and 'collectiondetails' in collection_response['response']:
|
||||
for collection in collection_response['response']['collectiondetails']:
|
||||
if collection.get('result') == 1 and 'children' in collection:
|
||||
|
||||
if (
|
||||
"response" in collection_response
|
||||
and "collectiondetails" in collection_response["response"]
|
||||
):
|
||||
for collection in collection_response["response"][
|
||||
"collectiondetails"
|
||||
]:
|
||||
if collection.get("result") == 1 and "children" in collection:
|
||||
# Extract item IDs with their sort order
|
||||
child_items = []
|
||||
for child in collection['children']:
|
||||
if 'publishedfileid' in child:
|
||||
child_items.append({
|
||||
'id': str(child['publishedfileid']),
|
||||
'sort_order': child.get('sortorder', 0)
|
||||
})
|
||||
|
||||
for child in collection["children"]:
|
||||
if "publishedfileid" in child:
|
||||
child_items.append(
|
||||
{
|
||||
"id": str(child["publishedfileid"]),
|
||||
"sort_order": child.get("sortorder", 0),
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by sort order to maintain collection order
|
||||
child_items.sort(key=lambda x: x['sort_order'])
|
||||
item_ids = [item['id'] for item in child_items]
|
||||
|
||||
child_items.sort(key=lambda x: x["sort_order"])
|
||||
item_ids = [item["id"] for item in child_items]
|
||||
|
||||
if item_ids:
|
||||
items = self._fetch_items_by_ids(item_ids)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch collection items via API: {e}")
|
||||
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _fetch_items_by_ids(self, item_ids: List[str]) -> List[Dict]:
|
||||
"""Fetch item details by their IDs"""
|
||||
items = []
|
||||
|
||||
|
||||
# Fetch details for all items in batches (Steam API has limits)
|
||||
batch_size = 20 # Conservative batch size
|
||||
for i in range(0, len(item_ids), batch_size):
|
||||
@ -300,7 +311,7 @@ class SteamCollectionFetcher:
|
||||
):
|
||||
item_id = item_data.get("publishedfileid", "unknown")
|
||||
result = item_data.get("result", 0)
|
||||
|
||||
|
||||
if result == 1: # Success
|
||||
item_info = {
|
||||
"steam_item_id": str(item_id),
|
||||
@ -333,14 +344,15 @@ class SteamCollectionFetcher:
|
||||
items.append(item_info)
|
||||
else:
|
||||
# Log failed items
|
||||
logger.warning(f"Failed to fetch item {item_id}: result={result}, ban_reason={item_data.get('ban_reason', 'N/A')}")
|
||||
logger.warning(
|
||||
f"Failed to fetch item {item_id}: result={result}, ban_reason={item_data.get('ban_reason', 'N/A')}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch batch of collection items: {e}")
|
||||
continue
|
||||
|
||||
return items
|
||||
|
||||
|
||||
|
||||
def fetch_steam_collection(url: str) -> Dict:
|
||||
@ -357,7 +369,7 @@ def fetch_steam_collection(url: str) -> Dict:
|
||||
return fetcher.fetch_collection_data(url)
|
||||
|
||||
|
||||
def create_or_update_collection(url: str) -> Tuple["SteamCollection", bool]:
|
||||
def create_or_update_collection(url: str) -> Tuple[SteamCollection, bool]:
|
||||
"""
|
||||
Create or update a Steam collection in the database
|
||||
|
||||
|
||||
@ -1,3 +1 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
|
||||
@ -10,6 +10,6 @@
|
||||
{% vite_asset 'src/main.ts' %}
|
||||
</head>
|
||||
<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>
|
||||
</html>
|
||||
|
||||
@ -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"}
|
||||
@ -23,6 +23,7 @@ dev = [
|
||||
"django-types>=0.22.0",
|
||||
"ipython>=8.37.0",
|
||||
"pre-commit>=4.3.0",
|
||||
"pyjwt>=2.10.1",
|
||||
"pyright>=1.1.407",
|
||||
"ruff>=0.14.2",
|
||||
]
|
||||
|
||||
11
uv.lock
11
uv.lock
@ -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 = "9.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "pre-commit" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "pyright" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
@ -447,6 +448,7 @@ dev = [
|
||||
{ name = "django-types", specifier = ">=0.22.0" },
|
||||
{ name = "ipython", specifier = ">=8.37.0" },
|
||||
{ name = "pre-commit", specifier = ">=4.3.0" },
|
||||
{ name = "pyjwt", specifier = ">=2.10.1" },
|
||||
{ name = "pyright", specifier = ">=1.1.407" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pyright"
|
||||
version = "1.1.407"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user