Compare commits
75
Commits
533d856c5b
...
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 | ||
|
|
6a882ce39a | ||
|
|
6dcd0174f9 | ||
|
|
fdbd660d22 |
@@ -177,3 +177,4 @@ pyrightconfig.json
|
||||
|
||||
tags
|
||||
media/
|
||||
settingsLocal.py
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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-XyhNv7AL.js",
|
||||
"name": "main",
|
||||
"src": "src/main.ts",
|
||||
"isEntry": true,
|
||||
"css": [
|
||||
"assets/main-ClCUrjHA.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,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.db import models
|
||||
import json
|
||||
|
||||
|
||||
class CustomUser(AbstractUser):
|
||||
@@ -15,6 +14,9 @@ class CustomUser(AbstractUser):
|
||||
# Additional fields that might come from CAS
|
||||
cas_attributes = models.JSONField(default=dict, blank=True)
|
||||
|
||||
# Market points balance
|
||||
points = models.IntegerField(default=1000)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.username} ({self.cas_user_id})"
|
||||
|
||||
@@ -57,4 +59,3 @@ class CustomUser(AbstractUser):
|
||||
self.cas_attributes = attributes
|
||||
|
||||
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
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'opus_submitter.settings')
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polylan_submitter.settings")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
@@ -18,5 +19,5 @@ def main():
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-14 23:43
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("noita", "0010_deathcounter"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="deathcounter",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
default=1,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-14 23:45
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("noita", "0011_deathcounter_user"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name="deathcounter",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("user_id", "seen_at"), name="unique_death_per_seen_at"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,84 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
import uuid
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def submission_file_upload_path(instance, filename):
|
||||
"""Generate upload path for submission files"""
|
||||
# Create path: submissions/{submission_id}/{uuid}_{filename}
|
||||
ext = filename.split(".")[-1] if "." in filename else ""
|
||||
new_filename = f"{uuid.uuid4()}_{filename}" if ext else str(uuid.uuid4())
|
||||
return f"noita-submissions/{instance.id}/{new_filename}"
|
||||
|
||||
|
||||
class LogfileSubmission(models.Model):
|
||||
"""Model representing a submission containing multiple puzzle responses"""
|
||||
|
||||
# Identification
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
|
||||
content_type = models.CharField(max_length=100, help_text="MIME type of the file")
|
||||
file_size = models.PositiveIntegerField(help_text="File size in bytes")
|
||||
file = models.FileField(
|
||||
upload_to=submission_file_upload_path,
|
||||
help_text="Uploaded file (image/gif)",
|
||||
)
|
||||
|
||||
# User information (optional for anonymous submissions)
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="User who made the submission (null for anonymous)",
|
||||
related_name="noita_submissions",
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
processed = models.BooleanField(default=False)
|
||||
|
||||
|
||||
class Objectiv(models.Model):
|
||||
objectiv_id = models.CharField(max_length=64)
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
first_seen_at = models.DateTimeField(default=timezone.now)
|
||||
seed = models.CharField(max_length=32)
|
||||
submission = models.ForeignKey("LogfileSubmission", on_delete=models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["objectiv_id", "user", "first_seen_at"],
|
||||
name="unique_objectiv_per_user_timestamp",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class ObjectivPoint(models.Model):
|
||||
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)
|
||||
|
||||
|
||||
class DeathCounter(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
seed = models.CharField(max_length=32)
|
||||
seen_at = models.DateTimeField()
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user_id", "seen_at"],
|
||||
name="unique_death_per_seen_at",
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from ninja import Schema
|
||||
|
||||
|
||||
class NoitaSubmissionOut(Schema):
|
||||
id: str
|
||||
user_id: Optional[int]
|
||||
username: Optional[str]
|
||||
file_size: int
|
||||
content_type: str
|
||||
created_at: datetime
|
||||
processed: bool
|
||||
|
||||
|
||||
class ObjectivResultOut(Schema):
|
||||
objectiv_id: str
|
||||
display_string: str
|
||||
first_seen_at: datetime | None
|
||||
count: int
|
||||
max_count: int
|
||||
seed: str | None
|
||||
points_per_objectiv: int
|
||||
total_points: int | None
|
||||
|
||||
|
||||
class ResultsOut(Schema):
|
||||
total_score: int
|
||||
deaths_count: int
|
||||
objectives: list[ObjectivResultOut]
|
||||
|
||||
|
||||
class LeaderboardEntryOut(Schema):
|
||||
rank: int
|
||||
username: str
|
||||
is_staff: bool
|
||||
total_score: int
|
||||
objectives_count: int
|
||||
deaths_count: int
|
||||
|
||||
|
||||
class LeaderboardOut(Schema):
|
||||
leaderboard: list[LeaderboardEntryOut]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user