Compare commits

...
72 Commits
Author SHA1 Message Date
Legrems 3e91ef77b6 chore: vite build 2026-05-24 19:21:05 +02:00
Legrems 215d68a0e1 chore: vite build 2026-05-24 19:14:08 +02:00
Legrems 47812ffd09 feat(market): settings toggle 2026-05-24 19:11:02 +02:00
Legrems a5fe8aacaf feat(migration): frontend migration submissions -> opus-magnum 2026-05-24 18:54:14 +02:00
Legrems b437210eb3 feat(migration): old submissions -> opus magnum app 2026-05-24 18:51:01 +02:00
Legrems 5584e54b58 migrate to opus-magnum app 2026-05-24 18:48:14 +02:00
Legrems 35ea54ecea chore: market build app 2026-05-24 18:19:06 +02:00
Legrems 821e453bc0 feat(noita): stores 2026-05-24 09:29:30 +02:00
Legrems 9fd0122a67 chore: market app build 2026-05-24 09:24:56 +02:00
Legrems e557fe2cda feat(market): add draft status and multiplier 2026-05-24 09:20:01 +02:00
Legrems 79e7cef3ba feat(market): resolve for admin 2026-05-23 20:33:21 +02:00
Legrems a264336bd8 feat(market): track user points change 2026-05-23 20:26:59 +02:00
Legrems 42e3571fab fix(market): better market card display 2026-05-23 20:06:17 +02:00
Legrems 43b314bb20 fix(openapi-ts): replace api call with generated api client 2026-05-23 19:51:08 +02:00
Legrems f1afb2096f fix(openapi-ts): first pass for replacing with openapi-ts 2026-05-23 18:53:33 +02:00
Legrems 62a81e57ad feat(openapi-ts): add generated api client 2026-05-23 18:50:26 +02:00
Legrems 303b9e1c8a feat(frontend): add openapi-ts 2026-05-23 18:50:19 +02:00
Legrems f7c7eba4da feat(market): basic page + submit 2026-05-23 18:30:50 +02:00
Legrems ce30539808 feat(market): base models 2026-05-23 17:48:27 +02:00
Legrems 544112b204 feat(games): add games to disable + path 2026-05-23 14:18:44 +02:00
Legrems 9f94fb3974 fix: add puzzle title in results page 2026-05-22 17:24:29 +02:00
Legrems 25072e8eb4 cache for result api 2026-05-22 17:21:53 +02:00
Legrems 9e5ab8539a top winners + by puzzle 2026-05-22 09:32:13 +02:00
Legrems 4ba6a48246 fix ranking order + display winning gifs 2026-05-22 06:32:18 +02:00
Legrems 92dddca964 tournament outputs 2026-05-22 06:18:11 +02:00
Legrems bf21a5eae6 fix(noita): better display name 2026-05-15 21:11:23 +02:00
Legrems 89faa7cd40 chore: build assets 2026-05-15 20:47:22 +02:00
Legrems a800c7fff7 fix(noita): all objectives display 2026-05-15 20:43:30 +02:00
Legrems 2ee8cd6be3 chore: vite build 2026-05-15 14:51:05 +02:00
Legrems 35824de310 admin display 2026-05-15 14:50:59 +02:00
Legrems 65cc2e555f fix: parse + store 2026-05-15 03:31:20 +02:00
Legrems 6d996a4e2f chore: vite build 2026-05-15 03:26:35 +02:00
Legrems f774ff3340 feat(opus): add coef subcards 2026-05-15 03:24:07 +02:00
Legrems 779393106d fix(opus): wrong frontend ordering points -> rank-points 2026-05-15 03:05:33 +02:00
Legrems 3e04f8312a feat(noita): death counter + ranking display 2026-05-15 02:15:00 +02:00
Legrems 7cfab20826 rework noita objectives + deathcounter 2026-05-15 01:50:34 +02:00
Legrems 754b0b0803 noita update 2026-05-10 23:09:42 +02:00
Legrems 79f469a393 chore: build vite assets 2026-05-10 04:01:57 +02:00
Legrems 5aeff9c218 feat(api): add cache + busting for admin 2026-05-10 04:00:53 +02:00
Legrems 2264401a91 fix: correct mime type for noita log file, opus card status display 2026-05-10 03:47:37 +02:00
Legrems 9662181d4d fix(cas): https in prod hihi 2026-05-10 03:28:13 +02:00
Legrems 90f1ce13cf fix: login with furl 2026-05-10 03:24:18 +02:00
Legrems f90377ac69 chore: remove building part in pyproject 2026-05-10 03:12:11 +02:00
Legrems d8d09c21d4 setup sentry 2026-05-10 03:11:10 +02:00
Legrems 3c86ca4c91 vite missing files 2026-05-10 03:05:45 +02:00
Legrems 0b138e315c vite assets build 2026-05-10 02:44:09 +02:00
Legrems d2a9dbe4a4 opus-magnum results view 2026-05-10 02:38:56 +02:00
Legrems fa53d74295 noita leaderboard + view + management commands 2026-05-10 02:11:30 +02:00
Legrems 52a6a4adb2 noita objectives + leaderboard 2026-05-10 02:01:10 +02:00
Legrems 69b6b46ee2 basic noita parsing 2026-05-10 01:45:29 +02:00
Legrems 119fdc2a51 basic noita submissions 2026-05-10 00:57:51 +02:00
Legrems 01b0dbd1d9 base for noita submitter 2026-05-10 00:36:15 +02:00
Legrems 19cc52c9f8 home page + opus result api working again 2026-05-10 00:17:20 +02:00
Legrems 07f5c76a52 opus-magnum split 2026-05-09 23:56:10 +02:00
Legrems 8584102402 remove not working endpoint 2026-05-09 23:42:26 +02:00
Legrems 404af4f90d chore: opus-submitter -> polylan-submitter 2026-05-09 23:25:34 +02:00
Legrems eb1eed852b chore: ruff format + fix 2026-05-09 22:13:40 +02:00
Legrems 11229a3906 frontend build 2025-11-28 14:35:20 +01:00
Legrems 38e2b5e858 remove custom manager on user 2025-11-28 14:34:29 +01:00
Legrems 1e4251c796 remove psycopg2, use psycopg 2025-11-28 14:25:59 +01:00
Legrems fa76fbce92 mutiple fixes 2025-11-28 14:05:26 +01:00
Legrems 9ee45463a8 better re-processing OCR for admin 2025-11-23 22:33:23 +01:00
Legrems 8f88548a59 single OCR thread in // 2025-11-23 12:20:59 +01:00
Legrems 012b72527b small ui fixes 2025-11-23 12:03:07 +01:00
Legrems 596731a8a7 auto-reload for admin + auto validation for all using python 2025-10-31 02:41:44 +01:00
Legrems e0ada1e26d add re-validation in python 2025-10-31 01:56:26 +01:00
Legrems f98145d6db some fixes 2025-10-31 01:05:57 +01:00
Legrems 0e1e77c2dd ruff + submissions validation 2025-10-30 14:43:19 +01:00
Legrems 15de496501 try for better ocr puzzle 2025-10-30 14:29:50 +01:00
Legrems 8960f551e6 add manual validation request on the submission form 2025-10-30 12:12:33 +01:00
Legrems 2260c7cc27 change for ocr confidence 2025-10-30 12:01:49 +01:00
Legrems b5f31a8c72 small ui tweaks 2025-10-30 11:32:00 +01:00
227 changed files with 15326 additions and 2643 deletions
-231
View File
@@ -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
-32
View File
@@ -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'
-6
View File
@@ -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()),
],
),
]
-3
View File
@@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.
-3
View File
@@ -1,3 +0,0 @@
from django.shortcuts import render
# Create your views here.
-68
View File
@@ -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,
}
-271
View File
@@ -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>
-5
View File
@@ -1,5 +0,0 @@
import { createApp } from 'vue'
import App from '@/App.vue'
import './style.css'
createApp(App).mount('#app')
-298
View File
@@ -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();
-88
View File
@@ -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[]
}
-33
View File
@@ -1,33 +0,0 @@
{
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot": {
"file": "assets/materialdesignicons-webfont-CSr8KVlo.eot",
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot"
},
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf": {
"file": "assets/materialdesignicons-webfont-B7mPwVP_.ttf",
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf"
},
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff": {
"file": "assets/materialdesignicons-webfont-PXm3-2wK.woff",
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff"
},
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2": {
"file": "assets/materialdesignicons-webfont-Dp5v-WZN.woff2",
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2"
},
"src/main.ts": {
"file": "assets/main-CNlI4PW6.js",
"name": "main",
"src": "src/main.ts",
"isEntry": true,
"css": [
"assets/main-HDjkw-xK.css"
],
"assets": [
"assets/materialdesignicons-webfont-CSr8KVlo.eot",
"assets/materialdesignicons-webfont-Dp5v-WZN.woff2",
"assets/materialdesignicons-webfont-PXm3-2wK.woff",
"assets/materialdesignicons-webfont-B7mPwVP_.ttf"
]
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,33 +0,0 @@
{
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot": {
"file": "assets/materialdesignicons-webfont-CSr8KVlo.eot",
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot"
},
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf": {
"file": "assets/materialdesignicons-webfont-B7mPwVP_.ttf",
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf"
},
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff": {
"file": "assets/materialdesignicons-webfont-PXm3-2wK.woff",
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff"
},
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2": {
"file": "assets/materialdesignicons-webfont-Dp5v-WZN.woff2",
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2"
},
"src/main.ts": {
"file": "assets/main-CNlI4PW6.js",
"name": "main",
"src": "src/main.ts",
"isEntry": true,
"css": [
"assets/main-HDjkw-xK.css"
],
"assets": [
"assets/materialdesignicons-webfont-CSr8KVlo.eot",
"assets/materialdesignicons-webfont-Dp5v-WZN.woff2",
"assets/materialdesignicons-webfont-PXm3-2wK.woff",
"assets/materialdesignicons-webfont-B7mPwVP_.ttf"
]
}
}
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

