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