Compare commits
No commits in common. "f98145d6db67c5b08a2e7ab84fa99b210222b082" and "6a882ce39a77e8dda5eb10d5e45428b0df5763be" have entirely different histories.
f98145d6db
...
6a882ce39a
231
opus_submitter/API_USAGE.md
Normal file
231
opus_submitter/API_USAGE.md
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
# 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,28 +9,24 @@ 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', {
|
||||||
"CAS Information",
|
'fields': ('cas_user_id', 'cas_groups', 'cas_attributes'),
|
||||||
{
|
}),
|
||||||
"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,131 +7,41 @@ 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')),
|
||||||
"id",
|
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||||
models.BigAutoField(
|
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||||
auto_created=True,
|
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||||
primary_key=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')),
|
||||||
serialize=False,
|
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||||
verbose_name="ID",
|
('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')),
|
||||||
("password", models.CharField(max_length=128, verbose_name="password")),
|
('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')),
|
||||||
"last_login",
|
('cas_user_id', models.CharField(blank=True, max_length=50, null=True, unique=True)),
|
||||||
models.DateTimeField(
|
('cas_groups', models.JSONField(blank=True, default=list)),
|
||||||
blank=True, null=True, verbose_name="last login"
|
('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')),
|
||||||
(
|
|
||||||
"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,5 +1,6 @@
|
|||||||
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):
|
||||||
@ -56,3 +57,4 @@ class CustomUser(AbstractUser):
|
|||||||
self.cas_attributes = attributes
|
self.cas_attributes = attributes
|
||||||
|
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
|||||||
@ -1 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
# Create your tests here.
|
# Create your tests here.
|
||||||
|
|||||||
@ -1 +1,3 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
# Create your views here.
|
# Create your views here.
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
#!/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:
|
||||||
@ -19,5 +18,5 @@ def main():
|
|||||||
execute_from_command_line(sys.argv)
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
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
|
||||||
|
|
||||||
@ -57,7 +58,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 * # noqa
|
from opus_submitter.settingsLocal import *
|
||||||
|
|||||||
@ -28,15 +28,7 @@ from .api import api
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def home(request: HttpRequest):
|
def home(request: HttpRequest):
|
||||||
from submissions.models import SteamCollection
|
return render(request, "index.html", {})
|
||||||
|
|
||||||
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,7 +12,6 @@
|
|||||||
"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,9 +14,6 @@ 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
|
||||||
@ -483,15 +480,6 @@ 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:
|
||||||
@ -531,16 +519,9 @@ 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==}
|
||||||
|
|
||||||
@ -584,9 +565,6 @@ 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==}
|
||||||
|
|
||||||
@ -600,10 +578,6 @@ 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
|
||||||
@ -681,9 +655,6 @@ 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==}
|
||||||
|
|
||||||
@ -708,9 +679,6 @@ 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==}
|
||||||
|
|
||||||
@ -718,15 +686,6 @@ 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}
|
||||||
@ -734,9 +693,6 @@ 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'}
|
||||||
@ -746,14 +702,6 @@ 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==}
|
||||||
|
|
||||||
@ -1155,24 +1103,6 @@ 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
|
||||||
@ -1216,14 +1146,8 @@ 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: {}
|
||||||
@ -1277,8 +1201,6 @@ 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: {}
|
||||||
@ -1287,8 +1209,6 @@ 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:
|
||||||
@ -1344,8 +1264,6 @@ 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: {}
|
||||||
@ -1358,19 +1276,10 @@ 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
|
||||||
@ -1379,8 +1288,6 @@ 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
|
||||||
@ -1411,12 +1318,6 @@ 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,13 +14,11 @@ 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("?")[
|
service_url = request.build_absolute_uri().split('?')[0] # Remove query params
|
||||||
0
|
|
||||||
] # Remove query params
|
|
||||||
|
|
||||||
user = authenticate(request=request, ticket=ticket, service=service_url)
|
user = authenticate(request=request, ticket=ticket, service=service_url)
|
||||||
|
|
||||||
@ -32,9 +30,7 @@ class SimpleCASLoginView(View):
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
# No ticket - redirect to CAS
|
# No ticket - redirect to CAS
|
||||||
service_url = request.build_absolute_uri().split("?")[
|
service_url = request.build_absolute_uri().split('?')[0] # Remove query params
|
||||||
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,22 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed, defineProps } from 'vue'
|
import { ref, onMounted, computed } 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 { apiService, errorHelpers } from '@/services/apiService'
|
import { puzzleHelpers, submissionHelpers, errorHelpers, apiService } from './services/apiService'
|
||||||
import { usePuzzlesStore } from '@/stores/puzzles'
|
import type { SteamCollection, SteamCollectionItem, Submission, PuzzleResponse, UserInfo } from './types'
|
||||||
import { useSubmissionsStore } from '@/stores/submissions'
|
|
||||||
import type { SteamCollection, PuzzleResponse, UserInfo } from '@/types'
|
|
||||||
|
|
||||||
const props = defineProps<{collectionTitle: string, collectionUrl: string, collectionDescription: string}>()
|
// API data
|
||||||
|
const collections = ref<SteamCollection[]>([])
|
||||||
// Pinia stores
|
const puzzles = ref<SteamCollectionItem[]>([])
|
||||||
const puzzlesStore = usePuzzlesStore()
|
const submissions = ref<Submission[]>([])
|
||||||
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
|
||||||
@ -29,7 +25,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[]> = {}
|
||||||
submissionsStore.submissions.forEach(submission => {
|
submissions.value.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
|
||||||
@ -59,15 +55,34 @@ onMounted(async () => {
|
|||||||
console.warn('User info error:', userResponse.error)
|
console.warn('User info error:', userResponse.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load puzzles from API using store
|
// Load puzzles from API
|
||||||
console.log('Loading puzzles...')
|
console.log('Loading puzzles...')
|
||||||
await puzzlesStore.loadPuzzles()
|
const loadedPuzzles = await puzzleHelpers.loadPuzzles()
|
||||||
console.log('Puzzles loaded:', puzzlesStore.puzzles.length)
|
puzzles.value = loadedPuzzles
|
||||||
|
console.log('Puzzles loaded:', loadedPuzzles.length)
|
||||||
|
|
||||||
// Load existing submissions using store
|
// Create mock collection from loaded puzzles for display
|
||||||
|
if (loadedPuzzles.length > 0) {
|
||||||
|
collections.value = [{
|
||||||
|
id: 1,
|
||||||
|
steam_id: '3479142989',
|
||||||
|
title: 'PolyLAN 41',
|
||||||
|
description: 'Puzzle collection for PolyLAN 41 fil rouge',
|
||||||
|
author_name: 'Flame Legrems',
|
||||||
|
total_items: loadedPuzzles.length,
|
||||||
|
unique_visitors: 31,
|
||||||
|
current_favorites: 1,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
}]
|
||||||
|
console.log('Collection created')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load existing submissions
|
||||||
console.log('Loading submissions...')
|
console.log('Loading submissions...')
|
||||||
await submissionsStore.loadSubmissions()
|
const loadedSubmissions = await submissionHelpers.loadSubmissions()
|
||||||
console.log('Submissions loaded:', submissionsStore.submissions.length)
|
submissions.value = loadedSubmissions
|
||||||
|
console.log('Submissions loaded:', loadedSubmissions.length)
|
||||||
|
|
||||||
console.log('Data load complete!')
|
console.log('Data load complete!')
|
||||||
|
|
||||||
@ -82,30 +97,36 @@ 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 store
|
// Create submission via API
|
||||||
const submission = await submissionsStore.createSubmission(
|
const response = await submissionHelpers.createFromFiles(
|
||||||
submissionData.files,
|
submissionData.files,
|
||||||
submissionData.notes,
|
puzzles.value,
|
||||||
submissionData.manualValidationRequested
|
submissionData.notes
|
||||||
)
|
)
|
||||||
|
|
||||||
// Show success message
|
if (response.error) {
|
||||||
if (submission) {
|
error.value = response.error
|
||||||
const puzzleNames = submission.responses.map(r => r.puzzle_name).join(', ')
|
alert(`Submission failed: ${response.error}`)
|
||||||
alert(`Solutions submitted successfully for puzzles: ${puzzleNames}`)
|
return
|
||||||
} else {
|
|
||||||
alert('Submission created successfully!')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close modal
|
if (response.data) {
|
||||||
submissionsStore.closeSubmissionModal()
|
// Add to local submissions list
|
||||||
|
submissions.value.unshift(response.data)
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
const puzzleNames = response.data.responses.map(r => r.puzzle_name).join(', ')
|
||||||
|
alert(`Solutions submitted successfully for puzzles: ${puzzleNames}`)
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
showSubmissionModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = errorHelpers.getErrorMessage(err)
|
const errorMessage = errorHelpers.getErrorMessage(err)
|
||||||
@ -118,16 +139,16 @@ const handleSubmission = async (submissionData: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openSubmissionModal = () => {
|
const openSubmissionModal = () => {
|
||||||
submissionsStore.openSubmissionModal()
|
showSubmissionModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeSubmissionModal = () => {
|
const closeSubmissionModal = () => {
|
||||||
submissionsStore.closeSubmissionModal()
|
showSubmissionModal.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to match puzzle name from OCR to actual puzzle
|
// Function to match puzzle name from OCR to actual puzzle
|
||||||
const findPuzzleByName = (ocrPuzzleName: string) => {
|
const findPuzzleByName = (ocrPuzzleName: string): SteamCollectionItem | null => {
|
||||||
return puzzlesStore.findPuzzleByName(ocrPuzzleName)
|
return puzzleHelpers.findPuzzleByName(puzzles.value, ocrPuzzleName)
|
||||||
}
|
}
|
||||||
|
|
||||||
const reloadPage = () => {
|
const reloadPage = () => {
|
||||||
@ -143,7 +164,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 items-start justify-between">
|
<div class="flex-none">
|
||||||
<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>
|
||||||
@ -153,11 +174,6 @@ 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>
|
||||||
@ -188,11 +204,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 class="mb-8">
|
<div v-if="collections.length > 0" 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">{{ props.collectionTitle }}</h2>
|
<h2 class="card-title text-2xl">{{ collections[0].title }}</h2>
|
||||||
<p class="text-base-content/70">{{ props.collectionDescription }}</p>
|
<p class="text-base-content/70">{{ collections[0].description }}</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"
|
||||||
@ -214,7 +230,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 puzzlesStore.puzzles"
|
v-for="puzzle in puzzles"
|
||||||
:key="puzzle.id"
|
:key="puzzle.id"
|
||||||
:puzzle="puzzle"
|
:puzzle="puzzle"
|
||||||
:responses="responsesByPuzzle[puzzle.id] || []"
|
:responses="responsesByPuzzle[puzzle.id] || []"
|
||||||
@ -222,7 +238,7 @@ const reloadPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div v-if="puzzlesStore.puzzles.length === 0" class="text-center py-12">
|
<div v-if="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>
|
||||||
@ -231,7 +247,7 @@ const reloadPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Submission Modal -->
|
<!-- Submission Modal -->
|
||||||
<div v-if="submissionsStore.isSubmissionModalOpen" class="modal modal-open">
|
<div v-if="showSubmissionModal" 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>
|
||||||
@ -244,7 +260,7 @@ const reloadPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SubmissionForm
|
<SubmissionForm
|
||||||
:puzzles="puzzlesStore.puzzles"
|
:puzzles="puzzles"
|
||||||
:find-puzzle-by-name="findPuzzleByName"
|
:find-puzzle-by-name="findPuzzleByName"
|
||||||
@submit="handleSubmission"
|
@submit="handleSubmission"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -43,46 +43,19 @@
|
|||||||
<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_title }}</div>
|
<div class="font-bold">{{ response.puzzle_name }}</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 class="flex justify-between items-center">
|
<div>Cost: {{ response.cost || '-' }}</div>
|
||||||
<span>Cost: {{ response.cost || '-' }}</span>
|
<div>Cycles: {{ response.cycles || '-' }}</div>
|
||||||
<span
|
<div>Area: {{ response.area || '-' }}</div>
|
||||||
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">
|
||||||
{{ getOverallConfidence(response) }}%
|
{{ response.ocr_confidence_score ? Math.round(response.ocr_confidence_score * 100) + '%' : 'Low' }}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@ -110,43 +83,19 @@
|
|||||||
|
|
||||||
<!-- 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 w-11/12 max-w-5xl">
|
<div class="modal-box">
|
||||||
<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_title }}</div>
|
<div class="font-bold">{{ validationModal.response.puzzle_name }}</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-4 gap-4">
|
<div class="grid grid-cols-3 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>
|
||||||
@ -203,10 +152,8 @@
|
|||||||
|
|
||||||
<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({
|
||||||
@ -225,7 +172,6 @@ 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: ''
|
||||||
@ -271,7 +217,6 @@ 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 || ''
|
||||||
@ -283,7 +228,6 @@ 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: ''
|
||||||
@ -329,26 +273,6 @@ 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,17 +83,7 @@
|
|||||||
|
|
||||||
<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"
|
||||||
@ -105,74 +95,19 @@
|
|||||||
<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
|
||||||
@ -207,8 +142,7 @@
|
|||||||
|
|
||||||
<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 {
|
||||||
@ -223,9 +157,6 @@ 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('')
|
||||||
@ -242,9 +173,10 @@ watch(files, (newFiles) => {
|
|||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
// Watch for puzzle changes and update OCR service
|
// Watch for puzzle changes and update OCR service
|
||||||
watch(() => puzzlesStore.puzzles, (newPuzzles) => {
|
watch(() => props.puzzles, (newPuzzles) => {
|
||||||
if (newPuzzles && newPuzzles.length > 0) {
|
if (newPuzzles && newPuzzles.length > 0) {
|
||||||
ocrService.setAvailablePuzzleNames(puzzlesStore.puzzleNames)
|
const puzzleNames = newPuzzles.map(puzzle => puzzle.title)
|
||||||
|
ocrService.setAvailablePuzzleNames(puzzleNames)
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
@ -362,15 +294,6 @@ 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)
|
||||||
@ -383,22 +306,4 @@ 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 ml-2">{{ data.files.length }} file(s)</span>
|
<span class="badge badge-ghost badge-sm">{{ data.files.length }} file(s)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -24,51 +24,18 @@
|
|||||||
<!-- 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">
|
||||||
<div class="flex-1">
|
<label class="label">
|
||||||
<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>
|
|
||||||
<textarea
|
|
||||||
v-model="notes"
|
|
||||||
class="flex textarea textarea-bordered h-24 w-full resize-none"
|
|
||||||
placeholder="Add any notes about your solution, approach, or interesting findings..."
|
|
||||||
maxlength="500"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Manual Validation Request -->
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label cursor-pointer justify-start gap-3">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
v-model="manualValidationRequested"
|
|
||||||
class="checkbox checkbox-primary"
|
|
||||||
/>
|
|
||||||
<div class="flex-1">
|
|
||||||
<span class="label-text font-medium">Request manual validation</span>
|
|
||||||
<div class="label-text-alt text-xs opacity-70 mt-1">
|
|
||||||
Check this if you want an admin to manually review your submission, even if OCR confidence is high.
|
|
||||||
<br>
|
|
||||||
<em>Note: This will be automatically checked if any OCR confidence is below 50%.</em>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="notes"
|
||||||
|
class="textarea textarea-bordered h-24 resize-none"
|
||||||
|
placeholder="Add any notes about your solution, approach, or interesting findings..."
|
||||||
|
maxlength="500"
|
||||||
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Submit Button -->
|
<!-- Submit Button -->
|
||||||
@ -76,14 +43,10 @@
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
:disabled="!canSubmit"
|
:disabled="isSubmitting"
|
||||||
>
|
>
|
||||||
<span v-if="isSubmitting" class="loading loading-spinner loading-sm"></span>
|
<span v-if="isSubmitting" class="loading loading-spinner loading-sm"></span>
|
||||||
<span v-if="isSubmitting">Submitting...</span>
|
{{ isSubmitting ? 'Submitting...' : 'Submit Solution' }}
|
||||||
<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>
|
||||||
@ -92,8 +55,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import FileUpload from '@/components/FileUpload.vue'
|
import FileUpload from './FileUpload.vue'
|
||||||
import type { SteamCollectionItem, SubmissionFile } from '@/types'
|
import type { SteamCollectionItem, SubmissionFile } from '@/types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -102,7 +65,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
submit: [submissionData: { files: SubmissionFile[], notes?: string, manualValidationRequested?: boolean }]
|
submit: [submissionData: { files: SubmissionFile[], notes?: string }]
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
@ -110,18 +73,13 @@ 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(() => {
|
||||||
const hasFiles = submissionFiles.value.length > 0
|
return submissionFiles.value.length > 0 &&
|
||||||
const noManualSelectionNeeded = !submissionFiles.value.some(file => file.needsManualPuzzleSelection)
|
!isSubmitting.value
|
||||||
|
|
||||||
return hasFiles &&
|
|
||||||
!isSubmitting.value &&
|
|
||||||
noManualSelectionNeeded
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Group files by detected puzzle
|
// Group files by detected puzzle
|
||||||
@ -129,10 +87,8 @@ 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 => {
|
||||||
// Use manual puzzle selection if available, otherwise fall back to OCR
|
if (file.ocrData?.puzzle) {
|
||||||
const puzzleName = file.manualPuzzleSelection || file.ocrData?.puzzle
|
const puzzleName = file.ocrData.puzzle
|
||||||
|
|
||||||
if (puzzleName) {
|
|
||||||
if (!grouped[puzzleName]) {
|
if (!grouped[puzzleName]) {
|
||||||
grouped[puzzleName] = {
|
grouped[puzzleName] = {
|
||||||
puzzle: props.findPuzzleByName(puzzleName),
|
puzzle: props.findPuzzleByName(puzzleName),
|
||||||
@ -146,28 +102,6 @@ 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
|
||||||
|
|
||||||
@ -177,14 +111,12 @@ 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,11 +1,5 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import App from '@/App.vue'
|
import App from '@/App.vue'
|
||||||
import { pinia } from '@/stores'
|
import './style.css'
|
||||||
import '@/style.css'
|
|
||||||
|
|
||||||
// const app = createApp(App)
|
createApp(App).mount('#app')
|
||||||
const selector = "#app"
|
|
||||||
const mountData = document.querySelector<HTMLElement>(selector)
|
|
||||||
const app = createApp(App, { ...mountData?.dataset })
|
|
||||||
app.use(pinia)
|
|
||||||
app.mount(selector)
|
|
||||||
|
|||||||
@ -115,7 +115,6 @@ 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
|
||||||
@ -123,9 +122,7 @@ export class ApiService {
|
|||||||
cycles?: string
|
cycles?: string
|
||||||
area?: string
|
area?: string
|
||||||
needs_manual_validation?: boolean
|
needs_manual_validation?: boolean
|
||||||
ocr_confidence_cost?: number
|
ocr_confidence_score?: number
|
||||||
ocr_confidence_cycles?: number
|
|
||||||
ocr_confidence_area?: number
|
|
||||||
}>
|
}>
|
||||||
},
|
},
|
||||||
files: File[]
|
files: File[]
|
||||||
@ -228,8 +225,7 @@ 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, {
|
||||||
@ -238,10 +234,8 @@ export const submissionHelpers = {
|
|||||||
}> = {}
|
}> = {}
|
||||||
|
|
||||||
files.forEach(file => {
|
files.forEach(file => {
|
||||||
// Use manual puzzle selection if available, otherwise fall back to OCR
|
if (file.ocrData?.puzzle) {
|
||||||
const puzzleName = file.manualPuzzleSelection || file.ocrData?.puzzle
|
const puzzleName = 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),
|
||||||
@ -274,9 +268,7 @@ 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_cost: fileWithOCR?.ocrData?.confidence?.cost || 0.0,
|
ocr_confidence_score: needsValidation ? 0.5 : 0.9 // Rough estimate
|
||||||
ocr_confidence_cycles: fileWithOCR?.ocrData?.confidence?.cycles || 0.0,
|
|
||||||
ocr_confidence_area: fileWithOCR?.ocrData?.confidence?.area || 0.0
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -290,11 +282,7 @@ 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({
|
return apiService.createSubmission({ notes, responses }, fileObjects)
|
||||||
notes,
|
|
||||||
manual_validation_requested: manualValidationRequested,
|
|
||||||
responses
|
|
||||||
}, fileObjects)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadSubmissions(limit = 20, offset = 0): Promise<Submission[]> {
|
async loadSubmissions(limit = 20, offset = 0): Promise<Submission[]> {
|
||||||
|
|||||||
@ -5,13 +5,6 @@ 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 {
|
||||||
@ -48,68 +41,6 @@ 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> {
|
||||||
@ -133,7 +64,6 @@ 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');
|
||||||
@ -166,8 +96,10 @@ export class OpusMagnumOCRService {
|
|||||||
tessedit_char_whitelist: '0123456789'
|
tessedit_char_whitelist: '0123456789'
|
||||||
});
|
});
|
||||||
} else if (key === 'puzzle') {
|
} else if (key === 'puzzle') {
|
||||||
// Puzzle name - use user words file for better matching
|
// Puzzle name - allow alphanumeric, spaces, and dashes
|
||||||
await this.configurePuzzleOCR();
|
await this.worker!.setParameters({
|
||||||
|
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 -'
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// Default - allow all characters
|
// Default - allow all characters
|
||||||
await this.worker!.setParameters({
|
await this.worker!.setParameters({
|
||||||
@ -176,12 +108,9 @@ export class OpusMagnumOCRService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Perform OCR on the region
|
// Perform OCR on the region
|
||||||
const { data: { text, confidence } } = await this.worker!.recognize(regionCanvas);
|
const { data: { text } } = 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
|
||||||
@ -201,42 +130,20 @@ 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 aggressive matching to force selection from available puzzles
|
// Post-process puzzle names with fuzzy matching
|
||||||
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 as any)[key] = cleanText;
|
results[key as keyof OpusMagnumData] = 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);
|
||||||
@ -295,7 +202,7 @@ export class OpusMagnumOCRService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the best matching puzzle name from available options using multiple strategies
|
* Find the best matching puzzle name from available options
|
||||||
*/
|
*/
|
||||||
private findBestPuzzleMatch(ocrText: string): string {
|
private findBestPuzzleMatch(ocrText: string): string {
|
||||||
if (!this.availablePuzzleNames.length) {
|
if (!this.availablePuzzleNames.length) {
|
||||||
@ -303,246 +210,38 @@ export class OpusMagnumOCRService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cleanedOcr = ocrText.trim();
|
const cleanedOcr = ocrText.trim();
|
||||||
if (!cleanedOcr) return '';
|
|
||||||
|
|
||||||
// Strategy 1: Exact match (case insensitive)
|
// First try 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;
|
||||||
|
|
||||||
// Strategy 2: Substring match (either direction)
|
// Then try fuzzy matching
|
||||||
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 = 0;
|
let bestScore = Infinity;
|
||||||
|
|
||||||
for (const puzzleName of this.availablePuzzleNames) {
|
for (const puzzleName of this.availablePuzzleNames) {
|
||||||
const scores = [
|
// Calculate similarity scores
|
||||||
this.calculateLevenshteinSimilarity(cleanedOcr, puzzleName),
|
const distance = this.levenshteinDistance(
|
||||||
this.calculateJaroWinklerSimilarity(cleanedOcr, puzzleName),
|
cleanedOcr.toLowerCase(),
|
||||||
this.calculateNGramSimilarity(cleanedOcr, puzzleName, 2)
|
puzzleName.toLowerCase()
|
||||||
];
|
|
||||||
|
|
||||||
// Use the maximum score from all algorithms
|
|
||||||
const maxScore = Math.max(...scores);
|
|
||||||
|
|
||||||
// Lower threshold for better matching - force selection even with moderate confidence
|
|
||||||
if (maxScore > bestScore && maxScore > 0.4) {
|
|
||||||
bestScore = maxScore;
|
|
||||||
bestMatch = puzzleName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 4: If no good match found, try character-based matching
|
|
||||||
if (bestScore < 0.6) {
|
|
||||||
const charMatch = this.findBestCharacterMatch(cleanedOcr);
|
|
||||||
if (charMatch) {
|
|
||||||
bestMatch = charMatch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bestMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate Levenshtein similarity (normalized)
|
|
||||||
*/
|
|
||||||
private calculateLevenshteinSimilarity(str1: string, str2: string): number {
|
|
||||||
const distance = this.levenshteinDistance(str1.toLowerCase(), str2.toLowerCase());
|
|
||||||
const maxLength = Math.max(str1.length, str2.length);
|
|
||||||
return maxLength === 0 ? 1 : 1 - (distance / maxLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate Jaro-Winkler similarity
|
|
||||||
*/
|
|
||||||
private calculateJaroWinklerSimilarity(str1: string, str2: string): number {
|
|
||||||
const s1 = str1.toLowerCase();
|
|
||||||
const s2 = str2.toLowerCase();
|
|
||||||
|
|
||||||
if (s1 === s2) return 1;
|
|
||||||
|
|
||||||
const matchWindow = Math.floor(Math.max(s1.length, s2.length) / 2) - 1;
|
|
||||||
if (matchWindow < 0) return 0;
|
|
||||||
|
|
||||||
const s1Matches = new Array(s1.length).fill(false);
|
|
||||||
const s2Matches = new Array(s2.length).fill(false);
|
|
||||||
|
|
||||||
let matches = 0;
|
|
||||||
let transpositions = 0;
|
|
||||||
|
|
||||||
// Find matches
|
|
||||||
for (let i = 0; i < s1.length; i++) {
|
|
||||||
const start = Math.max(0, i - matchWindow);
|
|
||||||
const end = Math.min(i + matchWindow + 1, s2.length);
|
|
||||||
|
|
||||||
for (let j = start; j < end; j++) {
|
|
||||||
if (s2Matches[j] || s1[i] !== s2[j]) continue;
|
|
||||||
s1Matches[i] = true;
|
|
||||||
s2Matches[j] = true;
|
|
||||||
matches++;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matches === 0) return 0;
|
|
||||||
|
|
||||||
// Count transpositions
|
|
||||||
let k = 0;
|
|
||||||
for (let i = 0; i < s1.length; i++) {
|
|
||||||
if (!s1Matches[i]) continue;
|
|
||||||
while (!s2Matches[k]) k++;
|
|
||||||
if (s1[i] !== s2[k]) transpositions++;
|
|
||||||
k++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const jaro = (matches / s1.length + matches / s2.length + (matches - transpositions / 2) / matches) / 3;
|
|
||||||
|
|
||||||
// Jaro-Winkler bonus for common prefix
|
|
||||||
let prefix = 0;
|
|
||||||
for (let i = 0; i < Math.min(s1.length, s2.length, 4); i++) {
|
|
||||||
if (s1[i] === s2[i]) prefix++;
|
|
||||||
else break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return jaro + (0.1 * prefix * (1 - jaro));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate N-gram similarity
|
|
||||||
*/
|
|
||||||
private calculateNGramSimilarity(str1: string, str2: string, n: number): number {
|
|
||||||
const s1 = str1.toLowerCase();
|
|
||||||
const s2 = str2.toLowerCase();
|
|
||||||
|
|
||||||
if (s1 === s2) return 1;
|
|
||||||
if (s1.length < n || s2.length < n) return 0;
|
|
||||||
|
|
||||||
const ngrams1 = new Set<string>();
|
|
||||||
const ngrams2 = new Set<string>();
|
|
||||||
|
|
||||||
for (let i = 0; i <= s1.length - n; i++) {
|
|
||||||
ngrams1.add(s1.substr(i, n));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i <= s2.length - n; i++) {
|
|
||||||
ngrams2.add(s2.substr(i, n));
|
|
||||||
}
|
|
||||||
|
|
||||||
const intersection = new Set([...ngrams1].filter(x => ngrams2.has(x)));
|
|
||||||
const union = new Set([...ngrams1, ...ngrams2]);
|
|
||||||
|
|
||||||
return intersection.size / union.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find best match based on character frequency
|
|
||||||
*/
|
|
||||||
private findBestCharacterMatch(ocrText: string): string | null {
|
|
||||||
let bestMatch = null;
|
|
||||||
let bestScore = 0;
|
|
||||||
|
|
||||||
for (const puzzleName of this.availablePuzzleNames) {
|
|
||||||
const score = this.calculateCharacterFrequencyScore(ocrText.toLowerCase(), puzzleName.toLowerCase());
|
|
||||||
if (score > bestScore && score > 0.3) {
|
|
||||||
bestScore = score;
|
|
||||||
bestMatch = puzzleName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bestMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate character frequency similarity
|
|
||||||
*/
|
|
||||||
private calculateCharacterFrequencyScore(str1: string, str2: string): number {
|
|
||||||
const freq1 = new Map<string, number>();
|
|
||||||
const freq2 = new Map<string, number>();
|
|
||||||
|
|
||||||
for (const char of str1) {
|
|
||||||
freq1.set(char, (freq1.get(char) || 0) + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const char of str2) {
|
|
||||||
freq2.set(char, (freq2.get(char) || 0) + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const allChars = new Set([...freq1.keys(), ...freq2.keys()]);
|
|
||||||
let similarity = 0;
|
|
||||||
let totalChars = 0;
|
|
||||||
|
|
||||||
for (const char of allChars) {
|
|
||||||
const count1 = freq1.get(char) || 0;
|
|
||||||
const count2 = freq2.get(char) || 0;
|
|
||||||
similarity += Math.min(count1, count2);
|
|
||||||
totalChars += Math.max(count1, count2);
|
|
||||||
}
|
|
||||||
|
|
||||||
return totalChars === 0 ? 0 : similarity / totalChars;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Force a match to available puzzle names - always returns a puzzle name
|
|
||||||
* This is used as a last resort to ensure OCR always selects from available puzzles
|
|
||||||
*/
|
|
||||||
private findBestPuzzleMatchForced(ocrText: string): string | null {
|
|
||||||
if (!this.availablePuzzleNames.length || !ocrText.trim()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanedOcr = ocrText.trim().toLowerCase();
|
|
||||||
let bestMatch = this.availablePuzzleNames[0]; // Default to first puzzle
|
|
||||||
let bestScore = 0;
|
|
||||||
|
|
||||||
// Try all matching algorithms and pick the best overall score
|
|
||||||
for (const puzzleName of this.availablePuzzleNames) {
|
|
||||||
const scores = [
|
|
||||||
this.calculateLevenshteinSimilarity(cleanedOcr, puzzleName),
|
|
||||||
this.calculateJaroWinklerSimilarity(cleanedOcr, puzzleName),
|
|
||||||
this.calculateNGramSimilarity(cleanedOcr, puzzleName, 2),
|
|
||||||
this.calculateCharacterFrequencyScore(cleanedOcr, puzzleName.toLowerCase()),
|
|
||||||
// Add length similarity bonus
|
|
||||||
this.calculateLengthSimilarity(cleanedOcr, puzzleName.toLowerCase())
|
|
||||||
];
|
|
||||||
|
|
||||||
// Use weighted average with emphasis on character frequency and length
|
|
||||||
const weightedScore = (
|
|
||||||
scores[0] * 0.25 + // Levenshtein
|
|
||||||
scores[1] * 0.25 + // Jaro-Winkler
|
|
||||||
scores[2] * 0.2 + // N-gram
|
|
||||||
scores[3] * 0.2 + // Character frequency
|
|
||||||
scores[4] * 0.1 // Length similarity
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (weightedScore > bestScore) {
|
// Normalize by length to get a similarity ratio
|
||||||
bestScore = weightedScore;
|
const maxLength = Math.max(cleanedOcr.length, puzzleName.length);
|
||||||
|
const similarity = 1 - (distance / maxLength);
|
||||||
|
|
||||||
|
// Consider it a good match if similarity is above 70%
|
||||||
|
if (similarity > 0.7 && distance < bestScore) {
|
||||||
|
bestScore = distance;
|
||||||
bestMatch = puzzleName;
|
bestMatch = puzzleName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Forced match for "${ocrText}": "${bestMatch}" (score: ${bestScore.toFixed(3)})`);
|
|
||||||
return bestMatch;
|
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) {
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
import { createPinia } from 'pinia'
|
|
||||||
|
|
||||||
export const pinia = createPinia()
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
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,13 +29,6 @@ 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 {
|
||||||
@ -46,8 +39,6 @@ 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 {
|
||||||
@ -58,9 +49,7 @@ export interface PuzzleResponse {
|
|||||||
cycles?: string
|
cycles?: string
|
||||||
area?: string
|
area?: string
|
||||||
needs_manual_validation?: boolean
|
needs_manual_validation?: boolean
|
||||||
ocr_confidence_cost?: number
|
ocr_confidence_score?: 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
|
||||||
@ -80,7 +69,6 @@ export interface Submission {
|
|||||||
is_validated?: boolean
|
is_validated?: boolean
|
||||||
validated_by?: number | null
|
validated_by?: number | null
|
||||||
validated_at?: string | null
|
validated_at?: string | null
|
||||||
manual_validation_requested?: boolean
|
|
||||||
total_responses?: number
|
total_responses?: number
|
||||||
needs_validation?: boolean
|
needs_validation?: boolean
|
||||||
created_at?: string
|
created_at?: string
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
17
opus_submitter/static_source/vite/assets/main-CNlI4PW6.js
Normal file
17
opus_submitter/static_source/vite/assets/main-CNlI4PW6.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
@ -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-B14l8Jy0.js",
|
"file": "assets/main-CNlI4PW6.js",
|
||||||
"name": "main",
|
"name": "main",
|
||||||
"src": "src/main.ts",
|
"src": "src/main.ts",
|
||||||
"isEntry": true,
|
"isEntry": true,
|
||||||
"css": [
|
"css": [
|
||||||
"assets/main-COx9N9qO.css"
|
"assets/main-HDjkw-xK.css"
|
||||||
],
|
],
|
||||||
"assets": [
|
"assets": [
|
||||||
"assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
"assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
||||||
|
|||||||
@ -2,12 +2,8 @@ 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,
|
SteamAPIKey, SteamCollection, SteamCollectionItem,
|
||||||
SteamCollection,
|
Submission, PuzzleResponse, SubmissionFile
|
||||||
SteamCollectionItem,
|
|
||||||
Submission,
|
|
||||||
PuzzleResponse,
|
|
||||||
SubmissionFile,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -155,14 +151,7 @@ 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 = [
|
fields = ["file", "original_filename", "file_size", "content_type", "ocr_processed", "ocr_error"]
|
||||||
"file",
|
|
||||||
"original_filename",
|
|
||||||
"file_size",
|
|
||||||
"content_type",
|
|
||||||
"ocr_processed",
|
|
||||||
"ocr_error",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class PuzzleResponseInline(admin.TabularInline):
|
class PuzzleResponseInline(admin.TabularInline):
|
||||||
@ -170,69 +159,39 @@ class PuzzleResponseInline(admin.TabularInline):
|
|||||||
extra = 0
|
extra = 0
|
||||||
readonly_fields = ["created_at", "updated_at"]
|
readonly_fields = ["created_at", "updated_at"]
|
||||||
fields = [
|
fields = [
|
||||||
"puzzle",
|
"puzzle", "puzzle_name", "cost", "cycles", "area",
|
||||||
"puzzle_name",
|
"needs_manual_validation", "ocr_confidence_score"
|
||||||
"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",
|
"id", "user", "total_responses", "needs_validation",
|
||||||
"user",
|
"is_validated", "created_at"
|
||||||
"total_responses",
|
|
||||||
"needs_validation",
|
|
||||||
"manual_validation_requested",
|
|
||||||
"is_validated",
|
|
||||||
"created_at",
|
|
||||||
]
|
]
|
||||||
list_filter = [
|
list_filter = [
|
||||||
"is_validated",
|
"is_validated", "created_at", "updated_at"
|
||||||
"manual_validation_requested",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
]
|
]
|
||||||
search_fields = ["id", "user__username", "notes"]
|
search_fields = ["id", "user__username", "notes"]
|
||||||
readonly_fields = [
|
readonly_fields = ["id", "created_at", "updated_at", "total_responses", "needs_validation"]
|
||||||
"id",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
"total_responses",
|
|
||||||
"needs_validation",
|
|
||||||
]
|
|
||||||
inlines = [PuzzleResponseInline]
|
inlines = [PuzzleResponseInline]
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
("Basic Information", {"fields": ("id", "user", "notes")}),
|
("Basic Information", {
|
||||||
(
|
"fields": ("id", "user", "notes")
|
||||||
"Validation",
|
}),
|
||||||
{
|
("Validation", {
|
||||||
"fields": (
|
"fields": ("is_validated", "validated_by", "validated_at")
|
||||||
"manual_validation_requested",
|
}),
|
||||||
"is_validated",
|
("Statistics", {
|
||||||
"validated_by",
|
"fields": ("total_responses", "needs_validation"),
|
||||||
"validated_at",
|
"classes": ("collapse",)
|
||||||
)
|
}),
|
||||||
},
|
("Timestamps", {
|
||||||
),
|
"fields": ("created_at", "updated_at"),
|
||||||
(
|
"classes": ("collapse",)
|
||||||
"Statistics",
|
}),
|
||||||
{
|
|
||||||
"fields": ("total_responses", "needs_validation"),
|
|
||||||
"classes": ("collapse",),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"Timestamps",
|
|
||||||
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
actions = ["mark_as_validated"]
|
actions = ["mark_as_validated"]
|
||||||
@ -258,57 +217,36 @@ class SubmissionAdmin(admin.ModelAdmin):
|
|||||||
@admin.register(PuzzleResponse)
|
@admin.register(PuzzleResponse)
|
||||||
class PuzzleResponseAdmin(admin.ModelAdmin):
|
class PuzzleResponseAdmin(admin.ModelAdmin):
|
||||||
list_display = [
|
list_display = [
|
||||||
"puzzle_name",
|
"puzzle_name", "submission", "puzzle", "cost", "cycles", "area",
|
||||||
"submission",
|
"needs_manual_validation", "created_at"
|
||||||
"puzzle",
|
]
|
||||||
"cost",
|
list_filter = [
|
||||||
"cycles",
|
"needs_manual_validation", "puzzle__collection", "created_at"
|
||||||
"area",
|
|
||||||
"needs_manual_validation",
|
|
||||||
"created_at",
|
|
||||||
]
|
]
|
||||||
list_filter = ["needs_manual_validation", "puzzle__collection", "created_at"]
|
|
||||||
search_fields = [
|
search_fields = [
|
||||||
"puzzle_name",
|
"puzzle_name", "submission__id", "puzzle__title",
|
||||||
"submission__id",
|
"cost", "cycles", "area"
|
||||||
"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", {"fields": ("submission", "puzzle", "puzzle_name")}),
|
("Basic Information", {
|
||||||
(
|
"fields": ("submission", "puzzle", "puzzle_name")
|
||||||
"OCR Data",
|
}),
|
||||||
{
|
("OCR Data", {
|
||||||
"fields": (
|
"fields": ("cost", "cycles", "area", "ocr_confidence_score")
|
||||||
"cost",
|
}),
|
||||||
"cycles",
|
("Validation", {
|
||||||
"area",
|
"fields": (
|
||||||
"ocr_confidence_cost",
|
"needs_manual_validation",
|
||||||
"ocr_confidence_cycles",
|
"validated_cost", "validated_cycles", "validated_area"
|
||||||
"ocr_confidence_area",
|
)
|
||||||
)
|
}),
|
||||||
},
|
("Timestamps", {
|
||||||
),
|
"fields": ("created_at", "updated_at"),
|
||||||
(
|
"classes": ("collapse",)
|
||||||
"Validation",
|
}),
|
||||||
{
|
|
||||||
"fields": (
|
|
||||||
"needs_manual_validation",
|
|
||||||
"validated_cost",
|
|
||||||
"validated_cycles",
|
|
||||||
"validated_area",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"Timestamps",
|
|
||||||
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
actions = ["mark_for_validation", "clear_validation_flag"]
|
actions = ["mark_for_validation", "clear_validation_flag"]
|
||||||
@ -321,9 +259,7 @@ 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(
|
self.message_user(request, f"{updated} responses cleared from validation queue.")
|
||||||
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"
|
||||||
@ -332,47 +268,35 @@ class PuzzleResponseAdmin(admin.ModelAdmin):
|
|||||||
@admin.register(SubmissionFile)
|
@admin.register(SubmissionFile)
|
||||||
class SubmissionFileAdmin(admin.ModelAdmin):
|
class SubmissionFileAdmin(admin.ModelAdmin):
|
||||||
list_display = [
|
list_display = [
|
||||||
"original_filename",
|
"original_filename", "response", "file_size_display",
|
||||||
"response",
|
"content_type", "ocr_processed", "created_at"
|
||||||
"file_size_display",
|
]
|
||||||
"content_type",
|
list_filter = [
|
||||||
"ocr_processed",
|
"content_type", "ocr_processed", "created_at"
|
||||||
"created_at",
|
|
||||||
]
|
]
|
||||||
list_filter = ["content_type", "ocr_processed", "created_at"]
|
|
||||||
search_fields = [
|
search_fields = [
|
||||||
"original_filename",
|
"original_filename", "response__puzzle_name",
|
||||||
"response__puzzle_name",
|
"response__submission__id"
|
||||||
"response__submission__id",
|
|
||||||
]
|
]
|
||||||
readonly_fields = [
|
readonly_fields = [
|
||||||
"file_size",
|
"file_size", "content_type", "ocr_processed",
|
||||||
"content_type",
|
"created_at", "updated_at", "file_url"
|
||||||
"ocr_processed",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
"file_url",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(
|
("File Information", {
|
||||||
"File Information",
|
"fields": ("file", "original_filename", "file_size", "content_type", "file_url")
|
||||||
{
|
}),
|
||||||
"fields": (
|
("OCR Processing", {
|
||||||
"file",
|
"fields": ("ocr_processed", "ocr_raw_data", "ocr_error")
|
||||||
"original_filename",
|
}),
|
||||||
"file_size",
|
("Relationships", {
|
||||||
"content_type",
|
"fields": ("response",)
|
||||||
"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,20 +23,16 @@ 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").filter(
|
return SteamCollectionItem.objects.select_related("collection").all()
|
||||||
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 (
|
return Submission.objects.prefetch_related(
|
||||||
Submission.objects.prefetch_related("responses__files", "responses__puzzle")
|
"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)
|
||||||
@ -69,29 +65,10 @@ 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
|
||||||
@ -112,10 +89,8 @@ 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=data.manual_validation_requested,
|
needs_manual_validation=response_data.needs_manual_validation,
|
||||||
ocr_confidence_cost=response_data.ocr_confidence_cost,
|
ocr_confidence_score=response_data.ocr_confidence_score,
|
||||||
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
|
||||||
@ -174,29 +149,27 @@ 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"}
|
||||||
|
|
||||||
response = get_object_or_404(PuzzleResponse, id=response_id)
|
try:
|
||||||
|
response = PuzzleResponse.objects.select_related("puzzle").get(id=response_id)
|
||||||
|
|
||||||
if data.puzzle is not None:
|
# Update validated values
|
||||||
puzzle = get_object_or_404(SteamCollectionItem, id=data.puzzle)
|
if data.validated_cost is not None:
|
||||||
response.puzzle = puzzle
|
response.validated_cost = data.validated_cost
|
||||||
|
if data.validated_cycles is not None:
|
||||||
|
response.validated_cycles = data.validated_cycles
|
||||||
|
if data.validated_area is not None:
|
||||||
|
response.validated_area = data.validated_area
|
||||||
|
|
||||||
# Update validated values
|
# Mark as no longer needing validation if we have all values
|
||||||
if data.validated_cost is not None:
|
if all([response.final_cost, response.final_cycles, response.final_area]):
|
||||||
response.validated_cost = data.validated_cost
|
response.needs_manual_validation = False
|
||||||
|
|
||||||
if data.validated_cycles is not None:
|
response.save()
|
||||||
response.validated_cycles = data.validated_cycles
|
|
||||||
|
|
||||||
if data.validated_area is not None:
|
return response
|
||||||
response.validated_area = data.validated_area
|
|
||||||
|
|
||||||
# Mark as no longer needing validation if we have all values
|
except PuzzleResponse.DoesNotExist:
|
||||||
if all([response.final_cost, response.final_cycles, response.final_area]):
|
raise Http404("Response not found")
|
||||||
response.needs_manual_validation = False
|
|
||||||
|
|
||||||
response.save()
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/responses/needs-validation", response=List[PuzzleResponseOut])
|
@router.get("/responses/needs-validation", response=List[PuzzleResponseOut])
|
||||||
@ -208,7 +181,6 @@ 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")
|
||||||
)
|
)
|
||||||
@ -221,22 +193,26 @@ 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"}
|
||||||
|
|
||||||
submission = get_object_or_404(Submission, id=submission_id)
|
try:
|
||||||
|
submission = Submission.objects.get(id=submission_id)
|
||||||
|
|
||||||
submission.is_validated = True
|
submission.is_validated = True
|
||||||
submission.validated_by = request.user
|
submission.validated_by = request.user
|
||||||
submission.validated_at = timezone.now()
|
submission.validated_at = timezone.now()
|
||||||
submission.save()
|
submission.save()
|
||||||
|
|
||||||
# Also mark all responses as not needing validation
|
# Also mark all responses as not needing validation
|
||||||
submission.responses.update(needs_manual_validation=False)
|
submission.responses.update(needs_manual_validation=False)
|
||||||
|
|
||||||
# Reload with relations
|
# Reload with relations
|
||||||
submission = Submission.objects.prefetch_related(
|
submission = Submission.objects.prefetch_related(
|
||||||
"responses__files", "responses__puzzle"
|
"responses__files", "responses__puzzle"
|
||||||
).get(id=submission.id)
|
).get(id=submission.id)
|
||||||
|
|
||||||
return submission
|
return submission
|
||||||
|
|
||||||
|
except Submission.DoesNotExist:
|
||||||
|
raise Http404("Submission not found")
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/submissions/{submission_id}")
|
@router.delete("/submissions/{submission_id}")
|
||||||
@ -246,9 +222,13 @@ 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"}
|
||||||
|
|
||||||
submission = get_object_or_404(Submission, id=submission_id)
|
try:
|
||||||
submission.delete()
|
submission = Submission.objects.get(id=submission_id)
|
||||||
return {"detail": "Submission deleted successfully"}
|
submission.delete()
|
||||||
|
return {"detail": "Submission deleted successfully"}
|
||||||
|
|
||||||
|
except Submission.DoesNotExist:
|
||||||
|
raise Http404("Submission not found")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
@ -267,7 +247,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": (total_responses - needs_validation) / total_responses
|
"validation_rate": validated_submissions / total_submissions
|
||||||
if total_responses
|
if total_submissions > 0
|
||||||
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,39 +8,40 @@ 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(
|
||||||
"--api-key",
|
'url',
|
||||||
type=str,
|
type=str,
|
||||||
help="Steam API key (optional, can also be set via STEAM_API_KEY environment variable)",
|
help='Steam Workshop collection URL'
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--force",
|
'--api-key',
|
||||||
action="store_true",
|
type=str,
|
||||||
help="Force refetch even if collection already exists",
|
help='Steam API key (optional, can also be set via STEAM_API_KEY environment variable)'
|
||||||
|
)
|
||||||
|
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(
|
existing = SteamCollection.objects.filter(steam_id=collection_id).first()
|
||||||
steam_id=collection_id
|
|
||||||
).first()
|
|
||||||
if existing:
|
if existing:
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
self.style.WARNING(
|
self.style.WARNING(
|
||||||
@ -71,9 +72,7 @@ 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(
|
self.stdout.write(f" Description: {collection.description[:100]}{'...' if len(collection.description) > 100 else ''}")
|
||||||
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}")
|
||||||
@ -82,14 +81,10 @@ 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(
|
self.stdout.write(f" - {item.title} (Steam ID: {item.steam_item_id})")
|
||||||
f" - {item.title} (Steam ID: {item.steam_item_id})"
|
|
||||||
)
|
|
||||||
|
|
||||||
if collection.items.count() > 10:
|
if collection.items.count() > 10:
|
||||||
self.stdout.write(
|
self.stdout.write(f" ... and {collection.items.count() - 10} more items")
|
||||||
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,215 +5,68 @@ 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')),
|
||||||
"id",
|
('url', models.URLField()),
|
||||||
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')),
|
||||||
"id",
|
('steam_id', models.CharField(help_text='Steam collection ID from URL', max_length=50, unique=True)),
|
||||||
models.BigAutoField(
|
('url', models.URLField(help_text='Full Steam Workshop collection URL')),
|
||||||
auto_created=True,
|
('title', models.CharField(blank=True, help_text='Collection title', max_length=255)),
|
||||||
primary_key=True,
|
('description', models.TextField(blank=True, help_text='Collection description')),
|
||||||
serialize=False,
|
('author_name', models.CharField(blank=True, help_text='Steam username of collection creator', max_length=100)),
|
||||||
verbose_name="ID",
|
('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')),
|
||||||
"steam_id",
|
('total_favorites', models.PositiveIntegerField(default=0, help_text='Total unique favorites')),
|
||||||
models.CharField(
|
('steam_created_date', models.DateTimeField(blank=True, help_text='When collection was created on Steam', null=True)),
|
||||||
help_text="Steam collection ID from URL",
|
('steam_updated_date', models.DateTimeField(blank=True, help_text='When collection was last updated on Steam', null=True)),
|
||||||
max_length=50,
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
unique=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')),
|
||||||
"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')),
|
||||||
"id",
|
('steam_item_id', models.CharField(help_text='Steam Workshop item ID', max_length=50)),
|
||||||
models.BigAutoField(
|
('title', models.CharField(blank=True, help_text='Item title', max_length=255)),
|
||||||
auto_created=True,
|
('author_name', models.CharField(blank=True, help_text='Steam username of item creator', max_length=100)),
|
||||||
primary_key=True,
|
('author_steam_id', models.CharField(blank=True, help_text='Steam ID of item creator', max_length=50)),
|
||||||
serialize=False,
|
('description', models.TextField(blank=True, help_text='Item description')),
|
||||||
verbose_name="ID",
|
('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)),
|
||||||
"steam_item_id",
|
('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='submissions.steamcollection')),
|
||||||
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,12 +4,13 @@ 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,66 +4,28 @@ 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')),
|
||||||
"id",
|
('name', models.CharField(help_text="Descriptive name for this API key (e.g., 'Production Key', 'Development Key')", max_length=100, unique=True)),
|
||||||
models.BigAutoField(
|
('api_key', models.CharField(help_text='Steam Web API key from https://steamcommunity.com/dev/apikey', max_length=64)),
|
||||||
auto_created=True,
|
('is_active', models.BooleanField(default=True, help_text='Whether this API key should be used')),
|
||||||
primary_key=True,
|
('description', models.TextField(blank=True, help_text='Optional description or notes about this API key')),
|
||||||
serialize=False,
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
verbose_name="ID",
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
),
|
('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,245 +8,75 @@ 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)),
|
||||||
"id",
|
('notes', models.TextField(blank=True, help_text='Optional notes about the submission')),
|
||||||
models.UUIDField(
|
('is_validated', models.BooleanField(default=False, help_text='Whether this submission has been manually validated')),
|
||||||
default=uuid.uuid4,
|
('validated_at', models.DateTimeField(blank=True, help_text='When this submission was validated', null=True)),
|
||||||
editable=False,
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
primary_key=True,
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
serialize=False,
|
('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)),
|
||||||
),
|
|
||||||
(
|
|
||||||
"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')),
|
||||||
"id",
|
('puzzle_name', models.CharField(help_text='Puzzle name as detected by OCR', max_length=255)),
|
||||||
models.BigAutoField(
|
('cost', models.CharField(blank=True, help_text='Cost value from OCR', max_length=20)),
|
||||||
auto_created=True,
|
('cycles', models.CharField(blank=True, help_text='Cycles value from OCR', max_length=20)),
|
||||||
primary_key=True,
|
('area', models.CharField(blank=True, help_text='Area value from OCR', max_length=20)),
|
||||||
serialize=False,
|
('needs_manual_validation', models.BooleanField(default=False, help_text='Whether OCR failed and manual validation is needed')),
|
||||||
verbose_name="ID",
|
('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)),
|
||||||
"puzzle_name",
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
models.CharField(
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
help_text="Puzzle name as detected by OCR", max_length=255
|
('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')),
|
||||||
),
|
|
||||||
(
|
|
||||||
"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')),
|
||||||
"id",
|
('file', models.FileField(help_text='Uploaded file (image/gif)', upload_to=submissions.models.submission_file_upload_path)),
|
||||||
models.BigAutoField(
|
('original_filename', models.CharField(help_text='Original filename as uploaded by user', max_length=255)),
|
||||||
auto_created=True,
|
('file_size', models.PositiveIntegerField(help_text='File size in bytes')),
|
||||||
primary_key=True,
|
('content_type', models.CharField(help_text='MIME type of the file', max_length=100)),
|
||||||
serialize=False,
|
('ocr_processed', models.BooleanField(default=False, help_text='Whether OCR has been processed for this file')),
|
||||||
verbose_name="ID",
|
('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)),
|
||||||
"file",
|
('response', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='submissions.puzzleresponse')),
|
||||||
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,16 +4,15 @@ 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(
|
field=models.TextField(blank=True, help_text='Optional notes about the submission', null=True),
|
||||||
blank=True, help_text="Optional notes about the submission", null=True
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,43 +0,0 @@
|
|||||||
# 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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
# 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",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
# 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,7 +1,9 @@
|
|||||||
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()
|
||||||
|
|
||||||
@ -241,12 +243,6 @@ 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)
|
||||||
@ -299,15 +295,8 @@ 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(
|
||||||
ocr_confidence_cost = models.FloatField(
|
null=True, blank=True, help_text="OCR confidence score (0.0 to 1.0)"
|
||||||
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
|
||||||
@ -327,6 +316,7 @@ 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,4 +1,5 @@
|
|||||||
from ninja import Schema, ModelSchema
|
from ninja import Schema, ModelSchema, File
|
||||||
|
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
|
||||||
@ -24,16 +25,13 @@ 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_cost: Optional[float] = None
|
ocr_confidence_score: 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]
|
||||||
|
|
||||||
|
|
||||||
@ -75,9 +73,7 @@ class PuzzleResponseOut(ModelSchema):
|
|||||||
"cycles",
|
"cycles",
|
||||||
"area",
|
"area",
|
||||||
"needs_manual_validation",
|
"needs_manual_validation",
|
||||||
"ocr_confidence_cost",
|
"ocr_confidence_score",
|
||||||
"ocr_confidence_cycles",
|
|
||||||
"ocr_confidence_area",
|
|
||||||
"validated_cost",
|
"validated_cost",
|
||||||
"validated_cycles",
|
"validated_cycles",
|
||||||
"validated_area",
|
"validated_area",
|
||||||
@ -102,7 +98,6 @@ 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",
|
||||||
]
|
]
|
||||||
@ -125,7 +120,6 @@ 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 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
# Create your tests here.
|
# Create your tests here.
|
||||||
|
|||||||
@ -4,7 +4,6 @@ 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
|
||||||
@ -21,11 +20,7 @@ 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 = (
|
self.api_key = api_key or self._get_api_key_from_db() or getattr(settings, "STEAM_API_KEY", None)
|
||||||
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:
|
||||||
@ -35,14 +30,12 @@ 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}")
|
||||||
@ -253,34 +246,30 @@ 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 = {"collectioncount": 1, "publishedfileids[0]": collection_id}
|
data = {
|
||||||
|
'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 (
|
if 'response' in collection_response and 'collectiondetails' in collection_response['response']:
|
||||||
"response" in collection_response
|
for collection in collection_response['response']['collectiondetails']:
|
||||||
and "collectiondetails" in collection_response["response"]
|
if collection.get('result') == 1 and 'children' in collection:
|
||||||
):
|
|
||||||
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']),
|
||||||
"id": str(child["publishedfileid"]),
|
'sort_order': child.get('sortorder', 0)
|
||||||
"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)
|
||||||
@ -344,9 +333,7 @@ class SteamCollectionFetcher:
|
|||||||
items.append(item_info)
|
items.append(item_info)
|
||||||
else:
|
else:
|
||||||
# Log failed items
|
# Log failed items
|
||||||
logger.warning(
|
logger.warning(f"Failed to fetch item {item_id}: result={result}, ban_reason={item_data.get('ban_reason', 'N/A')}")
|
||||||
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}")
|
||||||
@ -355,6 +342,7 @@ 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
|
||||||
@ -369,7 +357,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 +1,3 @@
|
|||||||
|
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" data-collection-title="{{ collection.title }}" data-collection-url="{{ collection.url }}" data-collection-description="{{ collection.description }}"></div>
|
<div id="app"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
{"root":["./src/main.ts","./src/services/apiService.ts","./src/services/ocrService.ts","./src/stores/index.ts","./src/stores/puzzles.ts","./src/stores/submissions.ts","./src/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/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,7 +23,6 @@ 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,7 +426,6 @@ 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" },
|
||||||
]
|
]
|
||||||
@ -448,7 +447,6 @@ 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" },
|
||||||
]
|
]
|
||||||
@ -774,15 +772,6 @@ 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