-6
View File
@@ -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'],
},
),
]
@@ -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),
),
]
-3
View File
@@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.
-3
View File
@@ -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
View File
@@ -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"}
+39
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "accounts"
@@ -0,0 +1,137 @@
# Generated by Django 5.2.7 on 2025-10-28 23:41
import django.contrib.auth.models
import django.contrib.auth.validators
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
]
operations = [
migrations.CreateModel(
name="CustomUser",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="email address"
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
),
(
"cas_user_id",
models.CharField(blank=True, max_length=50, null=True, unique=True),
),
("cas_groups", models.JSONField(blank=True, default=list)),
("cas_attributes", models.JSONField(blank=True, default=dict)),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
],
options={
"verbose_name": "user",
"verbose_name_plural": "users",
"abstract": False,
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
],
),
]
@@ -0,0 +1,17 @@
# Generated by Django 5.2.7 on 2026-05-23 18:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="customuser",
name="points",
field=models.IntegerField(default=1000),
),
]
@@ -1,6 +1,5 @@
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
import json
class CustomUser(AbstractUser): class CustomUser(AbstractUser):
@@ -15,6 +14,9 @@ class CustomUser(AbstractUser):
# Additional fields that might come from CAS # Additional fields that might come from CAS
cas_attributes = models.JSONField(default=dict, blank=True) cas_attributes = models.JSONField(default=dict, blank=True)
# Market points balance
points = models.IntegerField(default=1000)
def __str__(self): def __str__(self):
return f"{self.username} ({self.cas_user_id})" return f"{self.username} ({self.cas_user_id})"
@@ -57,4 +59,3 @@ class CustomUser(AbstractUser):
self.cas_attributes = attributes self.cas_attributes = attributes
self.save() self.save()
+1
View File
@@ -0,0 +1 @@
# Create your tests here.
+1
View File
@@ -0,0 +1 @@
# Create your views here.
+59
View File
@@ -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",),
},
),
)
+241
View File
@@ -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
+6
View File
@@ -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),
),
]
+24
View File
@@ -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)
+115
View File
@@ -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]
+1
View File
@@ -0,0 +1 @@
# Create your tests here.
+1
View File
@@ -0,0 +1 @@
# Create your views here.
+11
View File
@@ -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"]
+13
View File
@@ -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)
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class GamesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "games"
+22
View File
@@ -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),
]
+17
View File
@@ -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})"
+7
View File
@@ -0,0 +1,7 @@
from ninja import Schema
class GameOut(Schema):
steam_app_id: int
name: str
path: str
@@ -1,12 +1,13 @@
#!/usr/bin/env python #!/usr/bin/env python
"""Django's command-line utility for administrative tasks.""" """Django's command-line utility for administrative tasks."""
import os import os
import sys import sys
def main(): def main():
"""Run administrative tasks.""" """Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'opus_submitter.settings') os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polylan_submitter.settings")
try: try:
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
except ImportError as exc: except ImportError as exc:
@@ -18,5 +19,5 @@ def main():
execute_from_command_line(sys.argv) execute_from_command_line(sys.argv)
if __name__ == '__main__': if __name__ == "__main__":
main() main()
+92
View File
@@ -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
+184
View File
@@ -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
+6
View File
@@ -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,
),
),
]
+114
View File
@@ -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}"
+59
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
# Create your tests here.
+8
View File
@@ -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", {})
View File
+72
View File
@@ -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",)}),
)
+301
View File
@@ -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)}"}
+6
View File
@@ -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)),
],
),
]
@@ -0,0 +1,31 @@
# Generated by Django 5.2.7 on 2026-05-11 08:20
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("noita", "0006_objectivpoint"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveField(
model_name="objectiv",
name="count",
),
migrations.AddField(
model_name="objectiv",
name="first_seen_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddConstraint(
model_name="objectiv",
constraint=models.UniqueConstraint(
fields=("objectiv_id", "user", "first_seen_at"),
name="unique_objectiv_per_user_timestamp",
),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-05-11 08:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("noita", "0007_remove_objectiv_count_objectiv_first_seen_at_and_more"),
]
operations = [
migrations.AddField(
model_name="objectiv",
name="seed",
field=models.CharField(default="", max_length=32),
preserve_default=False,
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.2.7 on 2026-05-11 08:31
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("noita", "0008_objectiv_seed"),
]
operations = [
migrations.AddField(
model_name="objectiv",
name="submission",
field=models.ForeignKey(
default=1,
on_delete=django.db.models.deletion.CASCADE,
to="noita.logfilesubmission",
),
preserve_default=False,
),
]
@@ -0,0 +1,34 @@
# Generated by Django 5.2.7 on 2026-05-14 23:36
from django.db import migrations, models
def fw_func(apps, _schema_editor):
Objectiv = apps.get_model("noita", "Objectiv")
Objectiv.objects.filter(objectiv_id="DEATH").delete()
class Migration(migrations.Migration):
dependencies = [
("noita", "0009_objectiv_submission"),
]
operations = [
migrations.CreateModel(
name="DeathCounter",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("seed", models.CharField(max_length=32)),
("seen_at", models.DateTimeField()),
],
),
migrations.RunPython(fw_func, migrations.RunPython.noop),
]

Some files were not shown because too many files have changed in this diff Show More