Compare commits
72
Commits
6a882ce39a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e91ef77b6
|
||
|
|
215d68a0e1
|
||
|
|
47812ffd09
|
||
|
|
a5fe8aacaf
|
||
|
|
b437210eb3
|
||
|
|
5584e54b58
|
||
|
|
35ea54ecea
|
||
|
|
821e453bc0
|
||
|
|
9fd0122a67
|
||
|
|
e557fe2cda
|
||
|
|
79e7cef3ba
|
||
|
|
a264336bd8
|
||
|
|
42e3571fab
|
||
|
|
43b314bb20
|
||
|
|
f1afb2096f
|
||
|
|
62a81e57ad
|
||
|
|
303b9e1c8a
|
||
|
|
f7c7eba4da
|
||
|
|
ce30539808
|
||
|
|
544112b204
|
||
|
|
9f94fb3974
|
||
|
|
25072e8eb4
|
||
|
|
9e5ab8539a
|
||
|
|
4ba6a48246
|
||
|
|
92dddca964
|
||
|
|
bf21a5eae6
|
||
|
|
89faa7cd40
|
||
|
|
a800c7fff7
|
||
|
|
2ee8cd6be3
|
||
|
|
35824de310
|
||
|
|
65cc2e555f
|
||
|
|
6d996a4e2f
|
||
|
|
f774ff3340
|
||
|
|
779393106d
|
||
|
|
3e04f8312a
|
||
|
|
7cfab20826
|
||
|
|
754b0b0803
|
||
|
|
79f469a393
|
||
|
|
5aeff9c218
|
||
|
|
2264401a91
|
||
|
|
9662181d4d
|
||
|
|
90f1ce13cf
|
||
|
|
f90377ac69
|
||
|
|
d8d09c21d4
|
||
|
|
3c86ca4c91
|
||
|
|
0b138e315c
|
||
|
|
d2a9dbe4a4
|
||
|
|
fa53d74295
|
||
|
|
52a6a4adb2
|
||
|
|
69b6b46ee2
|
||
|
|
119fdc2a51
|
||
|
|
01b0dbd1d9
|
||
|
|
19cc52c9f8
|
||
|
|
07f5c76a52
|
||
|
|
8584102402
|
||
|
|
404af4f90d
|
||
|
|
eb1eed852b
|
||
|
|
11229a3906
|
||
|
|
38e2b5e858
|
||
|
|
1e4251c796
|
||
|
|
fa76fbce92
|
||
|
|
9ee45463a8
|
||
|
|
8f88548a59
|
||
|
|
012b72527b
|
||
|
|
596731a8a7 | ||
|
|
e0ada1e26d | ||
|
|
f98145d6db | ||
|
|
0e1e77c2dd | ||
|
|
15de496501 | ||
|
|
8960f551e6 | ||
|
|
2260c7cc27 | ||
|
|
b5f31a8c72 |
@@ -1,231 +0,0 @@
|
|||||||
# Opus Magnum Submission API Usage
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The API is built with Django Ninja and provides endpoints for managing puzzle submissions with OCR validation and S3 file storage.
|
|
||||||
|
|
||||||
## Base URL
|
|
||||||
- Development: `http://localhost:8000/api/`
|
|
||||||
- API Documentation: `http://localhost:8000/api/docs/`
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
Most endpoints support both authenticated and anonymous submissions. Admin endpoints require staff permissions.
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
### Required for S3 Storage
|
|
||||||
```bash
|
|
||||||
USE_S3=true
|
|
||||||
AWS_ACCESS_KEY_ID=your_access_key
|
|
||||||
AWS_SECRET_ACCESS_KEY=your_secret_key
|
|
||||||
AWS_STORAGE_BUCKET_NAME=your_bucket_name
|
|
||||||
AWS_S3_REGION_NAME=us-east-1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Optional
|
|
||||||
```bash
|
|
||||||
STEAM_API_KEY=your_steam_api_key
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### 1. Get Available Puzzles
|
|
||||||
```http
|
|
||||||
GET /api/submissions/puzzles
|
|
||||||
```
|
|
||||||
|
|
||||||
Response:
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"steam_item_id": "3479143948",
|
|
||||||
"title": "P41-FLOC",
|
|
||||||
"author_name": "Flame Legrems",
|
|
||||||
"description": "A challenging puzzle...",
|
|
||||||
"tags": ["puzzle", "chemistry"],
|
|
||||||
"order_index": 0,
|
|
||||||
"steam_url": "https://steamcommunity.com/workshop/filedetails/?id=3479143948",
|
|
||||||
"created_at": "2025-05-29T11:19:24Z",
|
|
||||||
"updated_at": "2025-05-30T22:15:09Z"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Create Submission
|
|
||||||
```http
|
|
||||||
POST /api/submissions/submissions
|
|
||||||
Content-Type: multipart/form-data
|
|
||||||
```
|
|
||||||
|
|
||||||
Form Data:
|
|
||||||
- `data`: JSON with submission data
|
|
||||||
- `files`: Array of uploaded files
|
|
||||||
|
|
||||||
Example data:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"notes": "My best solutions so far",
|
|
||||||
"responses": [
|
|
||||||
{
|
|
||||||
"puzzle_id": 1,
|
|
||||||
"puzzle_name": "P41-FLOC",
|
|
||||||
"cost": "150",
|
|
||||||
"cycles": "89",
|
|
||||||
"area": "12",
|
|
||||||
"needs_manual_validation": false,
|
|
||||||
"ocr_confidence_score": 0.95
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Response:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
|
||||||
"user": null,
|
|
||||||
"notes": "My best solutions so far",
|
|
||||||
"responses": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"puzzle": 1,
|
|
||||||
"puzzle_name": "P41-FLOC",
|
|
||||||
"cost": "150",
|
|
||||||
"cycles": "89",
|
|
||||||
"area": "12",
|
|
||||||
"needs_manual_validation": false,
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"original_filename": "solution.gif",
|
|
||||||
"file_size": 1024000,
|
|
||||||
"content_type": "image/gif",
|
|
||||||
"file_url": "https://bucket.s3.amazonaws.com/media/submissions/123.../file.gif",
|
|
||||||
"ocr_processed": false,
|
|
||||||
"created_at": "2025-10-29T00:00:00Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"final_cost": "150",
|
|
||||||
"final_cycles": "89",
|
|
||||||
"final_area": "12"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total_responses": 1,
|
|
||||||
"needs_validation": false,
|
|
||||||
"is_validated": false,
|
|
||||||
"created_at": "2025-10-29T00:00:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. List Submissions
|
|
||||||
```http
|
|
||||||
GET /api/submissions/submissions?limit=20&offset=0
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Get Submission Details
|
|
||||||
```http
|
|
||||||
GET /api/submissions/submissions/{submission_id}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Admin: Validate Response (Staff Only)
|
|
||||||
```http
|
|
||||||
PUT /api/submissions/responses/{response_id}/validate
|
|
||||||
Content-Type: application/json
|
|
||||||
```
|
|
||||||
|
|
||||||
Body:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"validated_cost": "150",
|
|
||||||
"validated_cycles": "89",
|
|
||||||
"validated_area": "12"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Admin: List Responses Needing Validation (Staff Only)
|
|
||||||
```http
|
|
||||||
GET /api/submissions/responses/needs-validation
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. Admin: Validate Entire Submission (Staff Only)
|
|
||||||
```http
|
|
||||||
POST /api/submissions/submissions/{submission_id}/validate
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. Get Statistics
|
|
||||||
```http
|
|
||||||
GET /api/submissions/stats
|
|
||||||
```
|
|
||||||
|
|
||||||
Response:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"total_submissions": 150,
|
|
||||||
"total_responses": 300,
|
|
||||||
"needs_validation": 25,
|
|
||||||
"validated_submissions": 120,
|
|
||||||
"validation_rate": 0.8
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## OCR Validation Logic
|
|
||||||
|
|
||||||
The system automatically flags responses for manual validation when:
|
|
||||||
|
|
||||||
1. **Incomplete OCR Data**: Missing cost, cycles, or area values
|
|
||||||
2. **Low Confidence**: OCR confidence score below threshold
|
|
||||||
3. **Manual Flag**: Explicitly marked by frontend OCR processing
|
|
||||||
|
|
||||||
### Manual Validation Workflow
|
|
||||||
|
|
||||||
1. Admin views responses needing validation: `GET /responses/needs-validation`
|
|
||||||
2. Admin reviews the uploaded files and OCR results
|
|
||||||
3. Admin provides corrected values: `PUT /responses/{id}/validate`
|
|
||||||
4. System updates `validated_*` fields and clears validation flag
|
|
||||||
5. Optional: Mark entire submission as validated: `POST /submissions/{id}/validate`
|
|
||||||
|
|
||||||
## File Storage
|
|
||||||
|
|
||||||
- **Development**: Files stored locally in `media/submissions/`
|
|
||||||
- **Production**: Files stored in S3 with path structure: `submissions/{submission_id}/{uuid}_{filename}`
|
|
||||||
- **Supported Formats**: JPEG, PNG, GIF, MP4, WebM
|
|
||||||
- **Size Limit**: 10MB per file
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
The API returns standard HTTP status codes:
|
|
||||||
|
|
||||||
- `200`: Success
|
|
||||||
- `400`: Bad Request (validation errors)
|
|
||||||
- `401`: Unauthorized
|
|
||||||
- `403`: Forbidden (admin required)
|
|
||||||
- `404`: Not Found
|
|
||||||
- `500`: Internal Server Error
|
|
||||||
|
|
||||||
Error Response Format:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"detail": "Error message",
|
|
||||||
"code": "error_code"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Frontend Integration
|
|
||||||
|
|
||||||
The Vue frontend should:
|
|
||||||
|
|
||||||
1. Upload files with OCR data extracted client-side
|
|
||||||
2. Group files by detected puzzle name
|
|
||||||
3. Create one submission with multiple responses
|
|
||||||
4. Handle validation flags and display admin feedback
|
|
||||||
5. Show file upload progress and S3 URLs
|
|
||||||
|
|
||||||
## Admin Interface
|
|
||||||
|
|
||||||
Django Admin provides:
|
|
||||||
|
|
||||||
- **Submission Management**: View, validate, and manage submissions
|
|
||||||
- **Response Validation**: Bulk actions for validation workflow
|
|
||||||
- **File Management**: View uploaded files and OCR data
|
|
||||||
- **Statistics Dashboard**: Track validation rates and submission metrics
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
from django.contrib.auth.admin import UserAdmin
|
|
||||||
from .models import CustomUser
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(CustomUser)
|
|
||||||
class CustomUserAdmin(UserAdmin):
|
|
||||||
"""Admin interface for CustomUser."""
|
|
||||||
|
|
||||||
# Add custom fields to the user admin
|
|
||||||
fieldsets = UserAdmin.fieldsets + (
|
|
||||||
('CAS Information', {
|
|
||||||
'fields': ('cas_user_id', 'cas_groups', 'cas_attributes'),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add custom fields to the list display
|
|
||||||
list_display = UserAdmin.list_display + ('cas_user_id', 'get_cas_groups_display')
|
|
||||||
|
|
||||||
# Add search fields
|
|
||||||
search_fields = UserAdmin.search_fields + ('cas_user_id',)
|
|
||||||
|
|
||||||
# Add filters
|
|
||||||
list_filter = UserAdmin.list_filter + ('cas_groups',)
|
|
||||||
|
|
||||||
# Make CAS fields readonly in admin
|
|
||||||
readonly_fields = ('cas_user_id', 'cas_groups', 'cas_attributes')
|
|
||||||
|
|
||||||
def get_cas_groups_display(self, obj):
|
|
||||||
"""Display CAS groups in admin list."""
|
|
||||||
return obj.get_cas_groups_display()
|
|
||||||
get_cas_groups_display.short_description = 'CAS Groups'
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class AccountsConfig(AppConfig):
|
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
|
||||||
name = 'accounts'
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-28 23:41
|
|
||||||
|
|
||||||
import django.contrib.auth.models
|
|
||||||
import django.contrib.auth.validators
|
|
||||||
import django.utils.timezone
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('auth', '0012_alter_user_first_name_max_length'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='CustomUser',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
|
||||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
|
||||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
|
||||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
|
||||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
|
||||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
|
||||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
|
||||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
|
||||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
|
||||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
|
||||||
('cas_user_id', models.CharField(blank=True, max_length=50, null=True, unique=True)),
|
|
||||||
('cas_groups', models.JSONField(blank=True, default=list)),
|
|
||||||
('cas_attributes', models.JSONField(blank=True, default=dict)),
|
|
||||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
|
||||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'user',
|
|
||||||
'verbose_name_plural': 'users',
|
|
||||||
'abstract': False,
|
|
||||||
},
|
|
||||||
managers=[
|
|
||||||
('objects', django.contrib.auth.models.UserManager()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from django.shortcuts import render
|
|
||||||
|
|
||||||
# Create your views here.
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
from ninja import NinjaAPI
|
|
||||||
from ninja.security import django_auth
|
|
||||||
from submissions.api import router as submissions_router
|
|
||||||
from submissions.schemas import UserInfoOut
|
|
||||||
|
|
||||||
# Create the main API instance
|
|
||||||
api = NinjaAPI(
|
|
||||||
title="Opus Magnum Submission API",
|
|
||||||
version="1.0.0",
|
|
||||||
description="API for managing Opus Magnum puzzle submissions",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add authentication for protected endpoints
|
|
||||||
# api.auth = django_auth # Uncomment if you want global auth
|
|
||||||
|
|
||||||
# Include the submissions router
|
|
||||||
api.add_router("/submissions/", submissions_router, tags=["submissions"])
|
|
||||||
|
|
||||||
|
|
||||||
# Health check endpoint
|
|
||||||
@api.get("/health")
|
|
||||||
def health_check(request):
|
|
||||||
"""Health check endpoint"""
|
|
||||||
return {"status": "healthy", "service": "opus-magnum-api"}
|
|
||||||
|
|
||||||
|
|
||||||
# API info endpoint
|
|
||||||
@api.get("/info")
|
|
||||||
def api_info(request):
|
|
||||||
"""Get API information"""
|
|
||||||
return {
|
|
||||||
"name": "Opus Magnum Submission API",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "API for managing puzzle submissions with OCR validation",
|
|
||||||
"features": [
|
|
||||||
"Multi-puzzle submissions",
|
|
||||||
"File upload to S3",
|
|
||||||
"OCR validation tracking",
|
|
||||||
"Manual validation workflow",
|
|
||||||
"Admin validation tools",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# User info endpoint
|
|
||||||
@api.get("/user", response=UserInfoOut)
|
|
||||||
def get_user_info(request):
|
|
||||||
"""Get current user information"""
|
|
||||||
user = request.user
|
|
||||||
|
|
||||||
if user.is_authenticated:
|
|
||||||
return {
|
|
||||||
"id": user.id,
|
|
||||||
"username": user.username,
|
|
||||||
"first_name": user.first_name,
|
|
||||||
"last_name": user.last_name,
|
|
||||||
"email": user.email,
|
|
||||||
"is_authenticated": True,
|
|
||||||
"is_staff": user.is_staff,
|
|
||||||
"is_superuser": user.is_superuser,
|
|
||||||
"cas_groups": getattr(user, 'cas_groups', [])
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return {
|
|
||||||
"is_authenticated": False,
|
|
||||||
"is_staff": False,
|
|
||||||
"is_superuser": False,
|
|
||||||
}
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, computed } from 'vue'
|
|
||||||
import PuzzleCard from './components/PuzzleCard.vue'
|
|
||||||
import SubmissionForm from './components/SubmissionForm.vue'
|
|
||||||
import AdminPanel from './components/AdminPanel.vue'
|
|
||||||
import { puzzleHelpers, submissionHelpers, errorHelpers, apiService } from './services/apiService'
|
|
||||||
import type { SteamCollection, SteamCollectionItem, Submission, PuzzleResponse, UserInfo } from './types'
|
|
||||||
|
|
||||||
// API data
|
|
||||||
const collections = ref<SteamCollection[]>([])
|
|
||||||
const puzzles = ref<SteamCollectionItem[]>([])
|
|
||||||
const submissions = ref<Submission[]>([])
|
|
||||||
const userInfo = ref<UserInfo | null>(null)
|
|
||||||
const isLoading = ref(true)
|
|
||||||
const showSubmissionModal = ref(false)
|
|
||||||
const error = ref<string>('')
|
|
||||||
|
|
||||||
// Mock data removed - using API data only
|
|
||||||
|
|
||||||
// Computed properties
|
|
||||||
const isSuperuser = computed(() => {
|
|
||||||
return userInfo.value?.is_superuser || false
|
|
||||||
})
|
|
||||||
|
|
||||||
// Computed property to get responses grouped by puzzle
|
|
||||||
const responsesByPuzzle = computed(() => {
|
|
||||||
const grouped: Record<number, PuzzleResponse[]> = {}
|
|
||||||
submissions.value.forEach(submission => {
|
|
||||||
submission.responses.forEach(response => {
|
|
||||||
// Handle both number and object types for puzzle field
|
|
||||||
const puzzleId = typeof response.puzzle === 'number' ? response.puzzle : response.puzzle.id
|
|
||||||
if (!grouped[puzzleId]) {
|
|
||||||
grouped[puzzleId] = []
|
|
||||||
}
|
|
||||||
grouped[puzzleId].push(response)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return grouped
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
isLoading.value = true
|
|
||||||
error.value = ''
|
|
||||||
|
|
||||||
console.log('Starting data load...')
|
|
||||||
|
|
||||||
// Load user info
|
|
||||||
console.log('Loading user info...')
|
|
||||||
const userResponse = await apiService.getUserInfo()
|
|
||||||
if (userResponse.data) {
|
|
||||||
userInfo.value = userResponse.data
|
|
||||||
console.log('User info loaded:', userResponse.data)
|
|
||||||
} else if (userResponse.error) {
|
|
||||||
console.warn('User info error:', userResponse.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load puzzles from API
|
|
||||||
console.log('Loading puzzles...')
|
|
||||||
const loadedPuzzles = await puzzleHelpers.loadPuzzles()
|
|
||||||
puzzles.value = loadedPuzzles
|
|
||||||
console.log('Puzzles loaded:', loadedPuzzles.length)
|
|
||||||
|
|
||||||
// Create mock collection from loaded puzzles for display
|
|
||||||
if (loadedPuzzles.length > 0) {
|
|
||||||
collections.value = [{
|
|
||||||
id: 1,
|
|
||||||
steam_id: '3479142989',
|
|
||||||
title: 'PolyLAN 41',
|
|
||||||
description: 'Puzzle collection for PolyLAN 41 fil rouge',
|
|
||||||
author_name: 'Flame Legrems',
|
|
||||||
total_items: loadedPuzzles.length,
|
|
||||||
unique_visitors: 31,
|
|
||||||
current_favorites: 1,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
updated_at: new Date().toISOString()
|
|
||||||
}]
|
|
||||||
console.log('Collection created')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load existing submissions
|
|
||||||
console.log('Loading submissions...')
|
|
||||||
const loadedSubmissions = await submissionHelpers.loadSubmissions()
|
|
||||||
submissions.value = loadedSubmissions
|
|
||||||
console.log('Submissions loaded:', loadedSubmissions.length)
|
|
||||||
|
|
||||||
console.log('Data load complete!')
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
error.value = errorHelpers.getErrorMessage(err)
|
|
||||||
console.error('Failed to load data:', err)
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
console.log('Loading state set to false')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleSubmission = async (submissionData: {
|
|
||||||
files: any[],
|
|
||||||
notes?: string
|
|
||||||
}) => {
|
|
||||||
try {
|
|
||||||
isLoading.value = true
|
|
||||||
error.value = ''
|
|
||||||
|
|
||||||
// Create submission via API
|
|
||||||
const response = await submissionHelpers.createFromFiles(
|
|
||||||
submissionData.files,
|
|
||||||
puzzles.value,
|
|
||||||
submissionData.notes
|
|
||||||
)
|
|
||||||
|
|
||||||
if (response.error) {
|
|
||||||
error.value = response.error
|
|
||||||
alert(`Submission failed: ${response.error}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.data) {
|
|
||||||
// Add to local submissions list
|
|
||||||
submissions.value.unshift(response.data)
|
|
||||||
|
|
||||||
// Show success message
|
|
||||||
const puzzleNames = response.data.responses.map(r => r.puzzle_name).join(', ')
|
|
||||||
alert(`Solutions submitted successfully for puzzles: ${puzzleNames}`)
|
|
||||||
|
|
||||||
// Close modal
|
|
||||||
showSubmissionModal.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
const errorMessage = errorHelpers.getErrorMessage(err)
|
|
||||||
error.value = errorMessage
|
|
||||||
alert(`Submission failed: ${errorMessage}`)
|
|
||||||
console.error('Submission error:', err)
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openSubmissionModal = () => {
|
|
||||||
showSubmissionModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeSubmissionModal = () => {
|
|
||||||
showSubmissionModal.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to match puzzle name from OCR to actual puzzle
|
|
||||||
const findPuzzleByName = (ocrPuzzleName: string): SteamCollectionItem | null => {
|
|
||||||
return puzzleHelpers.findPuzzleByName(puzzles.value, ocrPuzzleName)
|
|
||||||
}
|
|
||||||
|
|
||||||
const reloadPage = () => {
|
|
||||||
window.location.reload()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="min-h-screen bg-base-200">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="navbar bg-base-100 shadow-lg">
|
|
||||||
<div class="container mx-auto">
|
|
||||||
<div class="flex-1">
|
|
||||||
<h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</h1>
|
|
||||||
</div>
|
|
||||||
<div class="flex-none">
|
|
||||||
<div v-if="userInfo?.is_authenticated" class="flex items-center gap-2">
|
|
||||||
<div class="text-sm">
|
|
||||||
<span class="font-medium">{{ userInfo.username }}</span>
|
|
||||||
<span v-if="userInfo.is_superuser" class="badge badge-warning badge-xs ml-1">Admin</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-sm text-base-content/70">
|
|
||||||
Not logged in
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Content -->
|
|
||||||
<div class="container mx-auto px-4 py-8">
|
|
||||||
<!-- Loading State -->
|
|
||||||
<div v-if="isLoading" class="flex justify-center items-center min-h-[400px]">
|
|
||||||
<div class="text-center">
|
|
||||||
<span class="loading loading-spinner loading-lg"></span>
|
|
||||||
<p class="mt-4 text-base-content/70">Loading puzzles...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error State -->
|
|
||||||
<div v-else-if="error" class="alert alert-error max-w-2xl mx-auto">
|
|
||||||
<i class="mdi mdi-alert-circle text-xl"></i>
|
|
||||||
<div>
|
|
||||||
<h3 class="font-bold">Error Loading Data</h3>
|
|
||||||
<div class="text-sm">{{ error }}</div>
|
|
||||||
</div>
|
|
||||||
<button @click="reloadPage" class="btn btn-sm btn-outline">
|
|
||||||
<i class="mdi mdi-refresh mr-1"></i>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Content -->
|
|
||||||
<div v-else class="space-y-8">
|
|
||||||
<!-- Collection Info -->
|
|
||||||
<div v-if="collections.length > 0" class="mb-8">
|
|
||||||
<div class="card bg-base-100 shadow-lg">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title text-2xl">{{ collections[0].title }}</h2>
|
|
||||||
<p class="text-base-content/70">{{ collections[0].description }}</p>
|
|
||||||
<div class="flex flex-wrap gap-4 mt-4">
|
|
||||||
<button
|
|
||||||
@click="openSubmissionModal"
|
|
||||||
class="btn btn-primary"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-plus mr-2"></i>
|
|
||||||
Submit Solution
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Admin Panel (only for superusers) -->
|
|
||||||
<div v-if="isSuperuser">
|
|
||||||
<AdminPanel />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Puzzles Grid -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
<PuzzleCard
|
|
||||||
v-for="puzzle in puzzles"
|
|
||||||
:key="puzzle.id"
|
|
||||||
:puzzle="puzzle"
|
|
||||||
:responses="responsesByPuzzle[puzzle.id] || []"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div v-if="puzzles.length === 0" class="text-center py-12">
|
|
||||||
<div class="text-6xl mb-4">🧩</div>
|
|
||||||
<h3 class="text-xl font-bold mb-2">No Puzzles Available</h3>
|
|
||||||
<p class="text-base-content/70">Check back later for new puzzle collections!</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Submission Modal -->
|
|
||||||
<div v-if="showSubmissionModal" class="modal modal-open">
|
|
||||||
<div class="modal-box max-w-4xl">
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<h3 class="font-bold text-lg">Submit Solution</h3>
|
|
||||||
<button
|
|
||||||
@click="closeSubmissionModal"
|
|
||||||
class="btn btn-sm btn-circle btn-ghost"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-close"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SubmissionForm
|
|
||||||
:puzzles="puzzles"
|
|
||||||
:find-puzzle-by-name="findPuzzleByName"
|
|
||||||
@submit="handleSubmission"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="modal-backdrop" @click="closeSubmissionModal"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="card bg-base-100 shadow-xl">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title">
|
|
||||||
<i class="mdi mdi-shield-account text-2xl text-warning"></i>
|
|
||||||
Admin Panel
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<!-- Stats -->
|
|
||||||
<div class="stats stats-vertical lg:stats-horizontal shadow mb-6">
|
|
||||||
<div class="stat">
|
|
||||||
<div class="stat-title">Total Submissions</div>
|
|
||||||
<div class="stat-value text-primary">{{ stats.total_submissions }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat">
|
|
||||||
<div class="stat-title">Total Responses</div>
|
|
||||||
<div class="stat-value text-secondary">{{ stats.total_responses }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat">
|
|
||||||
<div class="stat-title">Need Validation</div>
|
|
||||||
<div class="stat-value text-warning">{{ stats.needs_validation }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat">
|
|
||||||
<div class="stat-title">Validation Rate</div>
|
|
||||||
<div class="stat-value text-success">{{ Math.round(stats.validation_rate * 100) }}%</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Responses Needing Validation -->
|
|
||||||
<div v-if="responsesNeedingValidation.length > 0">
|
|
||||||
<h3 class="text-lg font-bold mb-4">Responses Needing Validation</h3>
|
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="table table-zebra">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Puzzle</th>
|
|
||||||
<th>OCR Data</th>
|
|
||||||
<th>Confidence</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="response in responsesNeedingValidation" :key="response.id">
|
|
||||||
<td>
|
|
||||||
<div class="font-bold">{{ response.puzzle_name }}</div>
|
|
||||||
<div class="text-sm opacity-50">ID: {{ response.id }}</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="text-sm space-y-1">
|
|
||||||
<div>Cost: {{ response.cost || '-' }}</div>
|
|
||||||
<div>Cycles: {{ response.cycles || '-' }}</div>
|
|
||||||
<div>Area: {{ response.area || '-' }}</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="badge badge-warning badge-sm">
|
|
||||||
{{ response.ocr_confidence_score ? Math.round(response.ocr_confidence_score * 100) + '%' : 'Low' }}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
@click="openValidationModal(response)"
|
|
||||||
class="btn btn-sm btn-primary"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-check-circle mr-1"></i>
|
|
||||||
Validate
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="text-center py-8">
|
|
||||||
<i class="mdi mdi-check-all text-6xl text-success opacity-50"></i>
|
|
||||||
<p class="text-lg font-medium mt-2">All responses validated!</p>
|
|
||||||
<p class="text-sm opacity-70">No responses currently need manual validation.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Validation Modal -->
|
|
||||||
<div v-if="validationModal.show" class="modal modal-open">
|
|
||||||
<div class="modal-box">
|
|
||||||
<h3 class="font-bold text-lg mb-4">Validate Response</h3>
|
|
||||||
|
|
||||||
<div v-if="validationModal.response" class="space-y-4">
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<i class="mdi mdi-information-outline"></i>
|
|
||||||
<div>
|
|
||||||
<div class="font-bold">{{ validationModal.response.puzzle_name }}</div>
|
|
||||||
<div class="text-sm">Review and correct the OCR data below</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-3 gap-4">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Cost</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="validationModal.data.validated_cost"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm"
|
|
||||||
:placeholder="validationModal.response.cost || 'Enter cost'"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Cycles</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="validationModal.data.validated_cycles"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm"
|
|
||||||
:placeholder="validationModal.response.cycles || 'Enter cycles'"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Area</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="validationModal.data.validated_area"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm"
|
|
||||||
:placeholder="validationModal.response.area || 'Enter area'"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-action">
|
|
||||||
<button @click="closeValidationModal" class="btn btn-ghost">Cancel</button>
|
|
||||||
<button
|
|
||||||
@click="submitValidation"
|
|
||||||
class="btn btn-primary"
|
|
||||||
:disabled="isValidating"
|
|
||||||
>
|
|
||||||
<span v-if="isValidating" class="loading loading-spinner loading-sm"></span>
|
|
||||||
{{ isValidating ? 'Validating...' : 'Validate' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-backdrop" @click="closeValidationModal"></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { apiService } from '../services/apiService'
|
|
||||||
import type { PuzzleResponse } from '../types'
|
|
||||||
|
|
||||||
// Reactive data
|
|
||||||
const stats = ref({
|
|
||||||
total_submissions: 0,
|
|
||||||
total_responses: 0,
|
|
||||||
needs_validation: 0,
|
|
||||||
validated_submissions: 0,
|
|
||||||
validation_rate: 0
|
|
||||||
})
|
|
||||||
|
|
||||||
const responsesNeedingValidation = ref<PuzzleResponse[]>([])
|
|
||||||
const isLoading = ref(false)
|
|
||||||
const isValidating = ref(false)
|
|
||||||
|
|
||||||
const validationModal = ref({
|
|
||||||
show: false,
|
|
||||||
response: null as PuzzleResponse | null,
|
|
||||||
data: {
|
|
||||||
validated_cost: '',
|
|
||||||
validated_cycles: '',
|
|
||||||
validated_area: ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Methods
|
|
||||||
const loadData = async () => {
|
|
||||||
try {
|
|
||||||
isLoading.value = true
|
|
||||||
|
|
||||||
// Load stats (skip if endpoint doesn't exist)
|
|
||||||
try {
|
|
||||||
const statsResponse = await apiService.getStats()
|
|
||||||
if (statsResponse.data) {
|
|
||||||
stats.value = statsResponse.data
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Stats endpoint not available:', error)
|
|
||||||
// Set default stats
|
|
||||||
stats.value = {
|
|
||||||
total_submissions: 0,
|
|
||||||
total_responses: 0,
|
|
||||||
needs_validation: 0,
|
|
||||||
validated_submissions: 0,
|
|
||||||
validation_rate: 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load responses needing validation
|
|
||||||
const responsesResponse = await apiService.getResponsesNeedingValidation()
|
|
||||||
if (responsesResponse.data) {
|
|
||||||
responsesNeedingValidation.value = responsesResponse.data
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load admin data:', error)
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openValidationModal = (response: PuzzleResponse) => {
|
|
||||||
validationModal.value.response = response
|
|
||||||
validationModal.value.data = {
|
|
||||||
validated_cost: response.cost || '',
|
|
||||||
validated_cycles: response.cycles || '',
|
|
||||||
validated_area: response.area || ''
|
|
||||||
}
|
|
||||||
validationModal.value.show = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeValidationModal = () => {
|
|
||||||
validationModal.value.show = false
|
|
||||||
validationModal.value.response = null
|
|
||||||
validationModal.value.data = {
|
|
||||||
validated_cost: '',
|
|
||||||
validated_cycles: '',
|
|
||||||
validated_area: ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitValidation = async () => {
|
|
||||||
if (!validationModal.value.response?.id) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
isValidating.value = true
|
|
||||||
|
|
||||||
const response = await apiService.validateResponse(
|
|
||||||
validationModal.value.response.id,
|
|
||||||
validationModal.value.data
|
|
||||||
)
|
|
||||||
|
|
||||||
if (response.error) {
|
|
||||||
alert(`Validation failed: ${response.error}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove from validation list
|
|
||||||
responsesNeedingValidation.value = responsesNeedingValidation.value.filter(
|
|
||||||
r => r.id !== validationModal.value.response?.id
|
|
||||||
)
|
|
||||||
|
|
||||||
// Update stats
|
|
||||||
stats.value.needs_validation = Math.max(0, stats.value.needs_validation - 1)
|
|
||||||
|
|
||||||
closeValidationModal()
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Validation error:', error)
|
|
||||||
alert('Validation failed')
|
|
||||||
} finally {
|
|
||||||
isValidating.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lifecycle
|
|
||||||
onMounted(() => {
|
|
||||||
loadData()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Expose refresh method
|
|
||||||
defineExpose({
|
|
||||||
refresh: loadData
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="form-control w-full">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-medium">Upload Solution Files</span>
|
|
||||||
<span class="label-text-alt text-xs">Images or GIFs only</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="border-2 border-dashed border-base-300 rounded-lg p-6 text-center hover:border-primary transition-colors duration-300"
|
|
||||||
:class="{ 'border-primary bg-primary/5': isDragOver }"
|
|
||||||
@drop="handleDrop"
|
|
||||||
@dragover.prevent="isDragOver = true"
|
|
||||||
@dragleave="isDragOver = false"
|
|
||||||
@dragenter.prevent
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref="fileInput"
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
accept="image/*,.gif"
|
|
||||||
class="hidden"
|
|
||||||
@change="handleFileSelect"
|
|
||||||
>
|
|
||||||
|
|
||||||
<div v-if="files.length === 0" class="space-y-4">
|
|
||||||
<div class="mx-auto w-12 h-12 text-base-content/40 flex items-center justify-center">
|
|
||||||
<i class="mdi mdi-cloud-upload text-5xl"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-base-content/70 mb-2">Drop your files here or</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
@click="fileInput?.click()"
|
|
||||||
class="btn btn-primary btn-sm"
|
|
||||||
>
|
|
||||||
Choose Files
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-base-content/50">
|
|
||||||
Supported formats: JPG, PNG, GIF (max 256MB each)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="space-y-4">
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
<div
|
|
||||||
v-for="(file, index) in files"
|
|
||||||
:key="index"
|
|
||||||
class="relative group"
|
|
||||||
>
|
|
||||||
<div class="aspect-square rounded-lg overflow-hidden bg-base-200">
|
|
||||||
<img
|
|
||||||
:src="file.preview"
|
|
||||||
:alt="file.file.name"
|
|
||||||
class="w-full h-full object-cover"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center">
|
|
||||||
<button
|
|
||||||
@click="removeFile(index)"
|
|
||||||
class="btn btn-error btn-sm btn-circle"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-close"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-2">
|
|
||||||
<p class="text-xs font-medium truncate">{{ file.file.name }}</p>
|
|
||||||
<p class="text-xs text-base-content/60">
|
|
||||||
{{ formatFileSize(file.file.size) }} • {{ file.type.toUpperCase() }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- OCR Status and Results -->
|
|
||||||
<div v-if="file.ocrProcessing" class="mt-1 flex items-center gap-1">
|
|
||||||
<span class="loading loading-spinner loading-xs"></span>
|
|
||||||
<span class="text-xs text-info">Extracting puzzle data...</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="file.ocrError" class="mt-1">
|
|
||||||
<p class="text-xs text-error">{{ file.ocrError }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="file.ocrData" class="mt-1 space-y-1">
|
|
||||||
<div class="text-xs flex items-center justify-between">
|
|
||||||
<span class="font-medium text-success">✓ OCR Complete</span>
|
|
||||||
<button
|
|
||||||
@click="retryOCR(file)"
|
|
||||||
class="btn btn-xs btn-ghost"
|
|
||||||
title="Retry OCR"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-refresh"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="text-xs space-y-1 bg-base-200 p-2 rounded">
|
|
||||||
<div v-if="file.ocrData.puzzle">
|
|
||||||
<strong>Puzzle:</strong> {{ file.ocrData.puzzle }}
|
|
||||||
</div>
|
|
||||||
<div v-if="file.ocrData.cost">
|
|
||||||
<strong>Cost:</strong> {{ file.ocrData.cost }}
|
|
||||||
</div>
|
|
||||||
<div v-if="file.ocrData.cycles">
|
|
||||||
<strong>Cycles:</strong> {{ file.ocrData.cycles }}
|
|
||||||
</div>
|
|
||||||
<div v-if="file.ocrData.area">
|
|
||||||
<strong>Area:</strong> {{ file.ocrData.area }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Manual OCR trigger for non-auto detected files -->
|
|
||||||
<div v-else-if="!file.ocrProcessing && !file.ocrError && !file.ocrData" class="mt-1">
|
|
||||||
<button
|
|
||||||
@click="processOCR(file)"
|
|
||||||
class="btn btn-xs btn-outline"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-text-recognition"></i>
|
|
||||||
Extract Puzzle Data
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
@click="fileInput?.click()"
|
|
||||||
class="btn btn-outline btn-sm"
|
|
||||||
>
|
|
||||||
Add More Files
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="error" class="label">
|
|
||||||
<span class="label-text-alt text-error">{{ error }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, watch, nextTick } from 'vue'
|
|
||||||
import { ocrService } from '../services/ocrService'
|
|
||||||
import type { SubmissionFile, SteamCollectionItem } from '@/types'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
modelValue: SubmissionFile[]
|
|
||||||
puzzles?: SteamCollectionItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Emits {
|
|
||||||
'update:modelValue': [files: SubmissionFile[]]
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
const emit = defineEmits<Emits>()
|
|
||||||
|
|
||||||
const fileInput = ref<HTMLInputElement>()
|
|
||||||
const isDragOver = ref(false)
|
|
||||||
const error = ref('')
|
|
||||||
const files = ref<SubmissionFile[]>([])
|
|
||||||
|
|
||||||
// Watch for external changes to modelValue
|
|
||||||
watch(() => props.modelValue, (newFiles) => {
|
|
||||||
files.value = newFiles
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
// Watch for internal changes and emit
|
|
||||||
watch(files, (newFiles) => {
|
|
||||||
emit('update:modelValue', newFiles)
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
// Watch for puzzle changes and update OCR service
|
|
||||||
watch(() => props.puzzles, (newPuzzles) => {
|
|
||||||
if (newPuzzles && newPuzzles.length > 0) {
|
|
||||||
const puzzleNames = newPuzzles.map(puzzle => puzzle.title)
|
|
||||||
ocrService.setAvailablePuzzleNames(puzzleNames)
|
|
||||||
}
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
const handleFileSelect = (event: Event) => {
|
|
||||||
const target = event.target as HTMLInputElement
|
|
||||||
if (target.files) {
|
|
||||||
processFiles(Array.from(target.files))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDrop = (event: DragEvent) => {
|
|
||||||
event.preventDefault()
|
|
||||||
isDragOver.value = false
|
|
||||||
|
|
||||||
if (event.dataTransfer?.files) {
|
|
||||||
processFiles(Array.from(event.dataTransfer.files))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const processFiles = async (newFiles: File[]) => {
|
|
||||||
error.value = ''
|
|
||||||
|
|
||||||
for (const file of newFiles) {
|
|
||||||
if (!isValidFile(file)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const preview = await createPreview(file)
|
|
||||||
const fileType = file.type.startsWith('image/gif') ? 'gif' : 'image'
|
|
||||||
|
|
||||||
const submissionFile: SubmissionFile = {
|
|
||||||
file,
|
|
||||||
preview,
|
|
||||||
type: fileType,
|
|
||||||
ocrProcessing: false,
|
|
||||||
ocrError: undefined,
|
|
||||||
ocrData: undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
files.value.push(submissionFile)
|
|
||||||
|
|
||||||
// Start OCR processing for Opus Magnum images (with delay to ensure reactivity)
|
|
||||||
if (isOpusMagnumImage(file)) {
|
|
||||||
nextTick(() => {
|
|
||||||
processOCR(submissionFile)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
error.value = `Failed to process ${file.name}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValidFile = (file: File): boolean => {
|
|
||||||
// Check file type
|
|
||||||
if (!file.type.startsWith('image/')) {
|
|
||||||
error.value = `${file.name} is not a valid image file`
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check file size (256MB limit)
|
|
||||||
if (file.size > 256 * 1024 * 1024) {
|
|
||||||
error.value = `${file.name} is too large (max 256MB)`
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const createPreview = (file: File): Promise<string> => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = (e) => resolve(e.target?.result as string)
|
|
||||||
reader.onerror = reject
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeFile = (index: number) => {
|
|
||||||
files.value.splice(index, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatFileSize = (bytes: number): string => {
|
|
||||||
if (bytes === 0) return '0 Bytes'
|
|
||||||
|
|
||||||
const k = 1024
|
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
||||||
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
const isOpusMagnumImage = (file: File): boolean => {
|
|
||||||
// Basic heuristic - could be enhanced with actual image analysis
|
|
||||||
return file.type.startsWith('image/') && file.size > 50000 // > 50KB likely screenshot
|
|
||||||
}
|
|
||||||
|
|
||||||
const processOCR = async (submissionFile: SubmissionFile) => {
|
|
||||||
// Find the file in the reactive array to ensure proper reactivity
|
|
||||||
const fileIndex = files.value.findIndex(f => f.file === submissionFile.file)
|
|
||||||
if (fileIndex === -1) return
|
|
||||||
|
|
||||||
// Update the reactive array directly
|
|
||||||
files.value[fileIndex].ocrProcessing = true
|
|
||||||
files.value[fileIndex].ocrError = undefined
|
|
||||||
files.value[fileIndex].ocrData = undefined
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('Starting OCR processing for:', submissionFile.file.name)
|
|
||||||
await ocrService.initialize()
|
|
||||||
const ocrData = await ocrService.extractOpusMagnumData(submissionFile.file)
|
|
||||||
console.log('OCR completed:', ocrData)
|
|
||||||
|
|
||||||
// Force reactivity update
|
|
||||||
await nextTick()
|
|
||||||
files.value[fileIndex].ocrData = ocrData
|
|
||||||
await nextTick()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('OCR processing failed:', error)
|
|
||||||
files.value[fileIndex].ocrError = 'Failed to extract puzzle data'
|
|
||||||
} finally {
|
|
||||||
files.value[fileIndex].ocrProcessing = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const retryOCR = (submissionFile: SubmissionFile) => {
|
|
||||||
processOCR(submissionFile)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="card bg-base-100 shadow-xl">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title text-xl mb-6">
|
|
||||||
<i class="mdi mdi-check-circle text-2xl text-primary"></i>
|
|
||||||
Submit Solution
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<form @submit.prevent="handleSubmit" class="space-y-6">
|
|
||||||
<!-- Detected Puzzles Summary -->
|
|
||||||
<div v-if="Object.keys(responsesByPuzzle).length > 0" class="alert alert-info">
|
|
||||||
<i class="mdi mdi-information-outline text-xl"></i>
|
|
||||||
<div class="flex-1">
|
|
||||||
<h4 class="font-bold">Detected Puzzles ({{ Object.keys(responsesByPuzzle).length }})</h4>
|
|
||||||
<div class="text-sm space-y-1 mt-1">
|
|
||||||
<div v-for="(data, puzzleName) in responsesByPuzzle" :key="puzzleName" class="flex justify-between">
|
|
||||||
<span>{{ puzzleName }}</span>
|
|
||||||
<span class="badge badge-ghost badge-sm">{{ data.files.length }} file(s)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- File Upload -->
|
|
||||||
<FileUpload v-model="submissionFiles" :puzzles="puzzles" />
|
|
||||||
|
|
||||||
<!-- Notes -->
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-medium">Notes (Optional)</span>
|
|
||||||
<span class="label-text-alt">{{ notesLength }}/500</span>
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
v-model="notes"
|
|
||||||
class="textarea textarea-bordered h-24 resize-none"
|
|
||||||
placeholder="Add any notes about your solution, approach, or interesting findings..."
|
|
||||||
maxlength="500"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Submit Button -->
|
|
||||||
<div class="card-actions justify-end">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn btn-primary"
|
|
||||||
:disabled="isSubmitting"
|
|
||||||
>
|
|
||||||
<span v-if="isSubmitting" class="loading loading-spinner loading-sm"></span>
|
|
||||||
{{ isSubmitting ? 'Submitting...' : 'Submit Solution' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import FileUpload from './FileUpload.vue'
|
|
||||||
import type { SteamCollectionItem, SubmissionFile } from '@/types'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
puzzles: SteamCollectionItem[]
|
|
||||||
findPuzzleByName: (name: string) => SteamCollectionItem | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Emits {
|
|
||||||
submit: [submissionData: { files: SubmissionFile[], notes?: string }]
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
const emit = defineEmits<Emits>()
|
|
||||||
|
|
||||||
const submissionFiles = ref<SubmissionFile[]>([])
|
|
||||||
const notes = ref('')
|
|
||||||
const isSubmitting = ref(false)
|
|
||||||
|
|
||||||
const notesLength = computed(() => notes.value.length)
|
|
||||||
|
|
||||||
const canSubmit = computed(() => {
|
|
||||||
return submissionFiles.value.length > 0 &&
|
|
||||||
!isSubmitting.value
|
|
||||||
})
|
|
||||||
|
|
||||||
// Group files by detected puzzle
|
|
||||||
const responsesByPuzzle = computed(() => {
|
|
||||||
const grouped: Record<string, { puzzle: SteamCollectionItem | null, files: SubmissionFile[] }> = {}
|
|
||||||
|
|
||||||
submissionFiles.value.forEach(file => {
|
|
||||||
if (file.ocrData?.puzzle) {
|
|
||||||
const puzzleName = file.ocrData.puzzle
|
|
||||||
if (!grouped[puzzleName]) {
|
|
||||||
grouped[puzzleName] = {
|
|
||||||
puzzle: props.findPuzzleByName(puzzleName),
|
|
||||||
files: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
grouped[puzzleName].files.push(file)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return grouped
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!canSubmit.value) return
|
|
||||||
|
|
||||||
isSubmitting.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Emit the files and notes for the parent to handle API submission
|
|
||||||
emit('submit', {
|
|
||||||
files: submissionFiles.value,
|
|
||||||
notes: notes.value.trim() || undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
// Reset form
|
|
||||||
submissionFiles.value = []
|
|
||||||
notes.value = ''
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Submission error:', error)
|
|
||||||
} finally {
|
|
||||||
isSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { createApp } from 'vue'
|
|
||||||
import App from '@/App.vue'
|
|
||||||
import './style.css'
|
|
||||||
|
|
||||||
createApp(App).mount('#app')
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
import { createWorker } from 'tesseract.js';
|
|
||||||
|
|
||||||
export interface OpusMagnumData {
|
|
||||||
puzzle: string;
|
|
||||||
cost: string;
|
|
||||||
cycles: string;
|
|
||||||
area: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OCRRegion {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class OpusMagnumOCRService {
|
|
||||||
private worker: Tesseract.Worker | null = null;
|
|
||||||
private availablePuzzleNames: string[] = [];
|
|
||||||
|
|
||||||
// Regions based on main.py coordinates (adjusted for web usage)
|
|
||||||
private readonly regions: Record<string, OCRRegion> = {
|
|
||||||
puzzle: { x: 15, y: 600, width: 330, height: 28 },
|
|
||||||
cost: { x: 412, y: 603, width: 65, height: 22 },
|
|
||||||
cycles: { x: 577, y: 603, width: 65, height: 22 },
|
|
||||||
area: { x: 739, y: 603, width: 65, height: 22 }
|
|
||||||
};
|
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
|
||||||
if (this.worker) return;
|
|
||||||
|
|
||||||
this.worker = await createWorker('eng');
|
|
||||||
await this.worker.setParameters({
|
|
||||||
tessedit_ocr_engine_mode: '3',
|
|
||||||
tessedit_pageseg_mode: 7 as any
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the list of available puzzle names for better OCR matching
|
|
||||||
*/
|
|
||||||
setAvailablePuzzleNames(puzzleNames: string[]): void {
|
|
||||||
this.availablePuzzleNames = puzzleNames;
|
|
||||||
}
|
|
||||||
|
|
||||||
async extractOpusMagnumData(imageFile: File): Promise<OpusMagnumData> {
|
|
||||||
if (!this.worker) {
|
|
||||||
await this.initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert file to image element for canvas processing
|
|
||||||
const imageUrl = URL.createObjectURL(imageFile);
|
|
||||||
const img = new Image();
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
img.onload = async () => {
|
|
||||||
try {
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
const ctx = canvas.getContext('2d')!;
|
|
||||||
|
|
||||||
canvas.width = img.width;
|
|
||||||
canvas.height = img.height;
|
|
||||||
ctx.drawImage(img, 0, 0);
|
|
||||||
|
|
||||||
// Extract text from each region
|
|
||||||
const results: Partial<OpusMagnumData> = {};
|
|
||||||
|
|
||||||
for (const [key, region] of Object.entries(this.regions)) {
|
|
||||||
const regionCanvas = document.createElement('canvas');
|
|
||||||
const regionCtx = regionCanvas.getContext('2d')!;
|
|
||||||
|
|
||||||
regionCanvas.width = region.width;
|
|
||||||
regionCanvas.height = region.height;
|
|
||||||
|
|
||||||
// Extract region from main image
|
|
||||||
regionCtx.drawImage(
|
|
||||||
canvas,
|
|
||||||
region.x, region.y, region.width, region.height,
|
|
||||||
0, 0, region.width, region.height
|
|
||||||
);
|
|
||||||
|
|
||||||
// Convert to grayscale and invert (similar to main.py processing)
|
|
||||||
const imageData = regionCtx.getImageData(0, 0, region.width, region.height);
|
|
||||||
this.preprocessImage(imageData);
|
|
||||||
regionCtx.putImageData(imageData, 0, 0);
|
|
||||||
|
|
||||||
// Configure OCR based on content type
|
|
||||||
if (key === 'cost') {
|
|
||||||
// Cost field has digits + 'G' for gold (content type: 'digits_with_6')
|
|
||||||
await this.worker!.setParameters({
|
|
||||||
tessedit_char_whitelist: '0123456789G'
|
|
||||||
});
|
|
||||||
} else if (key === 'cycles' || key === 'area') {
|
|
||||||
// Pure digits (content type: 'digits')
|
|
||||||
await this.worker!.setParameters({
|
|
||||||
tessedit_char_whitelist: '0123456789'
|
|
||||||
});
|
|
||||||
} else if (key === 'puzzle') {
|
|
||||||
// Puzzle name - allow alphanumeric, spaces, and dashes
|
|
||||||
await this.worker!.setParameters({
|
|
||||||
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 -'
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Default - allow all characters
|
|
||||||
await this.worker!.setParameters({
|
|
||||||
tessedit_char_whitelist: ''
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform OCR on the region
|
|
||||||
const { data: { text } } = await this.worker!.recognize(regionCanvas);
|
|
||||||
let cleanText = text.trim();
|
|
||||||
|
|
||||||
// Post-process based on field type
|
|
||||||
if (key === 'cost') {
|
|
||||||
// Handle common OCR misreadings where G is read as 6
|
|
||||||
// If the text ends with 6 and looks like it should be G, remove it
|
|
||||||
if (cleanText.endsWith('6') && cleanText.length > 1) {
|
|
||||||
// Check if removing the last character gives a reasonable cost value
|
|
||||||
const withoutLast = cleanText.slice(0, -1);
|
|
||||||
if (/^\d+$/.test(withoutLast)) {
|
|
||||||
cleanText = withoutLast;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Remove any trailing G characters
|
|
||||||
cleanText = cleanText.replace(/G+$/g, '');
|
|
||||||
// Ensure only digits remain
|
|
||||||
cleanText = cleanText.replace(/[^0-9]/g, '');
|
|
||||||
} else if (key === 'cycles' || key === 'area') {
|
|
||||||
// Ensure only digits remain
|
|
||||||
cleanText = cleanText.replace(/[^0-9]/g, '');
|
|
||||||
} else if (key === 'puzzle') {
|
|
||||||
// Post-process puzzle names with fuzzy matching
|
|
||||||
cleanText = this.findBestPuzzleMatch(cleanText);
|
|
||||||
}
|
|
||||||
|
|
||||||
results[key as keyof OpusMagnumData] = cleanText;
|
|
||||||
}
|
|
||||||
|
|
||||||
URL.revokeObjectURL(imageUrl);
|
|
||||||
|
|
||||||
resolve({
|
|
||||||
puzzle: results.puzzle || '',
|
|
||||||
cost: results.cost || '',
|
|
||||||
cycles: results.cycles || '',
|
|
||||||
area: results.area || ''
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
URL.revokeObjectURL(imageUrl);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
img.onerror = () => {
|
|
||||||
URL.revokeObjectURL(imageUrl);
|
|
||||||
reject(new Error('Failed to load image'));
|
|
||||||
};
|
|
||||||
|
|
||||||
img.src = imageUrl;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private preprocessImage(imageData: ImageData): void {
|
|
||||||
// Convert to grayscale and invert (similar to cv2.bitwise_not in main.py)
|
|
||||||
const data = imageData.data;
|
|
||||||
|
|
||||||
for (let i = 0; i < data.length; i += 4) {
|
|
||||||
// Convert to grayscale
|
|
||||||
const gray = Math.round(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]);
|
|
||||||
|
|
||||||
// Invert the grayscale value
|
|
||||||
const inverted = 255 - gray;
|
|
||||||
|
|
||||||
data[i] = inverted; // Red
|
|
||||||
data[i + 1] = inverted; // Green
|
|
||||||
data[i + 2] = inverted; // Blue
|
|
||||||
// Alpha channel (data[i + 3]) remains unchanged
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate Levenshtein distance between two strings
|
|
||||||
*/
|
|
||||||
private levenshteinDistance(str1: string, str2: string): number {
|
|
||||||
const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null));
|
|
||||||
|
|
||||||
for (let i = 0; i <= str1.length; i++) matrix[0][i] = i;
|
|
||||||
for (let j = 0; j <= str2.length; j++) matrix[j][0] = j;
|
|
||||||
|
|
||||||
for (let j = 1; j <= str2.length; j++) {
|
|
||||||
for (let i = 1; i <= str1.length; i++) {
|
|
||||||
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
|
|
||||||
matrix[j][i] = Math.min(
|
|
||||||
matrix[j][i - 1] + 1, // deletion
|
|
||||||
matrix[j - 1][i] + 1, // insertion
|
|
||||||
matrix[j - 1][i - 1] + indicator // substitution
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return matrix[str2.length][str1.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the best matching puzzle name from available options
|
|
||||||
*/
|
|
||||||
private findBestPuzzleMatch(ocrText: string): string {
|
|
||||||
if (!this.availablePuzzleNames.length) {
|
|
||||||
return ocrText.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanedOcr = ocrText.trim();
|
|
||||||
|
|
||||||
// First try exact match (case insensitive)
|
|
||||||
const exactMatch = this.availablePuzzleNames.find(
|
|
||||||
name => name.toLowerCase() === cleanedOcr.toLowerCase()
|
|
||||||
);
|
|
||||||
if (exactMatch) return exactMatch;
|
|
||||||
|
|
||||||
// Then try fuzzy matching
|
|
||||||
let bestMatch = cleanedOcr;
|
|
||||||
let bestScore = Infinity;
|
|
||||||
|
|
||||||
for (const puzzleName of this.availablePuzzleNames) {
|
|
||||||
// Calculate similarity scores
|
|
||||||
const distance = this.levenshteinDistance(
|
|
||||||
cleanedOcr.toLowerCase(),
|
|
||||||
puzzleName.toLowerCase()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Normalize by length to get a similarity ratio
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bestMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async terminate(): Promise<void> {
|
|
||||||
if (this.worker) {
|
|
||||||
await this.worker.terminate();
|
|
||||||
this.worker = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Utility method to validate if an image looks like an Opus Magnum screenshot
|
|
||||||
static isValidOpusMagnumImage(file: File): boolean {
|
|
||||||
// Basic validation - could be enhanced with actual image analysis
|
|
||||||
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'];
|
|
||||||
return validTypes.includes(file.type);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug method to visualize OCR regions (similar to main.py debug rectangles)
|
|
||||||
static drawDebugRegions(imageFile: File): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const imageUrl = URL.createObjectURL(imageFile);
|
|
||||||
const img = new Image();
|
|
||||||
|
|
||||||
img.onload = () => {
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
const ctx = canvas.getContext('2d')!;
|
|
||||||
|
|
||||||
canvas.width = img.width;
|
|
||||||
canvas.height = img.height;
|
|
||||||
ctx.drawImage(img, 0, 0);
|
|
||||||
|
|
||||||
// Draw debug rectangles
|
|
||||||
ctx.strokeStyle = '#00ff00';
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
|
|
||||||
const service = new OpusMagnumOCRService();
|
|
||||||
Object.values(service.regions).forEach(region => {
|
|
||||||
ctx.strokeRect(region.x, region.y, region.width, region.height);
|
|
||||||
});
|
|
||||||
|
|
||||||
URL.revokeObjectURL(imageUrl);
|
|
||||||
resolve(canvas.toDataURL());
|
|
||||||
};
|
|
||||||
|
|
||||||
img.onerror = () => {
|
|
||||||
URL.revokeObjectURL(imageUrl);
|
|
||||||
reject(new Error('Failed to load image for debug'));
|
|
||||||
};
|
|
||||||
|
|
||||||
img.src = imageUrl;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Singleton instance for the application
|
|
||||||
export const ocrService = new OpusMagnumOCRService();
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
export interface SteamCollection {
|
|
||||||
id: number
|
|
||||||
steam_id: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
author_name: string
|
|
||||||
total_items: number
|
|
||||||
unique_visitors: number
|
|
||||||
current_favorites: number
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SteamCollectionItem {
|
|
||||||
id: number
|
|
||||||
steam_item_id: string
|
|
||||||
title: string
|
|
||||||
author_name: string
|
|
||||||
description: string
|
|
||||||
tags: string[]
|
|
||||||
order_index: number
|
|
||||||
collection: number
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OpusMagnumData {
|
|
||||||
puzzle: string
|
|
||||||
cost: string
|
|
||||||
cycles: string
|
|
||||||
area: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubmissionFile {
|
|
||||||
file: File
|
|
||||||
preview: string
|
|
||||||
type: 'image' | 'gif'
|
|
||||||
ocrData?: OpusMagnumData
|
|
||||||
ocrProcessing?: boolean
|
|
||||||
ocrError?: string
|
|
||||||
original_filename?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PuzzleResponse {
|
|
||||||
id?: number
|
|
||||||
puzzle: number | SteamCollectionItem
|
|
||||||
puzzle_name: string
|
|
||||||
cost?: string
|
|
||||||
cycles?: string
|
|
||||||
area?: string
|
|
||||||
needs_manual_validation?: boolean
|
|
||||||
ocr_confidence_score?: number
|
|
||||||
validated_cost?: string
|
|
||||||
validated_cycles?: string
|
|
||||||
validated_area?: string
|
|
||||||
final_cost?: string
|
|
||||||
final_cycles?: string
|
|
||||||
final_area?: string
|
|
||||||
files?: SubmissionFile[]
|
|
||||||
created_at?: string
|
|
||||||
updated_at?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Submission {
|
|
||||||
id?: string
|
|
||||||
user?: number | null
|
|
||||||
responses: PuzzleResponse[]
|
|
||||||
notes?: string
|
|
||||||
is_validated?: boolean
|
|
||||||
validated_by?: number | null
|
|
||||||
validated_at?: string | null
|
|
||||||
total_responses?: number
|
|
||||||
needs_validation?: boolean
|
|
||||||
created_at?: string
|
|
||||||
updated_at?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserInfo {
|
|
||||||
id?: number
|
|
||||||
username?: string
|
|
||||||
first_name?: string
|
|
||||||
last_name?: string
|
|
||||||
email?: string
|
|
||||||
is_authenticated: boolean
|
|
||||||
is_staff: boolean
|
|
||||||
is_superuser: boolean
|
|
||||||
cas_groups?: string[]
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot": {
|
|
||||||
"file": "assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
|
||||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot"
|
|
||||||
},
|
|
||||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf": {
|
|
||||||
"file": "assets/materialdesignicons-webfont-B7mPwVP_.ttf",
|
|
||||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf"
|
|
||||||
},
|
|
||||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff": {
|
|
||||||
"file": "assets/materialdesignicons-webfont-PXm3-2wK.woff",
|
|
||||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff"
|
|
||||||
},
|
|
||||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2": {
|
|
||||||
"file": "assets/materialdesignicons-webfont-Dp5v-WZN.woff2",
|
|
||||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2"
|
|
||||||
},
|
|
||||||
"src/main.ts": {
|
|
||||||
"file": "assets/main-CNlI4PW6.js",
|
|
||||||
"name": "main",
|
|
||||||
"src": "src/main.ts",
|
|
||||||
"isEntry": true,
|
|
||||||
"css": [
|
|
||||||
"assets/main-HDjkw-xK.css"
|
|
||||||
],
|
|
||||||
"assets": [
|
|
||||||
"assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
|
||||||
"assets/materialdesignicons-webfont-Dp5v-WZN.woff2",
|
|
||||||
"assets/materialdesignicons-webfont-PXm3-2wK.woff",
|
|
||||||
"assets/materialdesignicons-webfont-B7mPwVP_.ttf"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot": {
|
|
||||||
"file": "assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
|
||||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot"
|
|
||||||
},
|
|
||||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf": {
|
|
||||||
"file": "assets/materialdesignicons-webfont-B7mPwVP_.ttf",
|
|
||||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf"
|
|
||||||
},
|
|
||||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff": {
|
|
||||||
"file": "assets/materialdesignicons-webfont-PXm3-2wK.woff",
|
|
||||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff"
|
|
||||||
},
|
|
||||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2": {
|
|
||||||
"file": "assets/materialdesignicons-webfont-Dp5v-WZN.woff2",
|
|
||||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2"
|
|
||||||
},
|
|
||||||
"src/main.ts": {
|
|
||||||
"file": "assets/main-CNlI4PW6.js",
|
|
||||||
"name": "main",
|
|
||||||
"src": "src/main.ts",
|
|
||||||
"isEntry": true,
|
|
||||||
"css": [
|
|
||||||
"assets/main-HDjkw-xK.css"
|
|
||||||
],
|
|
||||||
"assets": [
|
|
||||||
"assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
|
||||||
"assets/materialdesignicons-webfont-Dp5v-WZN.woff2",
|
|
||||||
"assets/materialdesignicons-webfont-PXm3-2wK.woff",
|
|
||||||
"assets/materialdesignicons-webfont-B7mPwVP_.ttf"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,6 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class SubmissionsConfig(AppConfig):
|
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
|
||||||
name = 'submissions'
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-29 00:11
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Collection',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('url', models.URLField()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='SteamCollection',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('steam_id', models.CharField(help_text='Steam collection ID from URL', max_length=50, unique=True)),
|
|
||||||
('url', models.URLField(help_text='Full Steam Workshop collection URL')),
|
|
||||||
('title', models.CharField(blank=True, help_text='Collection title', max_length=255)),
|
|
||||||
('description', models.TextField(blank=True, help_text='Collection description')),
|
|
||||||
('author_name', models.CharField(blank=True, help_text='Steam username of collection creator', max_length=100)),
|
|
||||||
('author_steam_id', models.CharField(blank=True, help_text='Steam ID of collection creator', max_length=50)),
|
|
||||||
('total_items', models.PositiveIntegerField(default=0, help_text='Number of items in collection')),
|
|
||||||
('unique_visitors', models.PositiveIntegerField(default=0, help_text='Number of unique visitors')),
|
|
||||||
('current_favorites', models.PositiveIntegerField(default=0, help_text='Current number of favorites')),
|
|
||||||
('total_favorites', models.PositiveIntegerField(default=0, help_text='Total unique favorites')),
|
|
||||||
('steam_created_date', models.DateTimeField(blank=True, help_text='When collection was created on Steam', null=True)),
|
|
||||||
('steam_updated_date', models.DateTimeField(blank=True, help_text='When collection was last updated on Steam', null=True)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('last_fetched', models.DateTimeField(blank=True, help_text='When data was last fetched from Steam', null=True)),
|
|
||||||
('is_active', models.BooleanField(default=True, help_text='Whether this collection is actively tracked')),
|
|
||||||
('fetch_error', models.TextField(blank=True, help_text='Last error encountered when fetching data')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Steam Collection',
|
|
||||||
'verbose_name_plural': 'Steam Collections',
|
|
||||||
'ordering': ['-created_at'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='SteamCollectionItem',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('steam_item_id', models.CharField(help_text='Steam Workshop item ID', max_length=50)),
|
|
||||||
('title', models.CharField(blank=True, help_text='Item title', max_length=255)),
|
|
||||||
('author_name', models.CharField(blank=True, help_text='Steam username of item creator', max_length=100)),
|
|
||||||
('author_steam_id', models.CharField(blank=True, help_text='Steam ID of item creator', max_length=50)),
|
|
||||||
('description', models.TextField(blank=True, help_text='Item description')),
|
|
||||||
('tags', models.JSONField(blank=True, default=list, help_text='Item tags as JSON array')),
|
|
||||||
('order_index', models.PositiveIntegerField(default=0, help_text='Order of item in collection')),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='submissions.steamcollection')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Steam Collection Item',
|
|
||||||
'verbose_name_plural': 'Steam Collection Items',
|
|
||||||
'ordering': ['collection', 'order_index'],
|
|
||||||
'unique_together': {('collection', 'steam_item_id')},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-29 00:19
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('submissions', '0002_delete_collection'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='SteamAPIKey',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('name', models.CharField(help_text="Descriptive name for this API key (e.g., 'Production Key', 'Development Key')", max_length=100, unique=True)),
|
|
||||||
('api_key', models.CharField(help_text='Steam Web API key from https://steamcommunity.com/dev/apikey', max_length=64)),
|
|
||||||
('is_active', models.BooleanField(default=True, help_text='Whether this API key should be used')),
|
|
||||||
('description', models.TextField(blank=True, help_text='Optional description or notes about this API key')),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('last_used', models.DateTimeField(blank=True, help_text='When this API key was last used', null=True)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Steam API Key',
|
|
||||||
'verbose_name_plural': 'Steam API Keys',
|
|
||||||
'ordering': ['-is_active', 'name'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
-82
@@ -1,82 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-29 01:32
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
import submissions.models
|
|
||||||
import uuid
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('submissions', '0003_steamapikey'),
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Submission',
|
|
||||||
fields=[
|
|
||||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
|
||||||
('notes', models.TextField(blank=True, help_text='Optional notes about the submission')),
|
|
||||||
('is_validated', models.BooleanField(default=False, help_text='Whether this submission has been manually validated')),
|
|
||||||
('validated_at', models.DateTimeField(blank=True, help_text='When this submission was validated', null=True)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('user', models.ForeignKey(blank=True, help_text='User who made the submission (null for anonymous)', null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
|
||||||
('validated_by', models.ForeignKey(blank=True, help_text='Admin user who validated this submission', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='validated_submissions', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Submission',
|
|
||||||
'verbose_name_plural': 'Submissions',
|
|
||||||
'ordering': ['-created_at'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='PuzzleResponse',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('puzzle_name', models.CharField(help_text='Puzzle name as detected by OCR', max_length=255)),
|
|
||||||
('cost', models.CharField(blank=True, help_text='Cost value from OCR', max_length=20)),
|
|
||||||
('cycles', models.CharField(blank=True, help_text='Cycles value from OCR', max_length=20)),
|
|
||||||
('area', models.CharField(blank=True, help_text='Area value from OCR', max_length=20)),
|
|
||||||
('needs_manual_validation', models.BooleanField(default=False, help_text='Whether OCR failed and manual validation is needed')),
|
|
||||||
('ocr_confidence_score', models.FloatField(blank=True, help_text='OCR confidence score (0.0 to 1.0)', null=True)),
|
|
||||||
('validated_cost', models.CharField(blank=True, help_text='Manually validated cost value', max_length=20)),
|
|
||||||
('validated_cycles', models.CharField(blank=True, help_text='Manually validated cycles value', max_length=20)),
|
|
||||||
('validated_area', models.CharField(blank=True, help_text='Manually validated area value', max_length=20)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('puzzle', models.ForeignKey(help_text='The puzzle this response is for', on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='submissions.steamcollectionitem')),
|
|
||||||
('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='submissions.submission')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Puzzle Response',
|
|
||||||
'verbose_name_plural': 'Puzzle Responses',
|
|
||||||
'ordering': ['submission', 'puzzle__order_index'],
|
|
||||||
'unique_together': {('submission', 'puzzle')},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='SubmissionFile',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('file', models.FileField(help_text='Uploaded file (image/gif)', upload_to=submissions.models.submission_file_upload_path)),
|
|
||||||
('original_filename', models.CharField(help_text='Original filename as uploaded by user', max_length=255)),
|
|
||||||
('file_size', models.PositiveIntegerField(help_text='File size in bytes')),
|
|
||||||
('content_type', models.CharField(help_text='MIME type of the file', max_length=100)),
|
|
||||||
('ocr_processed', models.BooleanField(default=False, help_text='Whether OCR has been processed for this file')),
|
|
||||||
('ocr_raw_data', models.JSONField(blank=True, help_text='Raw OCR data as JSON', null=True)),
|
|
||||||
('ocr_error', models.TextField(blank=True, help_text='OCR processing error message')),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('response', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='submissions.puzzleresponse')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Submission File',
|
|
||||||
'verbose_name_plural': 'Submission Files',
|
|
||||||
'ordering': ['response', 'created_at'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-29 01:44
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('submissions', '0004_submission_puzzleresponse_submissionfile'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='submission',
|
|
||||||
name='notes',
|
|
||||||
field=models.TextField(blank=True, help_text='Optional notes about the submission', null=True),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from django.shortcuts import render
|
|
||||||
|
|
||||||
# Create your views here.
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
{"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"}
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth.admin import UserAdmin
|
||||||
|
from .models import CustomUser
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(CustomUser)
|
||||||
|
class CustomUserAdmin(UserAdmin):
|
||||||
|
"""Admin interface for CustomUser."""
|
||||||
|
|
||||||
|
# Add custom fields to the user admin
|
||||||
|
fieldsets = UserAdmin.fieldsets + (
|
||||||
|
(
|
||||||
|
"CAS Information",
|
||||||
|
{
|
||||||
|
"fields": ("cas_user_id", "cas_groups", "cas_attributes"),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add custom fields to the list display
|
||||||
|
list_display = UserAdmin.list_display + ("cas_user_id", "get_cas_groups_display")
|
||||||
|
|
||||||
|
# Add search fields
|
||||||
|
search_fields = UserAdmin.search_fields + ("cas_user_id",)
|
||||||
|
|
||||||
|
# Add filters
|
||||||
|
list_filter = UserAdmin.list_filter + ("cas_groups",)
|
||||||
|
|
||||||
|
# Make CAS fields readonly in admin
|
||||||
|
readonly_fields = ("cas_user_id", "cas_groups", "cas_attributes")
|
||||||
|
|
||||||
|
def get_cas_groups_display(self, obj):
|
||||||
|
"""Display CAS groups in admin list."""
|
||||||
|
return obj.get_cas_groups_display()
|
||||||
|
|
||||||
|
get_cas_groups_display.short_description = "CAS Groups"
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AccountsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "accounts"
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-10-28 23:41
|
||||||
|
|
||||||
|
import django.contrib.auth.models
|
||||||
|
import django.contrib.auth.validators
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("auth", "0012_alter_user_first_name_max_length"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="CustomUser",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||||
|
(
|
||||||
|
"last_login",
|
||||||
|
models.DateTimeField(
|
||||||
|
blank=True, null=True, verbose_name="last login"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_superuser",
|
||||||
|
models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Designates that this user has all permissions without explicitly assigning them.",
|
||||||
|
verbose_name="superuser status",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"username",
|
||||||
|
models.CharField(
|
||||||
|
error_messages={
|
||||||
|
"unique": "A user with that username already exists."
|
||||||
|
},
|
||||||
|
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||||
|
max_length=150,
|
||||||
|
unique=True,
|
||||||
|
validators=[
|
||||||
|
django.contrib.auth.validators.UnicodeUsernameValidator()
|
||||||
|
],
|
||||||
|
verbose_name="username",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"first_name",
|
||||||
|
models.CharField(
|
||||||
|
blank=True, max_length=150, verbose_name="first name"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"last_name",
|
||||||
|
models.CharField(
|
||||||
|
blank=True, max_length=150, verbose_name="last name"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"email",
|
||||||
|
models.EmailField(
|
||||||
|
blank=True, max_length=254, verbose_name="email address"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_staff",
|
||||||
|
models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Designates whether the user can log into this admin site.",
|
||||||
|
verbose_name="staff status",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_active",
|
||||||
|
models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
|
||||||
|
verbose_name="active",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"date_joined",
|
||||||
|
models.DateTimeField(
|
||||||
|
default=django.utils.timezone.now, verbose_name="date joined"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"cas_user_id",
|
||||||
|
models.CharField(blank=True, max_length=50, null=True, unique=True),
|
||||||
|
),
|
||||||
|
("cas_groups", models.JSONField(blank=True, default=list)),
|
||||||
|
("cas_attributes", models.JSONField(blank=True, default=dict)),
|
||||||
|
(
|
||||||
|
"groups",
|
||||||
|
models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||||
|
related_name="user_set",
|
||||||
|
related_query_name="user",
|
||||||
|
to="auth.group",
|
||||||
|
verbose_name="groups",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user_permissions",
|
||||||
|
models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Specific permissions for this user.",
|
||||||
|
related_name="user_set",
|
||||||
|
related_query_name="user",
|
||||||
|
to="auth.permission",
|
||||||
|
verbose_name="user permissions",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "user",
|
||||||
|
"verbose_name_plural": "users",
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
managers=[
|
||||||
|
("objects", django.contrib.auth.models.UserManager()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-23 18:11
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("accounts", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="customuser",
|
||||||
|
name="points",
|
||||||
|
field=models.IntegerField(default=1000),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.db import models
|
from django.db import models
|
||||||
import json
|
|
||||||
|
|
||||||
|
|
||||||
class CustomUser(AbstractUser):
|
class CustomUser(AbstractUser):
|
||||||
@@ -15,6 +14,9 @@ class CustomUser(AbstractUser):
|
|||||||
# Additional fields that might come from CAS
|
# Additional fields that might come from CAS
|
||||||
cas_attributes = models.JSONField(default=dict, blank=True)
|
cas_attributes = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
# Market points balance
|
||||||
|
points = models.IntegerField(default=1000)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.username} ({self.cas_user_id})"
|
return f"{self.username} ({self.cas_user_id})"
|
||||||
|
|
||||||
@@ -57,4 +59,3 @@ class CustomUser(AbstractUser):
|
|||||||
self.cas_attributes = attributes
|
self.cas_attributes = attributes
|
||||||
|
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Create your tests here.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Create your views here.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from animations.models import PuzzlePointsFactor, PuzzlePointsValue
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PuzzlePointsFactor)
|
||||||
|
class PuzzlePointsFactorAdmin(admin.ModelAdmin):
|
||||||
|
list_display = [
|
||||||
|
"id",
|
||||||
|
"cost",
|
||||||
|
"cycles",
|
||||||
|
"area",
|
||||||
|
"special_notes",
|
||||||
|
]
|
||||||
|
list_filter = ["cost", "cycles", "area", "special_notes"]
|
||||||
|
search_fields = ["cost", "cycles", "area", "special_notes"]
|
||||||
|
readonly_fields = ["created_at", "updated_at"]
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(
|
||||||
|
"Basic Information",
|
||||||
|
{"fields": ("cost", "cycles", "area")},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Special notes",
|
||||||
|
{
|
||||||
|
"fields": ("special_notes",),
|
||||||
|
"description": "Special notes about the puzzle. May be some extra restriction, etc...",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Metadata",
|
||||||
|
{
|
||||||
|
"fields": ("created_at", "updated_at"),
|
||||||
|
"classes": ("collapse",),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PuzzlePointsValue)
|
||||||
|
class PuzzlePointsValueAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["id", "points"]
|
||||||
|
list_filter = ["points"]
|
||||||
|
search_fields = ["points"]
|
||||||
|
readonly_fields = ["created_at", "updated_at"]
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(
|
||||||
|
"Basic Information",
|
||||||
|
{"fields": ("points",)},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Metadata",
|
||||||
|
{
|
||||||
|
"fields": ("created_at", "updated_at"),
|
||||||
|
"classes": ("collapse",),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
from collections import defaultdict
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from ninja import Router
|
||||||
|
from ninja.errors import HttpError
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
|
from accounts.models import CustomUser
|
||||||
|
from animations.schemas import (
|
||||||
|
RankingSchema,
|
||||||
|
TournamentSubmissionsOut,
|
||||||
|
TournamentPuzzleResultsOut,
|
||||||
|
PuzzleSubmissionsOut,
|
||||||
|
PuzzleResultsOut,
|
||||||
|
PuzzleSubmissionWithRankOut,
|
||||||
|
PuzzlePointsFactorOut,
|
||||||
|
WinnerResponseOut,
|
||||||
|
WinnerFileOut,
|
||||||
|
)
|
||||||
|
from opus_magnum.models import PuzzleResponse, SteamCollectionItem, SteamCollection
|
||||||
|
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("results", response=RankingSchema)
|
||||||
|
def results(request: HttpRequest) -> dict:
|
||||||
|
cache_key = "api:results:results"
|
||||||
|
cached_data = cache.get(cache_key)
|
||||||
|
|
||||||
|
if cached_data is not None:
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
responses_by_userid = defaultdict(list)
|
||||||
|
responses_by_puzzleid = defaultdict(list)
|
||||||
|
|
||||||
|
for response in list(
|
||||||
|
PuzzleResponse.objects.filter(needs_manual_validation=False)
|
||||||
|
.filter_user_best_response()
|
||||||
|
.prefetch_related("submission__user")
|
||||||
|
):
|
||||||
|
responses_by_userid[response.submission.user.id].append(response)
|
||||||
|
responses_by_puzzleid[response.puzzle.id].append(response)
|
||||||
|
|
||||||
|
ranking = {}
|
||||||
|
|
||||||
|
for puzzle_id, responses in responses_by_puzzleid.items():
|
||||||
|
ranking[puzzle_id] = sorted(
|
||||||
|
responses, key=lambda x: (x.rank_points is None, x.rank_points or 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"users": list(CustomUser.objects.filter(pk__in=responses_by_userid.keys())),
|
||||||
|
"puzzles": list(SteamCollectionItem.objects.all()),
|
||||||
|
"responses_by_userid": dict(responses_by_userid),
|
||||||
|
"ranking_by_puzzle": ranking,
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set("api:results:results", data, 300)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("top-submissions", response=TournamentSubmissionsOut)
|
||||||
|
def top_submissions(request: HttpRequest, limit: int = 5) -> dict:
|
||||||
|
"""Get tournament top submissions for each puzzle. Only available when tournament is closed."""
|
||||||
|
cache_key = f"api:results:top_submissions:{limit}"
|
||||||
|
cached_data = cache.get(cache_key)
|
||||||
|
|
||||||
|
if cached_data is not None:
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
collection = get_object_or_404(SteamCollection, is_active=True)
|
||||||
|
|
||||||
|
# Only allow access when tournament is closed
|
||||||
|
if collection.accepting_submissions:
|
||||||
|
raise HttpError(403, "Tournament is still accepting submissions")
|
||||||
|
|
||||||
|
# Get all puzzles
|
||||||
|
puzzles = SteamCollectionItem.objects.filter(collection=collection).order_by(
|
||||||
|
"order_index"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
submissions_list = []
|
||||||
|
for puzzle in puzzles:
|
||||||
|
# Get the top N responses for this puzzle (ranked by points, highest first)
|
||||||
|
top_responses = (
|
||||||
|
PuzzleResponse.objects.filter(puzzle=puzzle, needs_manual_validation=False)
|
||||||
|
.filter_user_best_response()
|
||||||
|
.annotate_rank_points()
|
||||||
|
.order_by("-rank_points")[:limit]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build submission list for this puzzle
|
||||||
|
puzzle_submissions = []
|
||||||
|
for response in top_responses:
|
||||||
|
# Get submission files
|
||||||
|
files = response.files.all()
|
||||||
|
response_files = [
|
||||||
|
WinnerFileOut(
|
||||||
|
file_url=file.file_url or "",
|
||||||
|
original_filename=file.original_filename,
|
||||||
|
)
|
||||||
|
for file in files
|
||||||
|
]
|
||||||
|
|
||||||
|
# Calculate total coefficient
|
||||||
|
total_coef = None
|
||||||
|
if (
|
||||||
|
puzzle.points_factor
|
||||||
|
and response.final_cost is not None
|
||||||
|
and response.final_cycles is not None
|
||||||
|
and response.final_area is not None
|
||||||
|
):
|
||||||
|
total_coef = (
|
||||||
|
puzzle.points_factor.cost * response.final_cost
|
||||||
|
+ puzzle.points_factor.cycles * response.final_cycles
|
||||||
|
+ puzzle.points_factor.area * response.final_area
|
||||||
|
)
|
||||||
|
|
||||||
|
submission_data = WinnerResponseOut(
|
||||||
|
user_id=response.submission.user.id if response.submission.user else 0,
|
||||||
|
username=response.submission.user.username
|
||||||
|
if response.submission.user
|
||||||
|
else "Anonymous",
|
||||||
|
final_cost=response.final_cost,
|
||||||
|
final_cycles=response.final_cycles,
|
||||||
|
final_area=response.final_area,
|
||||||
|
rank_points=response.rank_points,
|
||||||
|
total_coef=total_coef,
|
||||||
|
files=response_files,
|
||||||
|
)
|
||||||
|
puzzle_submissions.append(submission_data)
|
||||||
|
|
||||||
|
submissions_list.append(
|
||||||
|
PuzzleSubmissionsOut(
|
||||||
|
puzzle_id=puzzle.id,
|
||||||
|
puzzle_title=puzzle.title,
|
||||||
|
submissions=puzzle_submissions,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
data = {"submissions": submissions_list}
|
||||||
|
cache.set(f"api:results:top_submissions:{limit}", data, 300)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("puzzle-results", response=TournamentPuzzleResultsOut)
|
||||||
|
def puzzle_results(request: HttpRequest, limit: int = 5) -> dict:
|
||||||
|
"""Get tournament results organized by puzzle with coefficients. Only available when tournament is closed."""
|
||||||
|
cache_key = f"api:results:puzzle_results:{limit}"
|
||||||
|
cached_data = cache.get(cache_key)
|
||||||
|
|
||||||
|
if cached_data is not None:
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
collection = get_object_or_404(SteamCollection, is_active=True)
|
||||||
|
|
||||||
|
# Only allow access when tournament is closed
|
||||||
|
if collection.accepting_submissions:
|
||||||
|
raise HttpError(403, "Tournament is still accepting submissions")
|
||||||
|
|
||||||
|
# Get all puzzles
|
||||||
|
puzzles = SteamCollectionItem.objects.filter(collection=collection).order_by(
|
||||||
|
"order_index"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
results_list = []
|
||||||
|
for puzzle in puzzles:
|
||||||
|
# Get the top N responses for this puzzle (ranked by points)
|
||||||
|
top_responses = (
|
||||||
|
PuzzleResponse.objects.filter(puzzle=puzzle, needs_manual_validation=False)
|
||||||
|
.filter_user_best_response()
|
||||||
|
.annotate_rank_points()
|
||||||
|
.order_by("-rank_points")[:limit]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build submission list for this puzzle with rank
|
||||||
|
puzzle_submissions = []
|
||||||
|
for rank, response in enumerate(top_responses, 1):
|
||||||
|
# Get submission files
|
||||||
|
files = response.files.all()
|
||||||
|
response_files = [
|
||||||
|
WinnerFileOut(
|
||||||
|
file_url=file.file_url or "",
|
||||||
|
original_filename=file.original_filename,
|
||||||
|
)
|
||||||
|
for file in files
|
||||||
|
]
|
||||||
|
|
||||||
|
# Calculate total coefficient
|
||||||
|
total_coef = None
|
||||||
|
if (
|
||||||
|
puzzle.points_factor
|
||||||
|
and response.final_cost is not None
|
||||||
|
and response.final_cycles is not None
|
||||||
|
and response.final_area is not None
|
||||||
|
):
|
||||||
|
total_coef = (
|
||||||
|
puzzle.points_factor.cost * response.final_cost
|
||||||
|
+ puzzle.points_factor.cycles * response.final_cycles
|
||||||
|
+ puzzle.points_factor.area * response.final_area
|
||||||
|
)
|
||||||
|
|
||||||
|
submission_data = PuzzleSubmissionWithRankOut(
|
||||||
|
rank=rank,
|
||||||
|
user_id=response.submission.user.id if response.submission.user else 0,
|
||||||
|
username=response.submission.user.username
|
||||||
|
if response.submission.user
|
||||||
|
else "Anonymous",
|
||||||
|
final_cost=response.final_cost,
|
||||||
|
final_cycles=response.final_cycles,
|
||||||
|
final_area=response.final_area,
|
||||||
|
rank_points=response.rank_points,
|
||||||
|
total_coef=total_coef,
|
||||||
|
files=response_files,
|
||||||
|
)
|
||||||
|
puzzle_submissions.append(submission_data)
|
||||||
|
|
||||||
|
# Get points factor if available
|
||||||
|
points_factor = None
|
||||||
|
if puzzle.points_factor:
|
||||||
|
points_factor = PuzzlePointsFactorOut(
|
||||||
|
cost=puzzle.points_factor.cost,
|
||||||
|
cycles=puzzle.points_factor.cycles,
|
||||||
|
area=puzzle.points_factor.area,
|
||||||
|
)
|
||||||
|
|
||||||
|
results_list.append(
|
||||||
|
PuzzleResultsOut(
|
||||||
|
puzzle_id=puzzle.id,
|
||||||
|
puzzle_title=puzzle.title,
|
||||||
|
points_factor=points_factor,
|
||||||
|
submissions=puzzle_submissions,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
data = {"results": results_list}
|
||||||
|
cache.set(f"api:results:puzzle_results:{limit}", data, 300)
|
||||||
|
return data
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AnimationsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "animations"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-23 22:33
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="PuzzlePointsFactor",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("cost", models.IntegerField()),
|
||||||
|
("cycles", models.IntegerField()),
|
||||||
|
("area", models.IntegerField()),
|
||||||
|
("special_notes", models.TextField(blank=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-24 01:00
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("animations", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="PuzzlePointsValue",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("points", models.JSONField(default=[])),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-05 13:38
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("animations", "0002_puzzlepointsvalue"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="puzzlepointsvalue",
|
||||||
|
name="points",
|
||||||
|
field=models.JSONField(default=list),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzlePointsFactor(models.Model):
|
||||||
|
# Timestamps
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
cost = models.IntegerField()
|
||||||
|
cycles = models.IntegerField()
|
||||||
|
area = models.IntegerField()
|
||||||
|
|
||||||
|
special_notes = models.TextField(blank=True)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.cost} - {self.cycles} - {self.area}"
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzlePointsValue(models.Model):
|
||||||
|
# Timestamps
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
points = models.JSONField(default=list)
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
from ninja import ModelSchema, Schema
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from opus_magnum.models import PuzzleResponse
|
||||||
|
from opus_magnum.schemas import SteamCollectionItemOut
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzleResponseRankingOut(ModelSchema):
|
||||||
|
class Meta:
|
||||||
|
model = PuzzleResponse
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"puzzle_name",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
|
||||||
|
points: int | None = None
|
||||||
|
rank_points: int | None = None
|
||||||
|
puzzle_user_rank: int
|
||||||
|
user_response_rank: int
|
||||||
|
|
||||||
|
user_id: int
|
||||||
|
|
||||||
|
final_cost: int | None
|
||||||
|
final_cycles: int | None
|
||||||
|
final_area: int | None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_user_id(obj) -> int:
|
||||||
|
return obj.submission.user.id
|
||||||
|
|
||||||
|
|
||||||
|
class UserDisplayOut(Schema):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
is_staff: bool
|
||||||
|
|
||||||
|
|
||||||
|
class RankingSchema(Schema):
|
||||||
|
users: list[UserDisplayOut]
|
||||||
|
puzzles: list[SteamCollectionItemOut]
|
||||||
|
responses_by_userid: dict[int, list[PuzzleResponseRankingOut]]
|
||||||
|
ranking_by_puzzle: dict[int, list[PuzzleResponseRankingOut]]
|
||||||
|
|
||||||
|
|
||||||
|
class WinnerFileOut(Schema):
|
||||||
|
"""Schema for winner submission file"""
|
||||||
|
|
||||||
|
file_url: str
|
||||||
|
original_filename: str
|
||||||
|
|
||||||
|
|
||||||
|
class WinnerResponseOut(Schema):
|
||||||
|
"""Schema for winner response with files"""
|
||||||
|
|
||||||
|
user_id: int
|
||||||
|
username: str
|
||||||
|
final_cost: Optional[int]
|
||||||
|
final_cycles: Optional[int]
|
||||||
|
final_area: Optional[int]
|
||||||
|
rank_points: Optional[int]
|
||||||
|
total_coef: Optional[int]
|
||||||
|
files: List[WinnerFileOut]
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzleSubmissionsOut(Schema):
|
||||||
|
"""Schema for puzzle with all top submissions"""
|
||||||
|
|
||||||
|
puzzle_id: int
|
||||||
|
puzzle_title: str
|
||||||
|
submissions: List[WinnerResponseOut]
|
||||||
|
|
||||||
|
|
||||||
|
class TournamentSubmissionsOut(Schema):
|
||||||
|
"""Schema for tournament top submissions results"""
|
||||||
|
|
||||||
|
submissions: List[PuzzleSubmissionsOut]
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzlePointsFactorOut(Schema):
|
||||||
|
"""Schema for puzzle points factor"""
|
||||||
|
|
||||||
|
cost: int
|
||||||
|
cycles: int
|
||||||
|
area: int
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzleSubmissionWithRankOut(Schema):
|
||||||
|
"""Schema for puzzle submission with rank"""
|
||||||
|
|
||||||
|
rank: int
|
||||||
|
user_id: int
|
||||||
|
username: str
|
||||||
|
final_cost: Optional[int]
|
||||||
|
final_cycles: Optional[int]
|
||||||
|
final_area: Optional[int]
|
||||||
|
rank_points: Optional[int]
|
||||||
|
total_coef: Optional[int]
|
||||||
|
files: List[WinnerFileOut]
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzleResultsOut(Schema):
|
||||||
|
"""Schema for puzzle-specific results with coefficients"""
|
||||||
|
|
||||||
|
puzzle_id: int
|
||||||
|
puzzle_title: str
|
||||||
|
points_factor: Optional[PuzzlePointsFactorOut]
|
||||||
|
submissions: List[PuzzleSubmissionWithRankOut]
|
||||||
|
|
||||||
|
|
||||||
|
class TournamentPuzzleResultsOut(Schema):
|
||||||
|
"""Schema for tournament puzzle-specific results"""
|
||||||
|
|
||||||
|
results: List[PuzzleResultsOut]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Create your tests here.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Create your views here.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import Game
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Game)
|
||||||
|
class GameAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["name", "steam_app_id", "enabled", "updated_at"]
|
||||||
|
list_filter = ["enabled"]
|
||||||
|
search_fields = ["name", "steam_app_id"]
|
||||||
|
readonly_fields = ["created_at", "updated_at"]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
|
from ninja import Router
|
||||||
|
|
||||||
|
from .models import Game
|
||||||
|
from .schemas import GameOut
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response=List[GameOut])
|
||||||
|
def list_games(request):
|
||||||
|
return Game.objects.filter(enabled=True)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class GamesConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "games"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
|
||||||
|
from .models import Game
|
||||||
|
|
||||||
|
|
||||||
|
def require_game_enabled(steam_app_id: int):
|
||||||
|
def decorator(view_func):
|
||||||
|
@wraps(view_func)
|
||||||
|
def wrapper(request, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
game = Game.objects.get(steam_app_id=steam_app_id)
|
||||||
|
except Game.DoesNotExist:
|
||||||
|
raise PermissionDenied
|
||||||
|
if not game.enabled:
|
||||||
|
raise PermissionDenied
|
||||||
|
return view_func(request, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Game",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("steam_app_id", models.PositiveIntegerField(unique=True)),
|
||||||
|
("name", models.CharField(max_length=255)),
|
||||||
|
("enabled", models.BooleanField(default=True)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["name"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
NOITA_APP_ID = 881100
|
||||||
|
OPUS_MAGNUM_APP_ID = 558990
|
||||||
|
|
||||||
|
|
||||||
|
def seed_games(apps, schema_editor):
|
||||||
|
Game = apps.get_model("games", "Game")
|
||||||
|
Game.objects.get_or_create(
|
||||||
|
steam_app_id=NOITA_APP_ID,
|
||||||
|
defaults={"name": "Noita", "enabled": True},
|
||||||
|
)
|
||||||
|
Game.objects.get_or_create(
|
||||||
|
steam_app_id=OPUS_MAGNUM_APP_ID,
|
||||||
|
defaults={"name": "Opus Magnum", "enabled": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def unseed_games(apps, schema_editor):
|
||||||
|
Game = apps.get_model("games", "Game")
|
||||||
|
Game.objects.filter(steam_app_id__in=[NOITA_APP_ID, OPUS_MAGNUM_APP_ID]).delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("games", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(seed_games, reverse_code=unseed_games),
|
||||||
|
]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
NOITA_APP_ID = 881100
|
||||||
|
OPUS_MAGNUM_APP_ID = 558990
|
||||||
|
|
||||||
|
PATHS = {
|
||||||
|
NOITA_APP_ID: "/noita",
|
||||||
|
OPUS_MAGNUM_APP_ID: "/opus-magnum",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def set_paths(apps, schema_editor):
|
||||||
|
Game = apps.get_model("games", "Game")
|
||||||
|
for app_id, path in PATHS.items():
|
||||||
|
Game.objects.filter(steam_app_id=app_id).update(path=path)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("games", "0002_seed_noita_and_opus_magnum"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="game",
|
||||||
|
name="path",
|
||||||
|
field=models.CharField(default="", max_length=100),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.RunPython(set_paths, reverse_code=migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Game(models.Model):
|
||||||
|
steam_app_id = models.PositiveIntegerField(unique=True)
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
path = models.CharField(max_length=100)
|
||||||
|
enabled = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.name} ({self.steam_app_id})"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from ninja import Schema
|
||||||
|
|
||||||
|
|
||||||
|
class GameOut(Schema):
|
||||||
|
steam_app_id: int
|
||||||
|
name: str
|
||||||
|
path: str
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
"""Django's command-line utility for administrative tasks."""
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Run administrative tasks."""
|
"""Run administrative tasks."""
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'opus_submitter.settings')
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polylan_submitter.settings")
|
||||||
try:
|
try:
|
||||||
from django.core.management import execute_from_command_line
|
from django.core.management import execute_from_command_line
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
@@ -18,5 +19,5 @@ def main():
|
|||||||
execute_from_command_line(sys.argv)
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from market.models import Market, MarketOption, UserBet, UserPointChange
|
||||||
|
|
||||||
|
|
||||||
|
class MarketOptionInline(admin.TabularInline):
|
||||||
|
model = MarketOption
|
||||||
|
extra = 1
|
||||||
|
fields = ["text"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Market)
|
||||||
|
class MarketAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["title", "status", "end_date", "created_by", "created_at"]
|
||||||
|
list_filter = ["status", "created_at"]
|
||||||
|
search_fields = ["uuid", "title"]
|
||||||
|
readonly_fields = [
|
||||||
|
"uuid",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"created_by",
|
||||||
|
"winning_option",
|
||||||
|
]
|
||||||
|
inlines = [MarketOptionInline]
|
||||||
|
fieldsets = (
|
||||||
|
("Info", {"fields": ["uuid", "title", "description"]}),
|
||||||
|
("Configuration", {"fields": ["end_date", "multiplier"]}),
|
||||||
|
("Status", {"fields": ["status", "winning_option"]}),
|
||||||
|
("Metadata", {"fields": ["created_by", "created_at", "updated_at"]}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def has_change_permission(self, request, obj=None):
|
||||||
|
# Prevent any changes to resolved markets
|
||||||
|
if obj and obj.status == Market.Status.RESOLVED:
|
||||||
|
return False
|
||||||
|
return super().has_change_permission(request, obj)
|
||||||
|
|
||||||
|
def save_model(self, request, obj, form, change):
|
||||||
|
if not change: # Creating new market
|
||||||
|
obj.created_by = request.user
|
||||||
|
super().save_model(request, obj, form, change)
|
||||||
|
|
||||||
|
@admin.action(description="Publish selected draft markets")
|
||||||
|
def publish_markets(self, request, queryset):
|
||||||
|
updated = queryset.filter(status=Market.Status.DRAFT).update(
|
||||||
|
status=Market.Status.OPEN
|
||||||
|
)
|
||||||
|
self.message_user(request, f"Published {updated} market(s).")
|
||||||
|
|
||||||
|
@admin.action(description="Close selected markets")
|
||||||
|
def close_markets(self, request, queryset):
|
||||||
|
updated = queryset.filter(status=Market.Status.OPEN).update(
|
||||||
|
status=Market.Status.CLOSED
|
||||||
|
)
|
||||||
|
self.message_user(request, f"Closed {updated} market(s).")
|
||||||
|
|
||||||
|
actions = ["publish_markets", "close_markets"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(MarketOption)
|
||||||
|
class MarketOptionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["text", "market"]
|
||||||
|
list_filter = ["market"]
|
||||||
|
search_fields = ["uuid", "text", "market__title"]
|
||||||
|
readonly_fields = ["uuid"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UserBet)
|
||||||
|
class UserBetAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["user", "option", "amount", "created_at"]
|
||||||
|
list_filter = ["user", "created_at", "option__market"]
|
||||||
|
search_fields = ["uuid", "user__username", "option__text"]
|
||||||
|
readonly_fields = ["uuid", "user", "option", "amount", "created_at", "updated_at"]
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UserPointChange)
|
||||||
|
class UserPointChangeAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["user", "market", "amount", "reason", "created_at"]
|
||||||
|
list_filter = ["user", "reason", "created_at", "market"]
|
||||||
|
search_fields = ["uuid", "user__username", "market__title"]
|
||||||
|
readonly_fields = ["uuid", "created_at", "updated_at"]
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
from typing import List
|
||||||
|
from ninja import Router
|
||||||
|
from ninja.errors import HttpError
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.db.models import Sum, Prefetch
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from market.models import Market, MarketOption, UserBet, UserPointChange
|
||||||
|
from market.schemas import (
|
||||||
|
MarketListSchema,
|
||||||
|
ResolveMarketSchema,
|
||||||
|
UserBetCreateSchema,
|
||||||
|
UserBetSchema,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = Router(tags=["market"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response=List[MarketListSchema])
|
||||||
|
def list_markets(request):
|
||||||
|
"""List all markets (excludes draft markets)."""
|
||||||
|
markets = Market.objects.exclude(status=Market.Status.DRAFT)
|
||||||
|
# Prefetch options with total_bets annotation sorted by total_bets desc, then text asc
|
||||||
|
options_queryset = MarketOption.objects.annotate(
|
||||||
|
total_bets=Coalesce(Sum("user_bets__amount"), 0)
|
||||||
|
).order_by("-total_bets", "text")
|
||||||
|
|
||||||
|
return markets.prefetch_related(Prefetch("options", queryset=options_queryset))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user/bets", response=List[UserBetSchema])
|
||||||
|
def list_user_bets(request):
|
||||||
|
"""List all bets placed by the current user."""
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
raise HttpError(401, "Authentication required")
|
||||||
|
|
||||||
|
return (
|
||||||
|
UserBet.objects.filter(user=request.user)
|
||||||
|
.select_related("option__market")
|
||||||
|
.prefetch_related("option__market__options")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{market_uuid}/actions/close")
|
||||||
|
def close_market(request, market_uuid: str):
|
||||||
|
"""Close a market. Admin only."""
|
||||||
|
if not request.user.is_staff:
|
||||||
|
raise HttpError(403, "Permission denied")
|
||||||
|
|
||||||
|
market = get_object_or_404(Market, uuid=market_uuid)
|
||||||
|
market.status = Market.Status.CLOSED
|
||||||
|
market.save(update_fields=["status", "updated_at"])
|
||||||
|
return {"status": Market.Status.CLOSED}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{market_uuid}/actions/resolve", response=MarketListSchema)
|
||||||
|
def resolve_market(request, market_uuid: str, payload: ResolveMarketSchema):
|
||||||
|
"""Resolve a market with a winning option. Admin only."""
|
||||||
|
if not request.user.is_staff:
|
||||||
|
raise HttpError(403, "Permission denied")
|
||||||
|
|
||||||
|
market = get_object_or_404(Market, uuid=market_uuid)
|
||||||
|
winning_option = get_object_or_404(MarketOption, uuid=payload.winning_option_uuid)
|
||||||
|
|
||||||
|
if winning_option.market_id != market.id:
|
||||||
|
raise HttpError(400, "Option does not belong to this market")
|
||||||
|
|
||||||
|
market.winning_option = winning_option
|
||||||
|
market.status = Market.Status.RESOLVED
|
||||||
|
market.save(update_fields=["winning_option", "status", "updated_at"])
|
||||||
|
|
||||||
|
# Calculate and distribute winnings
|
||||||
|
all_bets = list(
|
||||||
|
UserBet.objects.filter(option__market=market).select_related("user")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate total pot
|
||||||
|
total_pot = sum(bet.amount for bet in all_bets)
|
||||||
|
if total_pot == 0:
|
||||||
|
return market
|
||||||
|
|
||||||
|
# Separate winning and losing bets
|
||||||
|
winning_bets = [bet for bet in all_bets if bet.option_id == winning_option.id]
|
||||||
|
losing_bets = [bet for bet in all_bets if bet.option_id != winning_option.id]
|
||||||
|
|
||||||
|
total_winning = sum(bet.amount for bet in winning_bets)
|
||||||
|
|
||||||
|
point_changes = []
|
||||||
|
users_to_update = []
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
# Award payouts to winners with multiplier
|
||||||
|
if total_winning > 0:
|
||||||
|
for bet in winning_bets:
|
||||||
|
payout = round(
|
||||||
|
bet.amount / total_winning * total_pot * market.multiplier
|
||||||
|
)
|
||||||
|
bet.user.points += payout
|
||||||
|
users_to_update.append(bet.user)
|
||||||
|
point_changes.append(
|
||||||
|
UserPointChange(
|
||||||
|
user=bet.user,
|
||||||
|
market=market,
|
||||||
|
amount=payout,
|
||||||
|
reason=UserPointChange.Reason.BET_WON,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Record losing bets (points already deducted)
|
||||||
|
for bet in losing_bets:
|
||||||
|
point_changes.append(
|
||||||
|
UserPointChange(
|
||||||
|
user=bet.user,
|
||||||
|
market=market,
|
||||||
|
amount=-bet.amount,
|
||||||
|
reason=UserPointChange.Reason.BET_LOST,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bulk update users
|
||||||
|
for user in users_to_update:
|
||||||
|
user.save(update_fields=["points"])
|
||||||
|
|
||||||
|
# Bulk create point changes
|
||||||
|
UserPointChange.objects.bulk_create(point_changes)
|
||||||
|
|
||||||
|
return market
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{market_uuid}/bets", response=UserBetSchema)
|
||||||
|
def create_bet(request, market_uuid: str, payload: UserBetCreateSchema):
|
||||||
|
"""Place a bet on a market option."""
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
raise HttpError(401, "Authentication required")
|
||||||
|
|
||||||
|
market = get_object_or_404(Market, uuid=market_uuid)
|
||||||
|
option = get_object_or_404(MarketOption, uuid=payload.option_uuid)
|
||||||
|
|
||||||
|
if option.market_id != market.id:
|
||||||
|
raise HttpError(400, "Option does not belong to this market")
|
||||||
|
|
||||||
|
if market.status != Market.Status.OPEN:
|
||||||
|
raise HttpError(400, "Market is not open for betting")
|
||||||
|
|
||||||
|
# Check if user already has a bet on a different option in this market
|
||||||
|
existing_bet_on_market = (
|
||||||
|
UserBet.objects.filter(user=request.user, option__market=market)
|
||||||
|
.exclude(option=option)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing_bet_on_market:
|
||||||
|
raise HttpError(400, "You can only bet on one option per market")
|
||||||
|
|
||||||
|
# Check if user already has a bet on this option
|
||||||
|
existing_bet = UserBet.objects.filter(user=request.user, option=option).first()
|
||||||
|
if existing_bet and payload.amount < existing_bet.amount:
|
||||||
|
raise HttpError(400, "Cannot decrease bet amount. You can only increase it.")
|
||||||
|
|
||||||
|
# Calculate delta (amount to deduct from user's points)
|
||||||
|
delta = payload.amount - (existing_bet.amount if existing_bet else 0)
|
||||||
|
|
||||||
|
# Check if user has enough points
|
||||||
|
if request.user.points < delta:
|
||||||
|
raise HttpError(400, "Insufficient points for this bet")
|
||||||
|
|
||||||
|
user_bet, created = UserBet.objects.update_or_create(
|
||||||
|
user=request.user,
|
||||||
|
option=option,
|
||||||
|
defaults={"amount": payload.amount},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Deduct points and record the change
|
||||||
|
request.user.points -= delta
|
||||||
|
request.user.save(update_fields=["points"])
|
||||||
|
UserPointChange.objects.create(
|
||||||
|
user=request.user,
|
||||||
|
market=market,
|
||||||
|
amount=-delta,
|
||||||
|
reason=UserPointChange.Reason.BET_PLACED,
|
||||||
|
)
|
||||||
|
|
||||||
|
return user_bet
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class MarketConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "market"
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-23 15:45
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Market",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"uuid",
|
||||||
|
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("title", models.CharField(max_length=255)),
|
||||||
|
("description", models.TextField(blank=True)),
|
||||||
|
(
|
||||||
|
"type",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("yes_no", "Yes/No"), ("multiple", "Multiple Choice")],
|
||||||
|
default="yes_no",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"status",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("open", "Open"),
|
||||||
|
("closed", "Closed"),
|
||||||
|
("resolved", "Resolved"),
|
||||||
|
],
|
||||||
|
default="open",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("end_date", models.DateTimeField()),
|
||||||
|
(
|
||||||
|
"created_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["-created_at"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="MarketOption",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"uuid",
|
||||||
|
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("text", models.CharField(max_length=255)),
|
||||||
|
("position", models.PositiveIntegerField(default=0)),
|
||||||
|
(
|
||||||
|
"market",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="options",
|
||||||
|
to="market.market",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["position"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="market",
|
||||||
|
name="winning_option",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="market_won",
|
||||||
|
to="market.marketoption",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="UserBet",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"uuid",
|
||||||
|
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("amount", models.PositiveIntegerField()),
|
||||||
|
(
|
||||||
|
"option",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="user_bets",
|
||||||
|
to="market.marketoption",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="bets",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="marketoption",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["market", "position"], name="market_mark_market__8679ce_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="marketoption",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("market", "position"), name="unique_market_option_position"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="market",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["status", "-created_at"], name="market_mark_status_1ef6c3_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="market",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["end_date"], name="market_mark_end_dat_26bec0_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="userbet",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["user", "option"], name="market_user_user_id_5e43d9_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="userbet",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("user", "option"), name="unique_user_bet_per_option"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-23 18:11
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("market", "0001_initial"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="UserPointChange",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"uuid",
|
||||||
|
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("amount", models.IntegerField()),
|
||||||
|
(
|
||||||
|
"reason",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("bet_placed", "Bet Placed"),
|
||||||
|
("bet_won", "Bet Won"),
|
||||||
|
("bet_lost", "Bet Lost"),
|
||||||
|
],
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"market",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="point_changes",
|
||||||
|
to="market.market",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="point_changes",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["-created_at"],
|
||||||
|
"indexes": [
|
||||||
|
models.Index(
|
||||||
|
fields=["user", "-created_at"],
|
||||||
|
name="market_user_user_id_631ba9_idx",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-23 18:19
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("market", "0002_userpointchange"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="market",
|
||||||
|
name="type",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-23 18:20
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("market", "0003_remove_market_type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="marketoption",
|
||||||
|
options={"ordering": ["text"]},
|
||||||
|
),
|
||||||
|
migrations.RemoveConstraint(
|
||||||
|
model_name="marketoption",
|
||||||
|
name="unique_market_option_position",
|
||||||
|
),
|
||||||
|
migrations.RemoveIndex(
|
||||||
|
model_name="marketoption",
|
||||||
|
name="market_mark_market__8679ce_idx",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="marketoption",
|
||||||
|
name="position",
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="marketoption",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["market"], name="market_mark_market__67f63b_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="marketoption",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("market", "text"), name="unique_market_option_text"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-23 18:34
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("market", "0004_alter_marketoption_options_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="market",
|
||||||
|
name="multiplier",
|
||||||
|
field=models.FloatField(default=1.0),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-23 18:40
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("market", "0005_market_multiplier"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="market",
|
||||||
|
name="status",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("draft", "Draft"),
|
||||||
|
("open", "Open"),
|
||||||
|
("closed", "Closed"),
|
||||||
|
("resolved", "Resolved"),
|
||||||
|
],
|
||||||
|
default="draft",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import uuid
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class BaseModel(models.Model):
|
||||||
|
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
|
class Market(BaseModel):
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
DRAFT = "draft", "Draft"
|
||||||
|
OPEN = "open", "Open"
|
||||||
|
CLOSED = "closed", "Closed"
|
||||||
|
RESOLVED = "resolved", "Resolved"
|
||||||
|
|
||||||
|
title = models.CharField(max_length=255)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=10, choices=Status.choices, default=Status.DRAFT
|
||||||
|
)
|
||||||
|
end_date = models.DateTimeField()
|
||||||
|
multiplier = models.FloatField(default=1.0)
|
||||||
|
created_by = models.ForeignKey("accounts.CustomUser", on_delete=models.PROTECT)
|
||||||
|
winning_option = models.ForeignKey(
|
||||||
|
"MarketOption",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="market_won",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["status", "-created_at"]),
|
||||||
|
models.Index(fields=["end_date"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
|
||||||
|
class MarketOption(BaseModel):
|
||||||
|
market = models.ForeignKey(Market, on_delete=models.CASCADE, related_name="options")
|
||||||
|
text = models.CharField(max_length=255)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["text"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["market", "text"],
|
||||||
|
name="unique_market_option_text",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["market"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.market.title} - {self.text}"
|
||||||
|
|
||||||
|
|
||||||
|
class UserBet(BaseModel):
|
||||||
|
user = models.ForeignKey(
|
||||||
|
"accounts.CustomUser", on_delete=models.CASCADE, related_name="bets"
|
||||||
|
)
|
||||||
|
option = models.ForeignKey(
|
||||||
|
MarketOption, on_delete=models.CASCADE, related_name="user_bets"
|
||||||
|
)
|
||||||
|
amount = models.PositiveIntegerField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "option"],
|
||||||
|
name="unique_user_bet_per_option",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["user", "option"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.username} bet {self.amount} on {self.option.text}"
|
||||||
|
|
||||||
|
|
||||||
|
class UserPointChange(BaseModel):
|
||||||
|
class Reason(models.TextChoices):
|
||||||
|
BET_PLACED = "bet_placed", "Bet Placed"
|
||||||
|
BET_WON = "bet_won", "Bet Won"
|
||||||
|
BET_LOST = "bet_lost", "Bet Lost"
|
||||||
|
|
||||||
|
user = models.ForeignKey(
|
||||||
|
"accounts.CustomUser", on_delete=models.CASCADE, related_name="point_changes"
|
||||||
|
)
|
||||||
|
market = models.ForeignKey(
|
||||||
|
Market, on_delete=models.CASCADE, related_name="point_changes"
|
||||||
|
)
|
||||||
|
amount = models.IntegerField()
|
||||||
|
reason = models.CharField(max_length=20, choices=Reason.choices)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["user", "-created_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.username} {self.reason}: {self.amount} pts on {self.market.title}"
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional, Any
|
||||||
|
from uuid import UUID
|
||||||
|
from ninja import Schema
|
||||||
|
from pydantic import field_serializer, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class MarketOptionSchema(Schema):
|
||||||
|
uuid: UUID
|
||||||
|
text: str
|
||||||
|
total_bets: int = 0
|
||||||
|
|
||||||
|
@field_serializer("uuid")
|
||||||
|
def serialize_uuid(self, value: UUID) -> str:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
class MarketListSchema(Schema):
|
||||||
|
uuid: UUID
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
status: str
|
||||||
|
end_date: datetime
|
||||||
|
multiplier: float = 1.0
|
||||||
|
created_at: datetime
|
||||||
|
options: List[MarketOptionSchema]
|
||||||
|
winning_option: Optional[MarketOptionSchema] = None
|
||||||
|
|
||||||
|
@field_serializer("uuid")
|
||||||
|
def serialize_uuid(self, value: UUID) -> str:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveMarketSchema(Schema):
|
||||||
|
winning_option_uuid: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserBetCreateSchema(Schema):
|
||||||
|
option_uuid: str
|
||||||
|
amount: int
|
||||||
|
|
||||||
|
|
||||||
|
class UserBetSchema(Schema):
|
||||||
|
uuid: UUID
|
||||||
|
amount: int
|
||||||
|
created_at: datetime
|
||||||
|
option: MarketOptionSchema
|
||||||
|
market: Optional[MarketListSchema] = None
|
||||||
|
|
||||||
|
@field_serializer("uuid")
|
||||||
|
def serialize_uuid(self, value: UUID) -> str:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def resolve_market_from_option(cls, data: Any) -> Any:
|
||||||
|
if hasattr(data, "option") and hasattr(data.option, "market"):
|
||||||
|
data.market = data.option.market
|
||||||
|
return data
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Create your tests here.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.http import HttpRequest
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def market_home(request: HttpRequest):
|
||||||
|
return render(request, "market.html", {})
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from noita.services.objectives import parse_objectives_and_store
|
||||||
|
from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(LogfileSubmission)
|
||||||
|
class LogfileSubmissionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"id",
|
||||||
|
"user",
|
||||||
|
"content_type",
|
||||||
|
"file_size",
|
||||||
|
"created_at",
|
||||||
|
"processed",
|
||||||
|
)
|
||||||
|
list_filter = ("content_type", "processed", "created_at")
|
||||||
|
search_fields = ("id", "user__username")
|
||||||
|
readonly_fields = ("id", "created_at", "updated_at")
|
||||||
|
fieldsets = (
|
||||||
|
("Identification", {"fields": ("id",)}),
|
||||||
|
("File Information", {"fields": ("file", "content_type", "file_size")}),
|
||||||
|
("User", {"fields": ("user",)}),
|
||||||
|
("Timestamps", {"fields": ("created_at", "updated_at")}),
|
||||||
|
("Processing", {"fields": ("processed",)}),
|
||||||
|
)
|
||||||
|
|
||||||
|
actions = ["validate_submission"]
|
||||||
|
|
||||||
|
def validate_submission(self, request, queryset):
|
||||||
|
for logfile in queryset:
|
||||||
|
parse_objectives_and_store(logfile)
|
||||||
|
|
||||||
|
self.message_user(request, f"{queryset.count()} submissions validated.")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Objectiv)
|
||||||
|
class ObjectivAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("objectiv_id", "user", "first_seen_at", "get_user_objectiv_count")
|
||||||
|
list_filter = ("objectiv_id", "user")
|
||||||
|
search_fields = ("objectiv_id", "user__username")
|
||||||
|
readonly_fields = ("user", "first_seen_at")
|
||||||
|
|
||||||
|
def get_user_objectiv_count(self, obj):
|
||||||
|
return Objectiv.objects.filter(
|
||||||
|
objectiv_id=obj.objectiv_id, user=obj.user
|
||||||
|
).count()
|
||||||
|
|
||||||
|
get_user_objectiv_count.short_description = "Count"
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ObjectivPoint)
|
||||||
|
class ObjectivPointAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("objectiv_id", "display_string", "max_count", "point")
|
||||||
|
list_filter = ("objectiv_id",)
|
||||||
|
search_fields = ("objectiv_id", "display_string")
|
||||||
|
fieldsets = (
|
||||||
|
("Objective Information", {"fields": ("objectiv_id", "display_string")}),
|
||||||
|
("Scoring", {"fields": ("max_count", "point")}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(DeathCounter)
|
||||||
|
class DeathCounterAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("user_id", "seed", "seen_at")
|
||||||
|
list_filter = ("user_id", "seed")
|
||||||
|
search_fields = ("user_id", "seed")
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
("User Information", {"fields": ("user_id",)}),
|
||||||
|
("Scoring", {"fields": ("seed",)}),
|
||||||
|
)
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
from django.http import HttpRequest
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.db.models import (
|
||||||
|
F,
|
||||||
|
Case,
|
||||||
|
When,
|
||||||
|
Count,
|
||||||
|
IntegerField,
|
||||||
|
Subquery,
|
||||||
|
OuterRef,
|
||||||
|
)
|
||||||
|
from ninja import Router, File
|
||||||
|
from ninja.files import UploadedFile
|
||||||
|
from ninja.decorators import decorate_view
|
||||||
|
|
||||||
|
from noita.schemas import ResultsOut, LeaderboardOut
|
||||||
|
from noita.services.objectives import parse_objectives_and_store
|
||||||
|
from games.decorators import require_game_enabled
|
||||||
|
|
||||||
|
from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter
|
||||||
|
from .schemas import NoitaSubmissionOut
|
||||||
|
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
NOITA_APP_ID = 881100
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("results", response=ResultsOut)
|
||||||
|
@decorate_view(require_game_enabled(NOITA_APP_ID))
|
||||||
|
def get_results(request: HttpRequest):
|
||||||
|
cache_key = f"api:noita:results:{request.user.id}"
|
||||||
|
cached_data = cache.get(cache_key)
|
||||||
|
|
||||||
|
if cached_data is not None:
|
||||||
|
return cached_data
|
||||||
|
"""
|
||||||
|
Get the user's score based on their objectives.
|
||||||
|
|
||||||
|
Calculates points as: ObjectivPoint.point * min(max_count, count) for each objective
|
||||||
|
Uses Django ORM annotate for efficient queryset computation.
|
||||||
|
"""
|
||||||
|
# Group objectives by objectiv_id and count occurrences
|
||||||
|
user_objectives = (
|
||||||
|
Objectiv.objects.filter(user=request.user)
|
||||||
|
.values("objectiv_id")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch points from ObjectivPoint using Subquery
|
||||||
|
user_objectives = user_objectives.annotate(
|
||||||
|
# Get points per objective from ObjectivPoint
|
||||||
|
points_per_objectiv=Subquery(
|
||||||
|
ObjectivPoint.objects.filter(objectiv_id=OuterRef("objectiv_id")).values(
|
||||||
|
"point"
|
||||||
|
)[:1],
|
||||||
|
output_field=IntegerField(),
|
||||||
|
),
|
||||||
|
# Get max_count from ObjectivPoint
|
||||||
|
max_objectives=Subquery(
|
||||||
|
ObjectivPoint.objects.filter(objectiv_id=OuterRef("objectiv_id")).values(
|
||||||
|
"max_count"
|
||||||
|
)[:1],
|
||||||
|
output_field=IntegerField(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle negative max_count (means unlimited)
|
||||||
|
user_objectives = user_objectives.annotate(
|
||||||
|
effective_max=Case(
|
||||||
|
When(max_objectives__lt=0, then=F("count")),
|
||||||
|
default=F("max_objectives"),
|
||||||
|
output_field=IntegerField(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate capped count and total points
|
||||||
|
user_objectives = user_objectives.annotate(
|
||||||
|
capped_count=Case(
|
||||||
|
When(effective_max__lt=F("count"), then=F("effective_max")),
|
||||||
|
default=F("count"),
|
||||||
|
output_field=IntegerField(),
|
||||||
|
),
|
||||||
|
total_points=F("points_per_objectiv") * F("capped_count"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Annotate seed + first-seen-at
|
||||||
|
user_objectives = user_objectives.annotate(
|
||||||
|
seed=F("seed"),
|
||||||
|
first_seen_at=F("first_seen_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build response with all objectives and compute total score
|
||||||
|
total_score = 0
|
||||||
|
with_points = {}
|
||||||
|
for obj in ObjectivPoint.objects.all():
|
||||||
|
with_points[obj.objectiv_id] = {
|
||||||
|
"objectiv_id": obj.objectiv_id,
|
||||||
|
"display_string": obj.display_string,
|
||||||
|
"count": 0,
|
||||||
|
"max_count": obj.max_count,
|
||||||
|
"points_per_objectiv": obj.point,
|
||||||
|
"total_points": 0,
|
||||||
|
"first_seen_at": None,
|
||||||
|
"seed": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
for obj in user_objectives.order_by("-total_points"):
|
||||||
|
points = obj["total_points"] or 0
|
||||||
|
with_points[obj["objectiv_id"]].update(
|
||||||
|
{
|
||||||
|
"count": obj["count"],
|
||||||
|
"total_points": points,
|
||||||
|
"first_seen_at": obj["first_seen_at"],
|
||||||
|
"seed": obj["seed"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
total_score += points
|
||||||
|
|
||||||
|
# Count deaths for the user
|
||||||
|
deaths_count = DeathCounter.objects.filter(user=request.user).count()
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"total_score": total_score,
|
||||||
|
"deaths_count": deaths_count,
|
||||||
|
"objectives": list(with_points.values()),
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set(f"api:noita:results:{request.user.id}", data, 300)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("leaderboard", response=LeaderboardOut)
|
||||||
|
@decorate_view(require_game_enabled(NOITA_APP_ID))
|
||||||
|
def get_leaderboard(request: HttpRequest):
|
||||||
|
"""
|
||||||
|
Get the global leaderboard for all users ranked by total score.
|
||||||
|
|
||||||
|
Uses Window functions to rank users by their total score in descending order.
|
||||||
|
"""
|
||||||
|
cache_key = "api:noita:leaderboard"
|
||||||
|
cached_data = cache.get(cache_key)
|
||||||
|
|
||||||
|
if cached_data is not None:
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
# Get all objectives with calculated points (grouped by objectiv_id and user)
|
||||||
|
all_objectives = (
|
||||||
|
Objectiv.objects.values("user", "objectiv_id")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.annotate(
|
||||||
|
# Fetch points from ObjectivPoint using Subquery
|
||||||
|
points_per_objectiv=Subquery(
|
||||||
|
ObjectivPoint.objects.filter(
|
||||||
|
objectiv_id=OuterRef("objectiv_id")
|
||||||
|
).values("point")[:1],
|
||||||
|
output_field=IntegerField(),
|
||||||
|
),
|
||||||
|
# Get max_count from ObjectivPoint
|
||||||
|
max_objectives=Subquery(
|
||||||
|
ObjectivPoint.objects.filter(
|
||||||
|
objectiv_id=OuterRef("objectiv_id")
|
||||||
|
).values("max_count")[:1],
|
||||||
|
output_field=IntegerField(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
# Handle negative max_count (means unlimited)
|
||||||
|
effective_max=Case(
|
||||||
|
When(max_objectives__lt=0, then=F("count")),
|
||||||
|
default=F("max_objectives"),
|
||||||
|
output_field=IntegerField(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
# Calculate capped count and total points
|
||||||
|
capped_count=Case(
|
||||||
|
When(effective_max__lt=F("count"), then=F("effective_max")),
|
||||||
|
default=F("count"),
|
||||||
|
output_field=IntegerField(),
|
||||||
|
),
|
||||||
|
total_points=F("points_per_objectiv") * F("capped_count"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build user totals by iterating through objectives
|
||||||
|
user_totals_dict = {}
|
||||||
|
for obj in all_objectives:
|
||||||
|
user_id = obj["user"]
|
||||||
|
points = obj["total_points"] or 0
|
||||||
|
if user_id not in user_totals_dict:
|
||||||
|
user_totals_dict[user_id] = 0
|
||||||
|
user_totals_dict[user_id] += points
|
||||||
|
|
||||||
|
# Get unique users and their scores, then apply ranking
|
||||||
|
users_with_scores = []
|
||||||
|
for user_id, total_score in user_totals_dict.items():
|
||||||
|
user = User.objects.get(id=user_id)
|
||||||
|
objectives_count = (
|
||||||
|
Objectiv.objects.filter(user_id=user_id)
|
||||||
|
.values("objectiv_id")
|
||||||
|
.distinct()
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
deaths_count = DeathCounter.objects.filter(user_id=user_id).count()
|
||||||
|
users_with_scores.append(
|
||||||
|
{
|
||||||
|
"user_id": user_id,
|
||||||
|
"user": user,
|
||||||
|
"total_score": total_score,
|
||||||
|
"objectives_count": objectives_count,
|
||||||
|
"deaths_count": deaths_count,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort by score and add rank
|
||||||
|
users_with_scores.sort(key=lambda x: x["total_score"], reverse=True)
|
||||||
|
leaderboard = [
|
||||||
|
{
|
||||||
|
"rank": idx + 1,
|
||||||
|
"username": entry["user"].username,
|
||||||
|
"is_staff": entry["user"].is_staff,
|
||||||
|
"total_score": entry["total_score"],
|
||||||
|
"objectives_count": entry["objectives_count"],
|
||||||
|
"deaths_count": entry["deaths_count"],
|
||||||
|
}
|
||||||
|
for idx, entry in enumerate(users_with_scores)
|
||||||
|
]
|
||||||
|
|
||||||
|
data = {"leaderboard": leaderboard}
|
||||||
|
cache.set("api:noita:leaderboard", data, 300)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("submit", response={200: NoitaSubmissionOut, 400: dict})
|
||||||
|
@decorate_view(require_game_enabled(NOITA_APP_ID))
|
||||||
|
def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
|
||||||
|
"""
|
||||||
|
Submit a Noita run file (log file, screenshot, or video).
|
||||||
|
|
||||||
|
Accepts:
|
||||||
|
- Text files (.txt) for polylan_mod_log.txt
|
||||||
|
- Images (.png, .jpg, .gif)
|
||||||
|
- Videos (.mp4, .webm)
|
||||||
|
|
||||||
|
Max file size: 256 MB
|
||||||
|
"""
|
||||||
|
# Validate file type
|
||||||
|
allowed_types = [
|
||||||
|
"text/plain",
|
||||||
|
"text/x-log",
|
||||||
|
]
|
||||||
|
|
||||||
|
if file.content_type not in allowed_types:
|
||||||
|
return 400, {
|
||||||
|
"detail": f"Invalid file type: {file.content_type}. Allowed types: {', '.join(allowed_types)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate file size (256MB limit)
|
||||||
|
if file.size > 256 * 1024 * 1024:
|
||||||
|
return 400, {"detail": "File too large (max 256MB)"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create submission
|
||||||
|
submission = LogfileSubmission.objects.create(
|
||||||
|
user=request.user if request.user.is_authenticated else None,
|
||||||
|
content_type=file.content_type,
|
||||||
|
file_size=file.size,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save the file
|
||||||
|
submission.file.save(file.name, ContentFile(file.read()), save=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
parse_objectives_and_store(submission)
|
||||||
|
submission.processed = True
|
||||||
|
submission.save(update_fields=["processed"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Invalidate caches on successful submission
|
||||||
|
if submission.user:
|
||||||
|
cache.delete(f"api:noita:results:{submission.user.id}")
|
||||||
|
cache.delete("api:noita:leaderboard")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(submission.id),
|
||||||
|
"user_id": submission.user_id,
|
||||||
|
"username": submission.user.username if submission.user else None,
|
||||||
|
"file_size": submission.file_size,
|
||||||
|
"content_type": submission.content_type,
|
||||||
|
"created_at": submission.created_at,
|
||||||
|
"processed": submission.processed,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return 500, {"detail": f"Error creating submission: {str(e)}"}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class NoitaConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "noita"
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from noita.models import ObjectivPoint
|
||||||
|
from noita.services.decode import POINTS
|
||||||
|
from noita.services.spells import ALL_PERKS, ALL_SPELLS
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Load ObjectivPoints from the POINTS dictionary in services.decode"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--clear",
|
||||||
|
action="store_true",
|
||||||
|
help="Clear all existing ObjectivPoints before loading",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
if options["clear"]:
|
||||||
|
ObjectivPoint.objects.all().delete()
|
||||||
|
self.stdout.write(self.style.SUCCESS("Cleared existing ObjectivPoints"))
|
||||||
|
|
||||||
|
created_count = 0
|
||||||
|
updated_count = 0
|
||||||
|
|
||||||
|
for objectiv_id, point_value in POINTS.items():
|
||||||
|
# Skip special entries
|
||||||
|
if objectiv_id in {"-", "DEBUG"}:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get display string from objectiv_id (convert to title case)
|
||||||
|
display_string = objectiv_id.replace("_", " ").title()
|
||||||
|
|
||||||
|
# Create or update ObjectivPoint
|
||||||
|
obj, created = ObjectivPoint.objects.get_or_create(
|
||||||
|
objectiv_id=objectiv_id,
|
||||||
|
defaults={
|
||||||
|
"display_string": display_string,
|
||||||
|
"point": point_value,
|
||||||
|
"max_count": 1, # Default max_count is 1
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if created:
|
||||||
|
created_count += 1
|
||||||
|
self.stdout.write(f"✓ Created: {objectiv_id} - {point_value} points")
|
||||||
|
else:
|
||||||
|
# Update if points changed
|
||||||
|
if obj.point != point_value or obj.display_string != display_string:
|
||||||
|
obj.point = point_value
|
||||||
|
obj.display_string = display_string
|
||||||
|
obj.save()
|
||||||
|
updated_count += 1
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(
|
||||||
|
f"↻ Updated: {objectiv_id} - {point_value} points"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for op in ObjectivPoint.objects.filter(objectiv_id__in=ALL_SPELLS):
|
||||||
|
op.display_string = f"Spell: {op.display_string}"
|
||||||
|
op.save()
|
||||||
|
|
||||||
|
for op in ObjectivPoint.objects.filter(objectiv_id__in=ALL_PERKS):
|
||||||
|
op.display_string = f"Perk: {op.display_string}"
|
||||||
|
op.save()
|
||||||
|
|
||||||
|
for op in ObjectivPoint.objects.filter(objectiv_id__startswith="BOSS_KILL_"):
|
||||||
|
b, k, c = op.display_string.split(" ")
|
||||||
|
op.display_string = f"Perk: {op.display_string}"
|
||||||
|
op.display_string = f"{b} {k} (with {c} orbs)"
|
||||||
|
op.save()
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"\nCreated: {created_count}"))
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"Updated: {updated_count}"))
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f"Total ObjectivPoints: {ObjectivPoint.objects.count()}")
|
||||||
|
)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-09 22:53
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import noita.models
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Submission",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"content_type",
|
||||||
|
models.CharField(help_text="MIME type of the file", max_length=100),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"file_size",
|
||||||
|
models.PositiveIntegerField(help_text="File size in bytes"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"file",
|
||||||
|
models.FileField(
|
||||||
|
help_text="Uploaded file (image/gif)",
|
||||||
|
upload_to=noita.models.submission_file_upload_path,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("processed", models.BooleanField(default=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,
|
||||||
|
related_name="noita_submissions",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-09 22:55
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0001_initial"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameModel(
|
||||||
|
old_name="Submission",
|
||||||
|
new_name="LogfileSubmission",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-09 23:05
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0002_rename_submission_logfilesubmission"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Objectiv",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("updated_at", models.DateTimeField()),
|
||||||
|
("objectiv_id", models.CharField(max_length=64)),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-09 23:06
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0003_objectiv"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="objectiv",
|
||||||
|
name="count",
|
||||||
|
field=models.IntegerField(default=1),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-09 23:21
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0004_objectiv_count"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="objectiv",
|
||||||
|
name="updated_at",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-09 23:44
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0005_remove_objectiv_updated_at"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ObjectivPoint",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("objectiv_id", models.CharField(max_length=64, unique=True)),
|
||||||
|
("display_string", models.CharField(max_length=255)),
|
||||||
|
("max_count", models.IntegerField(default=1)),
|
||||||
|
("point", models.IntegerField(default=0)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-11 08:20
|
||||||
|
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0006_objectivpoint"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="objectiv",
|
||||||
|
name="count",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="objectiv",
|
||||||
|
name="first_seen_at",
|
||||||
|
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="objectiv",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("objectiv_id", "user", "first_seen_at"),
|
||||||
|
name="unique_objectiv_per_user_timestamp",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-11 08:27
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0007_remove_objectiv_count_objectiv_first_seen_at_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="objectiv",
|
||||||
|
name="seed",
|
||||||
|
field=models.CharField(default="", max_length=32),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-11 08:31
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0008_objectiv_seed"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="objectiv",
|
||||||
|
name="submission",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
default=1,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="noita.logfilesubmission",
|
||||||
|
),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-14 23:36
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def fw_func(apps, _schema_editor):
|
||||||
|
Objectiv = apps.get_model("noita", "Objectiv")
|
||||||
|
Objectiv.objects.filter(objectiv_id="DEATH").delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0009_objectiv_submission"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="DeathCounter",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("seed", models.CharField(max_length=32)),
|
||||||
|
("seen_at", models.DateTimeField()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.RunPython(fw_func, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user