mutiple fixes

This commit is contained in:
2025-11-28 14:05:26 +01:00
parent 9ee45463a8
commit fa76fbce92
36 changed files with 694 additions and 242 deletions
+13
View File
@@ -2,6 +2,14 @@ from django.contrib.auth.models import AbstractUser
from django.db import models
class UserQuerySet(models.QuerySet):
pass
class UserManager(models.Manager.from_queryset(UserQuerySet)):
pass
class CustomUser(AbstractUser):
"""Custom User model to store CAS attributes from PolyLAN."""
@@ -14,6 +22,11 @@ class CustomUser(AbstractUser):
# Additional fields that might come from CAS
cas_attributes = models.JSONField(default=dict, blank=True)
objects = UserManager()
class Meta:
_base_manager_name = "objects"
def __str__(self):
return f"{self.username} ({self.cas_user_id})"
+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",),
},
),
)
+36
View File
@@ -0,0 +1,36 @@
from django.http.request import HttpRequest
from ninja import Router
from collections import defaultdict
from accounts.models import CustomUser
from animations.schemas import RankingSchema
from submissions.models import PuzzleResponse, SteamCollectionItem
router = Router()
@router.get("results", response=RankingSchema)
def results(request: HttpRequest) -> dict:
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)
return {
"users": CustomUser.objects.filter(pk__in=responses_by_userid.keys()),
"puzzles": SteamCollectionItem.objects.all(),
"responses_by_userid": responses_by_userid,
"ranking_by_puzzle": ranking,
}
+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,26 @@
# 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,22 @@
# 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=[])),
],
),
]
+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=[])
+37
View File
@@ -0,0 +1,37 @@
from ninja import ModelSchema, Schema
from submissions.models import PuzzleResponse
from submissions.schemas import SteamCollectionItemOut, UserInfoOut
class PuzzleResponseRankingOut(ModelSchema):
class Meta:
model = PuzzleResponse
fields = [
"id",
"puzzle_name",
"created_at",
"updated_at",
]
points: int
rank_points: int
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 RankingSchema(Schema):
users: list[UserInfoOut]
puzzles: list[SteamCollectionItemOut]
responses_by_userid: dict[int, list[PuzzleResponseRankingOut]]
ranking_by_puzzle: dict[int, list[PuzzleResponseRankingOut]]
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+3
View File
@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.
+2
View File
@@ -1,6 +1,7 @@
from ninja import NinjaAPI
from submissions.api import router as submissions_router
from submissions.schemas import UserInfoOut
from animations.api import router as results_router
# Create the main API instance
api = NinjaAPI(
@@ -26,6 +27,7 @@ It provides features for user authentication, puzzle listing, submission uploads
# Include the submissions router
api.add_router("/submissions/", submissions_router, tags=["submissions"])
api.add_router("/results/", results_router, tags=["results"])
# Health check endpoint
@@ -40,6 +40,7 @@ INSTALLED_APPS = [
"django.contrib.staticfiles",
"django_vite",
"accounts",
"animations",
"submissions",
]
+1
View File
@@ -11,6 +11,7 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.1.16",
"@tanstack/vue-table": "^8.21.3",
"@vueuse/core": "^14.0.0",
"install": "^0.13.0",
"pinia": "^3.0.3",
+20
View File
@@ -11,6 +11,9 @@ importers:
'@tailwindcss/vite':
specifier: ^4.1.16
version: 4.1.16(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2))
'@tanstack/vue-table':
specifier: ^8.21.3
version: 8.21.3(vue@3.5.22(typescript@5.9.3))
'@vueuse/core':
specifier: ^14.0.0
version: 14.0.0(vue@3.5.22(typescript@5.9.3))
@@ -452,6 +455,16 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7
'@tanstack/table-core@8.21.3':
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
'@tanstack/vue-table@8.21.3':
resolution: {integrity: sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==}
engines: {node: '>=12'}
peerDependencies:
vue: '>=3.2'
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -1120,6 +1133,13 @@ snapshots:
tailwindcss: 4.1.16
vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)
'@tanstack/table-core@8.21.3': {}
'@tanstack/vue-table@8.21.3(vue@3.5.22(typescript@5.9.3))':
dependencies:
'@tanstack/table-core': 8.21.3
vue: 3.5.22(typescript@5.9.3)
'@types/estree@1.0.8': {}
'@types/node@24.9.2':
+6 -3
View File
@@ -3,6 +3,7 @@ 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 Results from "@/components/Results.vue";
import { apiService, errorHelpers } from "@/services/apiService";
import { usePuzzlesStore } from "@/stores/puzzles";
import { useSubmissionsStore } from "@/stores/submissions";
@@ -39,10 +40,10 @@ const responsesByPuzzle = computed(() => {
submissions.value.forEach((submission) => {
submission.responses.forEach((response) => {
// Handle both number and object types for puzzle field
if (!grouped[response.puzzle]) {
grouped[response.puzzle] = [];
if (!grouped[response.puzzle_id]) {
grouped[response.puzzle_id] = [];
}
grouped[response.puzzle].push(response);
grouped[response.puzzle_id].push(response);
});
});
return grouped;
@@ -197,6 +198,8 @@ const reloadPage = () => {
</div>
</div>
<Results />
<!-- Admin Panel (only for superusers) -->
<div v-if="isSuperuser">
<AdminPanel />
+23 -17
View File
@@ -151,10 +151,6 @@
<img :src="file.file_url" />
</div>
<div class="mockup-code w-full">
<pre><code>{{ validationModal}}</code></pre>
</div>
<div v-if="validationModal.response" class="space-y-4">
<div class="alert alert-info">
<i class="mdi mdi-information-outline"></i>
@@ -194,7 +190,9 @@
v-model="validationModal.data.validated_cost"
type="text"
class="input input-bordered input-sm"
:placeholder="validationModal.response.cost || 'Enter cost'"
:placeholder="
validationModal.response.cost?.toString() || 'Enter cost'
"
/>
</div>
@@ -206,7 +204,9 @@
v-model="validationModal.data.validated_cycles"
type="text"
class="input input-bordered input-sm"
:placeholder="validationModal.response.cycles || 'Enter cycles'"
:placeholder="
validationModal.response.cycles?.toString() || 'Enter cycles'
"
/>
</div>
@@ -218,7 +218,9 @@
v-model="validationModal.data.validated_area"
type="text"
class="input input-bordered input-sm"
:placeholder="validationModal.response.area || 'Enter area'"
:placeholder="
validationModal.response.area?.toString() || 'Enter area'
"
/>
</div>
</div>
@@ -239,6 +241,10 @@
{{ isValidating ? "Validating..." : "Validate" }}
</button>
</div>
<div class="mockup-code w-full">
<pre><code>{{ validationModal}}</code></pre>
</div>
</div>
</div>
<div class="modal-backdrop" @click="closeValidationModal"></div>
@@ -270,9 +276,9 @@ const validationModal = ref({
response: null as PuzzleResponse | null,
data: {
puzzle: -1,
validated_cost: "",
validated_cycles: "",
validated_area: "",
validated_cost: 0,
validated_cycles: 0,
validated_area: 0,
},
});
@@ -332,10 +338,10 @@ const autoValidationResponse = async () => {
const openValidationModal = (response: PuzzleResponse) => {
validationModal.value.response = response;
validationModal.value.data = {
puzzle: response.puzzle || -1,
validated_cost: response.cost || "",
validated_cycles: response.cycles || "",
validated_area: response.area || "",
puzzle: response.puzzle_id || -1,
validated_cost: response.cost || 0,
validated_cycles: response.cycles || 0,
validated_area: response.area || 0,
};
validationModal.value.show = true;
};
@@ -345,9 +351,9 @@ const closeValidationModal = () => {
validationModal.value.response = null;
validationModal.value.data = {
puzzle: -1,
validated_cost: "",
validated_cycles: "",
validated_area: "",
validated_cost: 0,
validated_cycles: 0,
validated_area: 0,
};
};
+3 -4
View File
@@ -1,6 +1,7 @@
<template>
<div
class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow duration-300"
class="card bg-base-100 shadow-lg hover:shadow-2xl transition-shadow duration-300"
:class="responses?.length == 0 ? 'shadow-red-900' : 'shadow-primary-300'"
>
<div class="card-body">
<div class="flex items-start justify-between">
@@ -14,9 +15,7 @@
<div class="badge badge-primary badge-sm">
{{ puzzle.steam_item_id }}
</div>
<div class="badge badge-ghost badge-sm">
Order: {{ puzzle.order_index + 1 }}
</div>
<div class="badge badge-ghost badge-sm">ID: {{ puzzle.id }}</div>
</div>
<p
+12
View File
@@ -0,0 +1,12 @@
<script setup lang="ts"></script>
<template>
<div class="mb-8">
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<h2 class="card-title text-2xl">General Results</h2>
<div class="flex flex-wrap gap-4 mt-4">TODO :)</div>
</div>
</div>
</div>
</template>
+19 -57
View File
@@ -119,9 +119,9 @@ export class ApiService {
responses: Array<{
puzzle_id: number
puzzle_name: string
cost?: string
cycles?: string
area?: string
cost?: number
cycles?: number
area?: number
needs_manual_validation?: boolean
ocr_confidence_cost?: number
ocr_confidence_cycles?: number
@@ -147,9 +147,9 @@ export class ApiService {
async validateResponse(
responseId: number,
validationData: {
validated_cost?: string
validated_cycles?: string
validated_area?: string
validated_cost?: number
validated_cycles?: number
validated_area?: number
}
): Promise<ApiResponse<PuzzleResponse>> {
return this.request<PuzzleResponse>(`/submissions/responses/${responseId}/validate`, {
@@ -237,61 +237,23 @@ export const submissionHelpers = {
notes?: string,
manualValidationRequested?: boolean
): Promise<ApiResponse<Submission>> {
// Group files by detected puzzle
const responsesByPuzzle: Record<string, {
puzzle: SteamCollectionItem | null,
files: SubmissionFile[]
}> = {}
files.forEach(file => {
// Use manual puzzle selection if available, otherwise fall back to OCR
const puzzleName = file.manualPuzzleSelection || file.ocrData?.puzzle
const responses = files.map(item => {
if (puzzleName) {
if (!responsesByPuzzle[puzzleName]) {
responsesByPuzzle[puzzleName] = {
puzzle: puzzleHelpers.findPuzzleByName(puzzles, puzzleName),
files: []
}
}
responsesByPuzzle[puzzleName].files.push(file)
}
})
// Create responses array
const responses = Object.entries(responsesByPuzzle)
.filter(([_, data]) => data.puzzle) // Only include matched puzzles
.map(([puzzleName, data]) => {
// Get OCR data from the first file with complete data
const fileWithOCR = data.files.find(f =>
f.ocrData?.cost || f.ocrData?.cycles || f.ocrData?.area
)
// Check if manual validation is needed
const needsValidation = !fileWithOCR?.ocrData ||
!fileWithOCR.ocrData.cost ||
!fileWithOCR.ocrData.cycles ||
!fileWithOCR.ocrData.area
return {
puzzle_id: data.puzzle!.id,
puzzle_name: puzzleName,
cost: fileWithOCR?.ocrData?.cost,
cycles: fileWithOCR?.ocrData?.cycles,
area: fileWithOCR?.ocrData?.area,
needs_manual_validation: needsValidation,
ocr_confidence_cost: fileWithOCR?.ocrData?.confidence?.cost || 0.0,
ocr_confidence_cycles: fileWithOCR?.ocrData?.confidence?.cycles || 0.0,
ocr_confidence_area: fileWithOCR?.ocrData?.confidence?.area || 0.0
}
})
if (responses.length === 0) {
const puzzle = puzzleHelpers.findPuzzleByName(puzzles, item.ocrData?.puzzle || '')
if (!puzzle) { return }
return {
error: 'No valid puzzle responses found',
status: 400
puzzle_id: puzzle.id,
puzzle_name: item.ocrData?.puzzle || '',
cost: item.ocrData?.cost,
cycles: item.ocrData?.cycles,
area: item.ocrData?.area,
needs_manual_validation: (item.ocrData?.confidence.overall ?? 0) <= 0.8,
ocr_confidence_cost: item.ocrData?.confidence?.cost || 0.0,
ocr_confidence_cycles: item.ocrData?.confidence?.cycles || 0.0,
ocr_confidence_area: item.ocrData?.confidence?.area || 0.0
}
}
}).filter(item => item !== undefined)
// Extract actual File objects for upload
const fileObjects = files.map(f => f.file)
+6 -5
View File
@@ -1,6 +1,7 @@
import { OpusMagnumData } from '@/types';
import { createWorker } from 'tesseract.js';
export interface OpusMagnumData {
export interface OpusMagnumOCRData {
puzzle: string;
cost: string;
cycles: string;
@@ -132,7 +133,7 @@ export class OpusMagnumOCRService {
ctx.drawImage(img, 0, 0);
// Extract text from each region
const results: Partial<OpusMagnumData> = {};
const results: Partial<OpusMagnumOCRData> = {};
const confidenceScores: Record<string, number> = {};
for (const [key, region] of Object.entries(this.regions)) {
@@ -227,9 +228,9 @@ export class OpusMagnumOCRService {
resolve({
puzzle: results.puzzle || '',
cost: results.cost || '',
cycles: results.cycles || '',
area: results.area || '',
cost: parseInt(results.cost || ''),
cycles: parseInt(results.cycles || ''),
area: parseInt(results.area || ''),
confidence: {
puzzle: confidenceScores.puzzle || 0,
cost: confidenceScores.cost || 0,
+5 -4
View File
@@ -1,4 +1,4 @@
import { defineStore } from 'pinia'
import { defineStore, storeToRefs } from 'pinia'
import { ref } from 'vue'
import type { Submission, SubmissionFile } from '@/types'
import { submissionHelpers } from '@/services/apiService'
@@ -12,6 +12,9 @@ export const useSubmissionsStore = defineStore('submissions', () => {
const error = ref<string>('')
const isSubmissionModalOpen = ref(false)
const puzzlesStore = usePuzzlesStore()
const { puzzles } = storeToRefs(puzzlesStore)
// Actions
const loadSubmissions = async (limit = 20, offset = 0) => {
try {
@@ -42,11 +45,9 @@ export const useSubmissionsStore = defineStore('submissions', () => {
isLoading.value = true
error.value = ''
const puzzlesStore = usePuzzlesStore()
const response = await submissionHelpers.createFromFiles(
files,
puzzlesStore.puzzles,
puzzles.value,
notes,
manualValidationRequested
)
+13 -13
View File
@@ -26,9 +26,9 @@ export interface SteamCollectionItem {
export interface OpusMagnumData {
puzzle: string
cost: string
cycles: string
area: string
cost: number
cycles: number
area: number
confidence: {
puzzle: number
cost: number
@@ -54,21 +54,21 @@ export interface SubmissionFile {
export interface PuzzleResponse {
id?: number
// puzzle: number | SteamCollectionItem
puzzle: number
puzzle_id: number
puzzle_name: string
cost?: string
cycles?: string
area?: string
cost?: number
cycles?: number
area?: number
needs_manual_validation?: boolean
ocr_confidence_cost?: number
ocr_confidence_cycles?: number
ocr_confidence_area?: number
validated_cost?: string
validated_cycles?: string
validated_area?: string
final_cost?: string
final_cycles?: string
final_area?: string
validated_cost?: number
validated_cycles?: number
validated_area?: number
final_cost?: number
final_cycles?: number
final_area?: number
files?: SubmissionFile[]
created_at?: string
updated_at?: string
+2 -1
View File
@@ -1,7 +1,7 @@
from django.contrib import admin
from django.utils.html import format_html
from django.utils import timezone
from .models import (
from submissions.models import (
SteamAPIKey,
SteamCollection,
SteamCollectionItem,
@@ -148,6 +148,7 @@ class SteamCollectionItemAdmin(admin.ModelAdmin):
("Author Information", {"fields": ("author_name", "author_steam_id")}),
("Metadata", {"fields": ("tags",)}),
("Timestamps", {"fields": ("created_at", "updated_at")}),
("Points factor", {"fields": ("points_factor", "points_value")}),
)
+9 -1
View File
@@ -112,6 +112,14 @@ def create_submission(
ocr_confidence_cost=response_data.ocr_confidence_cost,
ocr_confidence_cycles=response_data.ocr_confidence_cycles,
ocr_confidence_area=response_data.ocr_confidence_area,
**{
# Put validated if not manual validation is needed
"validated_cost": response_data.cost,
"validated_cycles": response_data.cycles,
"validated_area": response_data.area,
}
if not data.manual_validation_requested
else {},
)
# Process files for this response
@@ -197,7 +205,7 @@ def validate_response(request, response_id: int, data: ValidationIn):
@router.put("/responses/{response_id}/validate/auto", response=PuzzleResponseOut)
def validate_response(request, response_id: int):
def validate_auto(request, response_id: int):
"""Try to auto validate a puzzle response"""
if not request.user.is_authenticated or not request.user.is_staff:
@@ -0,0 +1,20 @@
# Generated by Django 5.2.7 on 2025-11-23 22:33
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('animations', '0001_initial'),
('submissions', '0008_alter_puzzleresponse_unique_together'),
]
operations = [
migrations.AddField(
model_name='steamcollectionitem',
name='points_factor',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='animations.puzzlepointsfactor'),
),
]
@@ -0,0 +1,28 @@
# Generated by Django 5.2.7 on 2025-11-23 23:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('submissions', '0009_steamcollectionitem_points_factor'),
]
operations = [
migrations.AlterField(
model_name='puzzleresponse',
name='validated_area',
field=models.IntegerField(blank=True, help_text='Manually validated area value'),
),
migrations.AlterField(
model_name='puzzleresponse',
name='validated_cost',
field=models.IntegerField(blank=True, help_text='Manually validated cost value'),
),
migrations.AlterField(
model_name='puzzleresponse',
name='validated_cycles',
field=models.IntegerField(blank=True, help_text='Manually validated cycles value'),
),
]
@@ -0,0 +1,28 @@
# Generated by Django 5.2.7 on 2025-11-23 23:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('submissions', '0010_alter_puzzleresponse_validated_area_and_more'),
]
operations = [
migrations.AlterField(
model_name='puzzleresponse',
name='area',
field=models.IntegerField(blank=True, help_text='Area value from OCR'),
),
migrations.AlterField(
model_name='puzzleresponse',
name='cost',
field=models.IntegerField(blank=True, help_text='Cost value from OCR'),
),
migrations.AlterField(
model_name='puzzleresponse',
name='cycles',
field=models.IntegerField(blank=True, help_text='Cycles value from OCR'),
),
]
@@ -0,0 +1,28 @@
# Generated by Django 5.2.7 on 2025-11-23 23:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('submissions', '0011_alter_puzzleresponse_area_alter_puzzleresponse_cost_and_more'),
]
operations = [
migrations.AlterField(
model_name='puzzleresponse',
name='validated_area',
field=models.IntegerField(blank=True, help_text='Manually validated area value', null=True),
),
migrations.AlterField(
model_name='puzzleresponse',
name='validated_cost',
field=models.IntegerField(blank=True, help_text='Manually validated cost value', null=True),
),
migrations.AlterField(
model_name='puzzleresponse',
name='validated_cycles',
field=models.IntegerField(blank=True, help_text='Manually validated cycles value', null=True),
),
]
@@ -0,0 +1,20 @@
# Generated by Django 5.2.7 on 2025-11-24 01:00
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('animations', '0002_puzzlepointsvalue'),
('submissions', '0012_alter_puzzleresponse_validated_area_and_more'),
]
operations = [
migrations.AddField(
model_name='steamcollectionitem',
name='points_value',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='animations.puzzlepointsvalue'),
),
]
+99 -11
View File
@@ -1,11 +1,43 @@
from typing import Self
from django.db import models
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
import uuid
from django.db.models.expressions import Window
from django.db.models.functions import Cast, Rank, RowNumber
from django.db.models.query import F
User = get_user_model()
class JsonIndex(models.Func):
function = ""
template = "%(json_field)s -> (%(index)s::int)"
def __init__(self, json_field, index_expression, **extra):
super().__init__(json_field, index_expression, **extra)
self.output_field = models.IntegerField()
def resolve_expression(
self, query, allow_joins=True, reuse=None, summarize=False, for_save=False
):
# Resolve both expressions in the query context to ensure joins are set up correctly
clone = self.copy()
clone.source_expressions = [
expr.resolve_expression(query, allow_joins, reuse, summarize, for_save)
for expr in self.source_expressions
]
return clone
def as_sql(self, compiler, connection):
json_sql, json_params = compiler.compile(self.source_expressions[0])
idx_sql, idx_params = compiler.compile(self.source_expressions[1])
sql = self.template % {"json_field": json_sql, "index": idx_sql}
params = json_params + idx_params
return sql, params
class SteamAPIKey(models.Model):
"""Model to store Steam API key configuration - Admin only"""
@@ -178,6 +210,19 @@ class SteamCollectionItem(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# Puzzle points
points_factor = models.ForeignKey(
"animations.PuzzlePointsFactor",
null=True,
on_delete=models.SET_NULL,
)
points_value = models.ForeignKey(
"animations.PuzzlePointsValue",
null=True,
on_delete=models.SET_NULL,
)
class Meta:
ordering = ["collection", "order_index"]
unique_together = ["collection", "steam_item_id"]
@@ -271,6 +316,48 @@ class Submission(models.Model):
return self.responses.filter(needs_manual_validation=True).exists()
class PuzzleResponseQuerySet(models.QuerySet):
def annotate_rank_points(self) -> Self:
return (
self.annotate(
points=F("puzzle__points_factor__cost") * F("validated_cost")
+ F("puzzle__points_factor__cycles") * F("validated_cycles")
+ F("puzzle__points_factor__area") * F("validated_area")
)
.annotate(
user_response_rank=Window(
expression=RowNumber(),
partition_by=[F("puzzle"), F("submission__user")],
order_by=F("points").asc(),
)
)
# .filter(user_response_rank=1)
.annotate(
puzzle_user_rank=Window(
expression=Rank(),
partition_by=[F("puzzle")],
order_by=F("points").asc(),
)
)
.annotate(
rank_points=Cast(
JsonIndex(
F("puzzle__points_value__points"),
Cast(F("puzzle_user_rank") - 1, models.IntegerField()),
),
models.IntegerField(),
)
)
)
def filter_user_best_response(self) -> Self:
return self.annotate_rank_points().filter(user_response_rank=1)
class PuzzleResponseManager(models.Manager.from_queryset(PuzzleResponseQuerySet)):
pass
class PuzzleResponse(models.Model):
"""Model representing a response/solution for a specific puzzle"""
@@ -289,11 +376,9 @@ class PuzzleResponse(models.Model):
puzzle_name = models.CharField(
max_length=255, help_text="Puzzle name as detected by OCR"
)
cost = models.CharField(max_length=20, blank=True, help_text="Cost value from OCR")
cycles = models.CharField(
max_length=20, blank=True, help_text="Cycles value from OCR"
)
area = models.CharField(max_length=20, blank=True, help_text="Area value from OCR")
cost = models.IntegerField(blank=True, help_text="Cost value from OCR")
cycles = models.IntegerField(blank=True, help_text="Cycles value from OCR")
area = models.IntegerField(blank=True, help_text="Area value from OCR")
# Validation flags
needs_manual_validation = models.BooleanField(
@@ -311,24 +396,27 @@ class PuzzleResponse(models.Model):
)
# Manual validation overrides
validated_cost = models.CharField(
max_length=20, blank=True, help_text="Manually validated cost value"
validated_cost = models.IntegerField(
null=True, blank=True, help_text="Manually validated cost value"
)
validated_cycles = models.CharField(
max_length=20, blank=True, help_text="Manually validated cycles value"
validated_cycles = models.IntegerField(
null=True, blank=True, help_text="Manually validated cycles value"
)
validated_area = models.CharField(
max_length=20, blank=True, help_text="Manually validated area value"
validated_area = models.IntegerField(
null=True, blank=True, help_text="Manually validated area value"
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
objects = PuzzleResponseManager()
class Meta:
ordering = ["submission", "puzzle__order_index"]
verbose_name = "Puzzle Response"
verbose_name_plural = "Puzzle Responses"
_base_manager_name = "objects"
def __str__(self):
return f"Response for {self.puzzle_name} in {self.submission}"
+9 -9
View File
@@ -20,9 +20,9 @@ class PuzzleResponseIn(Schema):
puzzle_id: int
puzzle_name: str
cost: Optional[str] = None
cycles: Optional[str] = None
area: Optional[str] = None
cost: Optional[int] = None
cycles: Optional[int] = None
area: Optional[int] = None
needs_manual_validation: bool = False
ocr_confidence_cost: Optional[float] = None
ocr_confidence_cycles: Optional[float] = None
@@ -61,9 +61,9 @@ class PuzzleResponseOut(ModelSchema):
"""Schema for puzzle response output"""
files: List[SubmissionFileOut]
final_cost: Optional[str]
final_cycles: Optional[str]
final_area: Optional[str]
final_cost: Optional[int]
final_cycles: Optional[int]
final_area: Optional[int]
class Meta:
model = PuzzleResponse
@@ -126,9 +126,9 @@ class ValidationIn(Schema):
"""Schema for manual validation input"""
puzzle: Optional[int] = None
validated_cost: Optional[str] = None
validated_cycles: Optional[str] = None
validated_area: Optional[str] = None
validated_cost: Optional[int] = None
validated_cycles: Optional[int] = None
validated_area: Optional[int] = None
# Collection Schemas
+7 -2
View File
@@ -4,7 +4,7 @@ Utilities for fetching Steam Workshop collection data using Steam Web API
import re
import requests
from submissions.models import SteamCollection, SubmissionFile
from submissions.models import SteamCollection, SteamCollectionItem, SubmissionFile
from datetime import datetime
from django.utils import timezone
from django.conf import settings
@@ -485,7 +485,7 @@ def verify_ocr_data_for_file(file: str) -> tuple[str, int, int, int]:
# cv2.imwrite("output_debug.jpg", output)
os.remove("temp.jpg")
return puzzle, cost, cycles, area
return puzzle, int(cost), int(cycles), int(area)
def verify_and_validate_ocr_date_for_submission(file: SubmissionFile):
@@ -496,10 +496,15 @@ def verify_and_validate_ocr_date_for_submission(file: SubmissionFile):
f"{r.submission.user}: ({r.cost: >4} {r.cycles: >4} {r.area: >4}) -> ({ocr_data[1]: >4} {ocr_data[2]: >4} {ocr_data[3]: >4})"
)
if puzzle := SteamCollectionItem.objects.filter(title=ocr_data[0]).first():
# print(f"{r.puzzle.title} -> {ocr_data[0]}")
r.puzzle = puzzle
valid_count = 0
for index, field in enumerate(["cost", "cycles", "area"]):
value = getattr(r, field, -1)
# print(f"{value} -> {ocr_data[index + 1]}")
if value == ocr_data[index + 1]:
setattr(r, f"validated_{field}", value)
valid_count += 1