mutiple fixes
This commit is contained in:
@@ -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})"
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
from django.contrib import admin
|
||||
from animations.models import PuzzlePointsFactor, PuzzlePointsValue
|
||||
|
||||
|
||||
@admin.register(PuzzlePointsFactor)
|
||||
class PuzzlePointsFactorAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id",
|
||||
"cost",
|
||||
"cycles",
|
||||
"area",
|
||||
"special_notes",
|
||||
]
|
||||
list_filter = ["cost", "cycles", "area", "special_notes"]
|
||||
search_fields = ["cost", "cycles", "area", "special_notes"]
|
||||
readonly_fields = ["created_at", "updated_at"]
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Basic Information",
|
||||
{"fields": ("cost", "cycles", "area")},
|
||||
),
|
||||
(
|
||||
"Special notes",
|
||||
{
|
||||
"fields": ("special_notes",),
|
||||
"description": "Special notes about the puzzle. May be some extra restriction, etc...",
|
||||
},
|
||||
),
|
||||
(
|
||||
"Metadata",
|
||||
{
|
||||
"fields": ("created_at", "updated_at"),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(PuzzlePointsValue)
|
||||
class PuzzlePointsValueAdmin(admin.ModelAdmin):
|
||||
list_display = ["id", "points"]
|
||||
list_filter = ["points"]
|
||||
search_fields = ["points"]
|
||||
readonly_fields = ["created_at", "updated_at"]
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Basic Information",
|
||||
{"fields": ("points",)},
|
||||
),
|
||||
(
|
||||
"Metadata",
|
||||
{
|
||||
"fields": ("created_at", "updated_at"),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,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,
|
||||
}
|
||||
@@ -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=[])),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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=[])
|
||||
@@ -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]]
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+20
@@ -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':
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")}),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
+28
@@ -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'),
|
||||
),
|
||||
]
|
||||
+28
@@ -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'),
|
||||
),
|
||||
]
|
||||
+28
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user