Compare commits
66
Commits
f98145d6db
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e91ef77b6
|
||
|
|
215d68a0e1
|
||
|
|
47812ffd09
|
||
|
|
a5fe8aacaf
|
||
|
|
b437210eb3
|
||
|
|
5584e54b58
|
||
|
|
35ea54ecea
|
||
|
|
821e453bc0
|
||
|
|
9fd0122a67
|
||
|
|
e557fe2cda
|
||
|
|
79e7cef3ba
|
||
|
|
a264336bd8
|
||
|
|
42e3571fab
|
||
|
|
43b314bb20
|
||
|
|
f1afb2096f
|
||
|
|
62a81e57ad
|
||
|
|
303b9e1c8a
|
||
|
|
f7c7eba4da
|
||
|
|
ce30539808
|
||
|
|
544112b204
|
||
|
|
9f94fb3974
|
||
|
|
25072e8eb4
|
||
|
|
9e5ab8539a
|
||
|
|
4ba6a48246
|
||
|
|
92dddca964
|
||
|
|
bf21a5eae6
|
||
|
|
89faa7cd40
|
||
|
|
a800c7fff7
|
||
|
|
2ee8cd6be3
|
||
|
|
35824de310
|
||
|
|
65cc2e555f
|
||
|
|
6d996a4e2f
|
||
|
|
f774ff3340
|
||
|
|
779393106d
|
||
|
|
3e04f8312a
|
||
|
|
7cfab20826
|
||
|
|
754b0b0803
|
||
|
|
79f469a393
|
||
|
|
5aeff9c218
|
||
|
|
2264401a91
|
||
|
|
9662181d4d
|
||
|
|
90f1ce13cf
|
||
|
|
f90377ac69
|
||
|
|
d8d09c21d4
|
||
|
|
3c86ca4c91
|
||
|
|
0b138e315c
|
||
|
|
d2a9dbe4a4
|
||
|
|
fa53d74295
|
||
|
|
52a6a4adb2
|
||
|
|
69b6b46ee2
|
||
|
|
119fdc2a51
|
||
|
|
01b0dbd1d9
|
||
|
|
19cc52c9f8
|
||
|
|
07f5c76a52
|
||
|
|
8584102402
|
||
|
|
404af4f90d
|
||
|
|
eb1eed852b
|
||
|
|
11229a3906
|
||
|
|
38e2b5e858
|
||
|
|
1e4251c796
|
||
|
|
fa76fbce92
|
||
|
|
9ee45463a8
|
||
|
|
8f88548a59
|
||
|
|
012b72527b
|
||
|
|
596731a8a7 | ||
|
|
e0ada1e26d |
@@ -1,67 +0,0 @@
|
|||||||
from ninja import NinjaAPI
|
|
||||||
from submissions.api import router as submissions_router
|
|
||||||
from submissions.schemas import UserInfoOut
|
|
||||||
|
|
||||||
# Create the main API instance
|
|
||||||
api = NinjaAPI(
|
|
||||||
title="Opus Magnum Submission API",
|
|
||||||
version="1.0.0",
|
|
||||||
description="API for managing Opus Magnum puzzle submissions",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add authentication for protected endpoints
|
|
||||||
# api.auth = django_auth # Uncomment if you want global auth
|
|
||||||
|
|
||||||
# Include the submissions router
|
|
||||||
api.add_router("/submissions/", submissions_router, tags=["submissions"])
|
|
||||||
|
|
||||||
|
|
||||||
# Health check endpoint
|
|
||||||
@api.get("/health")
|
|
||||||
def health_check(request):
|
|
||||||
"""Health check endpoint"""
|
|
||||||
return {"status": "healthy", "service": "opus-magnum-api"}
|
|
||||||
|
|
||||||
|
|
||||||
# API info endpoint
|
|
||||||
@api.get("/info")
|
|
||||||
def api_info(request):
|
|
||||||
"""Get API information"""
|
|
||||||
return {
|
|
||||||
"name": "Opus Magnum Submission API",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "API for managing puzzle submissions with OCR validation",
|
|
||||||
"features": [
|
|
||||||
"Multi-puzzle submissions",
|
|
||||||
"File upload to S3",
|
|
||||||
"OCR validation tracking",
|
|
||||||
"Manual validation workflow",
|
|
||||||
"Admin validation tools",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# User info endpoint
|
|
||||||
@api.get("/user", response=UserInfoOut)
|
|
||||||
def get_user_info(request):
|
|
||||||
"""Get current user information"""
|
|
||||||
user = request.user
|
|
||||||
|
|
||||||
if user.is_authenticated:
|
|
||||||
return {
|
|
||||||
"id": user.id,
|
|
||||||
"username": user.username,
|
|
||||||
"first_name": user.first_name,
|
|
||||||
"last_name": user.last_name,
|
|
||||||
"email": user.email,
|
|
||||||
"is_authenticated": True,
|
|
||||||
"is_staff": user.is_staff,
|
|
||||||
"is_superuser": user.is_superuser,
|
|
||||||
"cas_groups": getattr(user, "cas_groups", []),
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return {
|
|
||||||
"is_authenticated": False,
|
|
||||||
"is_staff": False,
|
|
||||||
"is_superuser": False,
|
|
||||||
}
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, computed, defineProps } from 'vue'
|
|
||||||
import PuzzleCard from '@/components/PuzzleCard.vue'
|
|
||||||
import SubmissionForm from '@/components/SubmissionForm.vue'
|
|
||||||
import AdminPanel from '@/components/AdminPanel.vue'
|
|
||||||
import { apiService, errorHelpers } from '@/services/apiService'
|
|
||||||
import { usePuzzlesStore } from '@/stores/puzzles'
|
|
||||||
import { useSubmissionsStore } from '@/stores/submissions'
|
|
||||||
import type { SteamCollection, PuzzleResponse, UserInfo } from '@/types'
|
|
||||||
|
|
||||||
const props = defineProps<{collectionTitle: string, collectionUrl: string, collectionDescription: string}>()
|
|
||||||
|
|
||||||
// Pinia stores
|
|
||||||
const puzzlesStore = usePuzzlesStore()
|
|
||||||
const submissionsStore = useSubmissionsStore()
|
|
||||||
|
|
||||||
// Local state
|
|
||||||
const userInfo = ref<UserInfo | null>(null)
|
|
||||||
const isLoading = ref(true)
|
|
||||||
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[]> = {}
|
|
||||||
submissionsStore.submissions.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 using store
|
|
||||||
console.log('Loading puzzles...')
|
|
||||||
await puzzlesStore.loadPuzzles()
|
|
||||||
console.log('Puzzles loaded:', puzzlesStore.puzzles.length)
|
|
||||||
|
|
||||||
// Load existing submissions using store
|
|
||||||
console.log('Loading submissions...')
|
|
||||||
await submissionsStore.loadSubmissions()
|
|
||||||
console.log('Submissions loaded:', submissionsStore.submissions.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,
|
|
||||||
manualValidationRequested?: boolean
|
|
||||||
}) => {
|
|
||||||
try {
|
|
||||||
isLoading.value = true
|
|
||||||
error.value = ''
|
|
||||||
|
|
||||||
// Create submission via store
|
|
||||||
const submission = await submissionsStore.createSubmission(
|
|
||||||
submissionData.files,
|
|
||||||
submissionData.notes,
|
|
||||||
submissionData.manualValidationRequested
|
|
||||||
)
|
|
||||||
|
|
||||||
// Show success message
|
|
||||||
if (submission) {
|
|
||||||
const puzzleNames = submission.responses.map(r => r.puzzle_name).join(', ')
|
|
||||||
alert(`Solutions submitted successfully for puzzles: ${puzzleNames}`)
|
|
||||||
} else {
|
|
||||||
alert('Submission created successfully!')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal
|
|
||||||
submissionsStore.closeSubmissionModal()
|
|
||||||
|
|
||||||
} 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 = () => {
|
|
||||||
submissionsStore.openSubmissionModal()
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeSubmissionModal = () => {
|
|
||||||
submissionsStore.closeSubmissionModal()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to match puzzle name from OCR to actual puzzle
|
|
||||||
const findPuzzleByName = (ocrPuzzleName: string) => {
|
|
||||||
return puzzlesStore.findPuzzleByName(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 items-start justify-between">
|
|
||||||
<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 class="flex flex-col items-end gap-2">
|
|
||||||
<a href="/admin" class="btn btn-xs btn-warning">
|
|
||||||
Admin django
|
|
||||||
</a>
|
|
||||||
</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 class="mb-8">
|
|
||||||
<div class="card bg-base-100 shadow-lg">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title text-2xl">{{ props.collectionTitle }}</h2>
|
|
||||||
<p class="text-base-content/70">{{ props.collectionDescription }}</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 puzzlesStore.puzzles"
|
|
||||||
:key="puzzle.id"
|
|
||||||
:puzzle="puzzle"
|
|
||||||
:responses="responsesByPuzzle[puzzle.id] || []"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div v-if="puzzlesStore.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="submissionsStore.isSubmissionModalOpen" 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="puzzlesStore.puzzles"
|
|
||||||
:find-puzzle-by-name="findPuzzleByName"
|
|
||||||
@submit="handleSubmission"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="modal-backdrop" @click="closeSubmissionModal"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,356 +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_title }}</div>
|
|
||||||
<div class="text-sm opacity-50">ID: {{ response.id }}</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="text-sm space-y-1">
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<span>Cost: {{ response.cost || '-' }}</span>
|
|
||||||
<span
|
|
||||||
v-if="response.ocr_confidence_cost"
|
|
||||||
class="badge badge-xs"
|
|
||||||
:class="getConfidenceBadgeClass(response.ocr_confidence_cost)"
|
|
||||||
>
|
|
||||||
{{ Math.round(response.ocr_confidence_cost * 100) }}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<span>Cycles: {{ response.cycles || '-' }}</span>
|
|
||||||
<span
|
|
||||||
v-if="response.ocr_confidence_cycles"
|
|
||||||
class="badge badge-xs"
|
|
||||||
:class="getConfidenceBadgeClass(response.ocr_confidence_cycles)"
|
|
||||||
>
|
|
||||||
{{ Math.round(response.ocr_confidence_cycles * 100) }}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<span>Area: {{ response.area || '-' }}</span>
|
|
||||||
<span
|
|
||||||
v-if="response.ocr_confidence_area"
|
|
||||||
class="badge badge-xs"
|
|
||||||
:class="getConfidenceBadgeClass(response.ocr_confidence_area)"
|
|
||||||
>
|
|
||||||
{{ Math.round(response.ocr_confidence_area * 100) }}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="badge badge-warning badge-sm">
|
|
||||||
{{ getOverallConfidence(response) }}%
|
|
||||||
</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 w-11/12 max-w-5xl">
|
|
||||||
<h3 class="font-bold text-lg mb-4">Validate Response</h3>
|
|
||||||
|
|
||||||
<div v-for="file in validationModal.response.files">
|
|
||||||
<img :src="file.file_url">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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_title }}</div>
|
|
||||||
<div class="text-sm">Review and correct the OCR data below</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-4 gap-4">
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Puzzle</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
v-model="validationModal.data.puzzle"
|
|
||||||
class="select select-bordered select-sm w-full"
|
|
||||||
>
|
|
||||||
<option value="">Select puzzle...</option>
|
|
||||||
<option
|
|
||||||
v-for="puzzle in puzzlesStore.puzzles"
|
|
||||||
:key="puzzle.id"
|
|
||||||
:value="puzzle.id"
|
|
||||||
>
|
|
||||||
{{ puzzle.title }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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'
|
|
||||||
import {usePuzzlesStore} from '@/stores/puzzles'
|
|
||||||
const puzzlesStore = usePuzzlesStore()
|
|
||||||
|
|
||||||
// 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: {
|
|
||||||
puzzle_title: '',
|
|
||||||
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 = {
|
|
||||||
puzzle: response.puzzle || '',
|
|
||||||
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 = {
|
|
||||||
puzzle: '',
|
|
||||||
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()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Helper functions for confidence display
|
|
||||||
const getConfidenceBadgeClass = (confidence: number): string => {
|
|
||||||
if (confidence >= 0.8) return 'badge-success'
|
|
||||||
if (confidence >= 0.6) return 'badge-warning'
|
|
||||||
return 'badge-error'
|
|
||||||
}
|
|
||||||
|
|
||||||
const getOverallConfidence = (response: PuzzleResponse): number => {
|
|
||||||
const confidences = [
|
|
||||||
response.ocr_confidence_cost,
|
|
||||||
response.ocr_confidence_cycles,
|
|
||||||
response.ocr_confidence_area
|
|
||||||
].filter(conf => conf !== undefined && conf !== null) as number[]
|
|
||||||
|
|
||||||
if (confidences.length === 0) return 0
|
|
||||||
|
|
||||||
const average = confidences.reduce((sum, conf) => sum + conf, 0) / confidences.length
|
|
||||||
return Math.round(average * 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expose refresh method
|
|
||||||
defineExpose({
|
|
||||||
refresh: loadData
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,195 +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 ml-2">{{ data.files.length }} file(s)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- File Upload -->
|
|
||||||
<FileUpload v-model="submissionFiles" :puzzles="puzzles" />
|
|
||||||
|
|
||||||
<!-- Manual Selection Warning -->
|
|
||||||
<div v-if="filesNeedingManualSelection.length > 0" class="alert alert-warning">
|
|
||||||
<i class="mdi mdi-alert-circle text-xl"></i>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="font-bold">Manual Puzzle Selection Required</div>
|
|
||||||
<div class="text-sm">
|
|
||||||
{{ filesNeedingManualSelection.length }} file(s) have low OCR confidence for puzzle names.
|
|
||||||
Please select the correct puzzle for each file before submitting.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Notes -->
|
|
||||||
<div class="form-control">
|
|
||||||
<div class="flex-1">
|
|
||||||
<label class="flex label">
|
|
||||||
<span class="label-text font-medium">Notes (Optional)</span>
|
|
||||||
<span class="label-text-alt">{{ notesLength }}/500</span>
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
v-model="notes"
|
|
||||||
class="flex textarea textarea-bordered h-24 w-full resize-none"
|
|
||||||
placeholder="Add any notes about your solution, approach, or interesting findings..."
|
|
||||||
maxlength="500"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Manual Validation Request -->
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label cursor-pointer justify-start gap-3">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
v-model="manualValidationRequested"
|
|
||||||
class="checkbox checkbox-primary"
|
|
||||||
/>
|
|
||||||
<div class="flex-1">
|
|
||||||
<span class="label-text font-medium">Request manual validation</span>
|
|
||||||
<div class="label-text-alt text-xs opacity-70 mt-1">
|
|
||||||
Check this if you want an admin to manually review your submission, even if OCR confidence is high.
|
|
||||||
<br>
|
|
||||||
<em>Note: This will be automatically checked if any OCR confidence is below 50%.</em>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Submit Button -->
|
|
||||||
<div class="card-actions justify-end">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn btn-primary"
|
|
||||||
:disabled="!canSubmit"
|
|
||||||
>
|
|
||||||
<span v-if="isSubmitting" class="loading loading-spinner loading-sm"></span>
|
|
||||||
<span v-if="isSubmitting">Submitting...</span>
|
|
||||||
<span v-else-if="filesNeedingManualSelection.length > 0">
|
|
||||||
Select Puzzles ({{ filesNeedingManualSelection.length }} remaining)
|
|
||||||
</span>
|
|
||||||
<span v-else>Submit Solution</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, watch } from 'vue'
|
|
||||||
import FileUpload from '@/components/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, manualValidationRequested?: boolean }]
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
const emit = defineEmits<Emits>()
|
|
||||||
|
|
||||||
const submissionFiles = ref<SubmissionFile[]>([])
|
|
||||||
const notes = ref('')
|
|
||||||
const manualValidationRequested = ref(false)
|
|
||||||
const isSubmitting = ref(false)
|
|
||||||
|
|
||||||
const notesLength = computed(() => notes.value.length)
|
|
||||||
|
|
||||||
const canSubmit = computed(() => {
|
|
||||||
const hasFiles = submissionFiles.value.length > 0
|
|
||||||
const noManualSelectionNeeded = !submissionFiles.value.some(file => file.needsManualPuzzleSelection)
|
|
||||||
|
|
||||||
return hasFiles &&
|
|
||||||
!isSubmitting.value &&
|
|
||||||
noManualSelectionNeeded
|
|
||||||
})
|
|
||||||
|
|
||||||
// Group files by detected puzzle
|
|
||||||
const responsesByPuzzle = computed(() => {
|
|
||||||
const grouped: Record<string, { puzzle: SteamCollectionItem | null, files: SubmissionFile[] }> = {}
|
|
||||||
|
|
||||||
submissionFiles.value.forEach(file => {
|
|
||||||
// Use manual puzzle selection if available, otherwise fall back to OCR
|
|
||||||
const puzzleName = file.manualPuzzleSelection || file.ocrData?.puzzle
|
|
||||||
|
|
||||||
if (puzzleName) {
|
|
||||||
if (!grouped[puzzleName]) {
|
|
||||||
grouped[puzzleName] = {
|
|
||||||
puzzle: props.findPuzzleByName(puzzleName),
|
|
||||||
files: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
grouped[puzzleName].files.push(file)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return grouped
|
|
||||||
})
|
|
||||||
|
|
||||||
// Count files that need manual puzzle selection
|
|
||||||
const filesNeedingManualSelection = computed(() => {
|
|
||||||
return submissionFiles.value.filter(file => file.needsManualPuzzleSelection)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Check if any OCR confidence is below 50%
|
|
||||||
const hasLowConfidence = computed(() => {
|
|
||||||
return submissionFiles.value.some(file => {
|
|
||||||
if (!file.ocrData?.confidence) return false
|
|
||||||
return file.ocrData.confidence.cost < 0.5 ||
|
|
||||||
file.ocrData.confidence.cycles < 0.5 ||
|
|
||||||
file.ocrData.confidence.area < 0.5
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Auto-check manual validation when confidence is low
|
|
||||||
watch(hasLowConfidence, (newValue) => {
|
|
||||||
if (newValue && !manualValidationRequested.value) {
|
|
||||||
manualValidationRequested.value = true
|
|
||||||
}
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
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,
|
|
||||||
manualValidationRequested: manualValidationRequested.value
|
|
||||||
})
|
|
||||||
|
|
||||||
// Reset form
|
|
||||||
submissionFiles.value = []
|
|
||||||
notes.value = ''
|
|
||||||
manualValidationRequested.value = false
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Submission error:', error)
|
|
||||||
} finally {
|
|
||||||
isSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,100 +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
|
|
||||||
confidence: {
|
|
||||||
puzzle: number
|
|
||||||
cost: number
|
|
||||||
cycles: number
|
|
||||||
area: number
|
|
||||||
overall: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubmissionFile {
|
|
||||||
file: File
|
|
||||||
preview: string
|
|
||||||
type: 'image' | 'gif'
|
|
||||||
ocrData?: OpusMagnumData
|
|
||||||
ocrProcessing?: boolean
|
|
||||||
ocrError?: string
|
|
||||||
original_filename?: string
|
|
||||||
manualPuzzleSelection?: string
|
|
||||||
needsManualPuzzleSelection?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PuzzleResponse {
|
|
||||||
id?: number
|
|
||||||
puzzle: number | SteamCollectionItem
|
|
||||||
puzzle_name: string
|
|
||||||
cost?: string
|
|
||||||
cycles?: string
|
|
||||||
area?: string
|
|
||||||
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
|
|
||||||
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
|
|
||||||
manual_validation_requested?: boolean
|
|
||||||
total_responses?: number
|
|
||||||
needs_validation?: boolean
|
|
||||||
created_at?: string
|
|
||||||
updated_at?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserInfo {
|
|
||||||
id?: number
|
|
||||||
username?: string
|
|
||||||
first_name?: string
|
|
||||||
last_name?: string
|
|
||||||
email?: string
|
|
||||||
is_authenticated: boolean
|
|
||||||
is_staff: boolean
|
|
||||||
is_superuser: boolean
|
|
||||||
cas_groups?: string[]
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot": {
|
|
||||||
"file": "assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
|
||||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot"
|
|
||||||
},
|
|
||||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf": {
|
|
||||||
"file": "assets/materialdesignicons-webfont-B7mPwVP_.ttf",
|
|
||||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf"
|
|
||||||
},
|
|
||||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff": {
|
|
||||||
"file": "assets/materialdesignicons-webfont-PXm3-2wK.woff",
|
|
||||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff"
|
|
||||||
},
|
|
||||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2": {
|
|
||||||
"file": "assets/materialdesignicons-webfont-Dp5v-WZN.woff2",
|
|
||||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2"
|
|
||||||
},
|
|
||||||
"src/main.ts": {
|
|
||||||
"file": "assets/main-CNlI4PW6.js",
|
|
||||||
"name": "main",
|
|
||||||
"src": "src/main.ts",
|
|
||||||
"isEntry": true,
|
|
||||||
"css": [
|
|
||||||
"assets/main-HDjkw-xK.css"
|
|
||||||
],
|
|
||||||
"assets": [
|
|
||||||
"assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
|
||||||
"assets/materialdesignicons-webfont-Dp5v-WZN.woff2",
|
|
||||||
"assets/materialdesignicons-webfont-PXm3-2wK.woff",
|
|
||||||
"assets/materialdesignicons-webfont-B7mPwVP_.ttf"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot": {
|
|
||||||
"file": "assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
|
||||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot"
|
|
||||||
},
|
|
||||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf": {
|
|
||||||
"file": "assets/materialdesignicons-webfont-B7mPwVP_.ttf",
|
|
||||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf"
|
|
||||||
},
|
|
||||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff": {
|
|
||||||
"file": "assets/materialdesignicons-webfont-PXm3-2wK.woff",
|
|
||||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff"
|
|
||||||
},
|
|
||||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2": {
|
|
||||||
"file": "assets/materialdesignicons-webfont-Dp5v-WZN.woff2",
|
|
||||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2"
|
|
||||||
},
|
|
||||||
"src/main.ts": {
|
|
||||||
"file": "assets/main-B14l8Jy0.js",
|
|
||||||
"name": "main",
|
|
||||||
"src": "src/main.ts",
|
|
||||||
"isEntry": true,
|
|
||||||
"css": [
|
|
||||||
"assets/main-COx9N9qO.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 |
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
{"root":["./src/main.ts","./src/services/apiService.ts","./src/services/ocrService.ts","./src/stores/index.ts","./src/stores/puzzles.ts","./src/stores/submissions.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"}
|
|
||||||
@@ -34,3 +34,6 @@ class CustomUserAdmin(UserAdmin):
|
|||||||
return obj.get_cas_groups_display()
|
return obj.get_cas_groups_display()
|
||||||
|
|
||||||
get_cas_groups_display.short_description = "CAS Groups"
|
get_cas_groups_display.short_description = "CAS Groups"
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -14,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})"
|
||||||
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from animations.models import PuzzlePointsFactor, PuzzlePointsValue
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PuzzlePointsFactor)
|
||||||
|
class PuzzlePointsFactorAdmin(admin.ModelAdmin):
|
||||||
|
list_display = [
|
||||||
|
"id",
|
||||||
|
"cost",
|
||||||
|
"cycles",
|
||||||
|
"area",
|
||||||
|
"special_notes",
|
||||||
|
]
|
||||||
|
list_filter = ["cost", "cycles", "area", "special_notes"]
|
||||||
|
search_fields = ["cost", "cycles", "area", "special_notes"]
|
||||||
|
readonly_fields = ["created_at", "updated_at"]
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(
|
||||||
|
"Basic Information",
|
||||||
|
{"fields": ("cost", "cycles", "area")},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Special notes",
|
||||||
|
{
|
||||||
|
"fields": ("special_notes",),
|
||||||
|
"description": "Special notes about the puzzle. May be some extra restriction, etc...",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Metadata",
|
||||||
|
{
|
||||||
|
"fields": ("created_at", "updated_at"),
|
||||||
|
"classes": ("collapse",),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PuzzlePointsValue)
|
||||||
|
class PuzzlePointsValueAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["id", "points"]
|
||||||
|
list_filter = ["points"]
|
||||||
|
search_fields = ["points"]
|
||||||
|
readonly_fields = ["created_at", "updated_at"]
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(
|
||||||
|
"Basic Information",
|
||||||
|
{"fields": ("points",)},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Metadata",
|
||||||
|
{
|
||||||
|
"fields": ("created_at", "updated_at"),
|
||||||
|
"classes": ("collapse",),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
from collections import defaultdict
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from ninja import Router
|
||||||
|
from ninja.errors import HttpError
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
|
from accounts.models import CustomUser
|
||||||
|
from animations.schemas import (
|
||||||
|
RankingSchema,
|
||||||
|
TournamentSubmissionsOut,
|
||||||
|
TournamentPuzzleResultsOut,
|
||||||
|
PuzzleSubmissionsOut,
|
||||||
|
PuzzleResultsOut,
|
||||||
|
PuzzleSubmissionWithRankOut,
|
||||||
|
PuzzlePointsFactorOut,
|
||||||
|
WinnerResponseOut,
|
||||||
|
WinnerFileOut,
|
||||||
|
)
|
||||||
|
from opus_magnum.models import PuzzleResponse, SteamCollectionItem, SteamCollection
|
||||||
|
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("results", response=RankingSchema)
|
||||||
|
def results(request: HttpRequest) -> dict:
|
||||||
|
cache_key = "api:results:results"
|
||||||
|
cached_data = cache.get(cache_key)
|
||||||
|
|
||||||
|
if cached_data is not None:
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
responses_by_userid = defaultdict(list)
|
||||||
|
responses_by_puzzleid = defaultdict(list)
|
||||||
|
|
||||||
|
for response in list(
|
||||||
|
PuzzleResponse.objects.filter(needs_manual_validation=False)
|
||||||
|
.filter_user_best_response()
|
||||||
|
.prefetch_related("submission__user")
|
||||||
|
):
|
||||||
|
responses_by_userid[response.submission.user.id].append(response)
|
||||||
|
responses_by_puzzleid[response.puzzle.id].append(response)
|
||||||
|
|
||||||
|
ranking = {}
|
||||||
|
|
||||||
|
for puzzle_id, responses in responses_by_puzzleid.items():
|
||||||
|
ranking[puzzle_id] = sorted(
|
||||||
|
responses, key=lambda x: (x.rank_points is None, x.rank_points or 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"users": list(CustomUser.objects.filter(pk__in=responses_by_userid.keys())),
|
||||||
|
"puzzles": list(SteamCollectionItem.objects.all()),
|
||||||
|
"responses_by_userid": dict(responses_by_userid),
|
||||||
|
"ranking_by_puzzle": ranking,
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set("api:results:results", data, 300)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("top-submissions", response=TournamentSubmissionsOut)
|
||||||
|
def top_submissions(request: HttpRequest, limit: int = 5) -> dict:
|
||||||
|
"""Get tournament top submissions for each puzzle. Only available when tournament is closed."""
|
||||||
|
cache_key = f"api:results:top_submissions:{limit}"
|
||||||
|
cached_data = cache.get(cache_key)
|
||||||
|
|
||||||
|
if cached_data is not None:
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
collection = get_object_or_404(SteamCollection, is_active=True)
|
||||||
|
|
||||||
|
# Only allow access when tournament is closed
|
||||||
|
if collection.accepting_submissions:
|
||||||
|
raise HttpError(403, "Tournament is still accepting submissions")
|
||||||
|
|
||||||
|
# Get all puzzles
|
||||||
|
puzzles = SteamCollectionItem.objects.filter(collection=collection).order_by(
|
||||||
|
"order_index"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
submissions_list = []
|
||||||
|
for puzzle in puzzles:
|
||||||
|
# Get the top N responses for this puzzle (ranked by points, highest first)
|
||||||
|
top_responses = (
|
||||||
|
PuzzleResponse.objects.filter(puzzle=puzzle, needs_manual_validation=False)
|
||||||
|
.filter_user_best_response()
|
||||||
|
.annotate_rank_points()
|
||||||
|
.order_by("-rank_points")[:limit]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build submission list for this puzzle
|
||||||
|
puzzle_submissions = []
|
||||||
|
for response in top_responses:
|
||||||
|
# Get submission files
|
||||||
|
files = response.files.all()
|
||||||
|
response_files = [
|
||||||
|
WinnerFileOut(
|
||||||
|
file_url=file.file_url or "",
|
||||||
|
original_filename=file.original_filename,
|
||||||
|
)
|
||||||
|
for file in files
|
||||||
|
]
|
||||||
|
|
||||||
|
# Calculate total coefficient
|
||||||
|
total_coef = None
|
||||||
|
if (
|
||||||
|
puzzle.points_factor
|
||||||
|
and response.final_cost is not None
|
||||||
|
and response.final_cycles is not None
|
||||||
|
and response.final_area is not None
|
||||||
|
):
|
||||||
|
total_coef = (
|
||||||
|
puzzle.points_factor.cost * response.final_cost
|
||||||
|
+ puzzle.points_factor.cycles * response.final_cycles
|
||||||
|
+ puzzle.points_factor.area * response.final_area
|
||||||
|
)
|
||||||
|
|
||||||
|
submission_data = WinnerResponseOut(
|
||||||
|
user_id=response.submission.user.id if response.submission.user else 0,
|
||||||
|
username=response.submission.user.username
|
||||||
|
if response.submission.user
|
||||||
|
else "Anonymous",
|
||||||
|
final_cost=response.final_cost,
|
||||||
|
final_cycles=response.final_cycles,
|
||||||
|
final_area=response.final_area,
|
||||||
|
rank_points=response.rank_points,
|
||||||
|
total_coef=total_coef,
|
||||||
|
files=response_files,
|
||||||
|
)
|
||||||
|
puzzle_submissions.append(submission_data)
|
||||||
|
|
||||||
|
submissions_list.append(
|
||||||
|
PuzzleSubmissionsOut(
|
||||||
|
puzzle_id=puzzle.id,
|
||||||
|
puzzle_title=puzzle.title,
|
||||||
|
submissions=puzzle_submissions,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
data = {"submissions": submissions_list}
|
||||||
|
cache.set(f"api:results:top_submissions:{limit}", data, 300)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("puzzle-results", response=TournamentPuzzleResultsOut)
|
||||||
|
def puzzle_results(request: HttpRequest, limit: int = 5) -> dict:
|
||||||
|
"""Get tournament results organized by puzzle with coefficients. Only available when tournament is closed."""
|
||||||
|
cache_key = f"api:results:puzzle_results:{limit}"
|
||||||
|
cached_data = cache.get(cache_key)
|
||||||
|
|
||||||
|
if cached_data is not None:
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
collection = get_object_or_404(SteamCollection, is_active=True)
|
||||||
|
|
||||||
|
# Only allow access when tournament is closed
|
||||||
|
if collection.accepting_submissions:
|
||||||
|
raise HttpError(403, "Tournament is still accepting submissions")
|
||||||
|
|
||||||
|
# Get all puzzles
|
||||||
|
puzzles = SteamCollectionItem.objects.filter(collection=collection).order_by(
|
||||||
|
"order_index"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
results_list = []
|
||||||
|
for puzzle in puzzles:
|
||||||
|
# Get the top N responses for this puzzle (ranked by points)
|
||||||
|
top_responses = (
|
||||||
|
PuzzleResponse.objects.filter(puzzle=puzzle, needs_manual_validation=False)
|
||||||
|
.filter_user_best_response()
|
||||||
|
.annotate_rank_points()
|
||||||
|
.order_by("-rank_points")[:limit]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build submission list for this puzzle with rank
|
||||||
|
puzzle_submissions = []
|
||||||
|
for rank, response in enumerate(top_responses, 1):
|
||||||
|
# Get submission files
|
||||||
|
files = response.files.all()
|
||||||
|
response_files = [
|
||||||
|
WinnerFileOut(
|
||||||
|
file_url=file.file_url or "",
|
||||||
|
original_filename=file.original_filename,
|
||||||
|
)
|
||||||
|
for file in files
|
||||||
|
]
|
||||||
|
|
||||||
|
# Calculate total coefficient
|
||||||
|
total_coef = None
|
||||||
|
if (
|
||||||
|
puzzle.points_factor
|
||||||
|
and response.final_cost is not None
|
||||||
|
and response.final_cycles is not None
|
||||||
|
and response.final_area is not None
|
||||||
|
):
|
||||||
|
total_coef = (
|
||||||
|
puzzle.points_factor.cost * response.final_cost
|
||||||
|
+ puzzle.points_factor.cycles * response.final_cycles
|
||||||
|
+ puzzle.points_factor.area * response.final_area
|
||||||
|
)
|
||||||
|
|
||||||
|
submission_data = PuzzleSubmissionWithRankOut(
|
||||||
|
rank=rank,
|
||||||
|
user_id=response.submission.user.id if response.submission.user else 0,
|
||||||
|
username=response.submission.user.username
|
||||||
|
if response.submission.user
|
||||||
|
else "Anonymous",
|
||||||
|
final_cost=response.final_cost,
|
||||||
|
final_cycles=response.final_cycles,
|
||||||
|
final_area=response.final_area,
|
||||||
|
rank_points=response.rank_points,
|
||||||
|
total_coef=total_coef,
|
||||||
|
files=response_files,
|
||||||
|
)
|
||||||
|
puzzle_submissions.append(submission_data)
|
||||||
|
|
||||||
|
# Get points factor if available
|
||||||
|
points_factor = None
|
||||||
|
if puzzle.points_factor:
|
||||||
|
points_factor = PuzzlePointsFactorOut(
|
||||||
|
cost=puzzle.points_factor.cost,
|
||||||
|
cycles=puzzle.points_factor.cycles,
|
||||||
|
area=puzzle.points_factor.area,
|
||||||
|
)
|
||||||
|
|
||||||
|
results_list.append(
|
||||||
|
PuzzleResultsOut(
|
||||||
|
puzzle_id=puzzle.id,
|
||||||
|
puzzle_title=puzzle.title,
|
||||||
|
points_factor=points_factor,
|
||||||
|
submissions=puzzle_submissions,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
data = {"results": results_list}
|
||||||
|
cache.set(f"api:results:puzzle_results:{limit}", data, 300)
|
||||||
|
return data
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class SubmissionsConfig(AppConfig):
|
class AnimationsConfig(AppConfig):
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
name = "submissions"
|
name = "animations"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-23 22:33
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="PuzzlePointsFactor",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("cost", models.IntegerField()),
|
||||||
|
("cycles", models.IntegerField()),
|
||||||
|
("area", models.IntegerField()),
|
||||||
|
("special_notes", models.TextField(blank=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-24 01:00
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("animations", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="PuzzlePointsValue",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("points", models.JSONField(default=[])),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-05 13:38
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("animations", "0002_puzzlepointsvalue"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="puzzlepointsvalue",
|
||||||
|
name="points",
|
||||||
|
field=models.JSONField(default=list),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzlePointsFactor(models.Model):
|
||||||
|
# Timestamps
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
cost = models.IntegerField()
|
||||||
|
cycles = models.IntegerField()
|
||||||
|
area = models.IntegerField()
|
||||||
|
|
||||||
|
special_notes = models.TextField(blank=True)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.cost} - {self.cycles} - {self.area}"
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzlePointsValue(models.Model):
|
||||||
|
# Timestamps
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
points = models.JSONField(default=list)
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
from ninja import ModelSchema, Schema
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from opus_magnum.models import PuzzleResponse
|
||||||
|
from opus_magnum.schemas import SteamCollectionItemOut
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzleResponseRankingOut(ModelSchema):
|
||||||
|
class Meta:
|
||||||
|
model = PuzzleResponse
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"puzzle_name",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
|
||||||
|
points: int | None = None
|
||||||
|
rank_points: int | None = None
|
||||||
|
puzzle_user_rank: int
|
||||||
|
user_response_rank: int
|
||||||
|
|
||||||
|
user_id: int
|
||||||
|
|
||||||
|
final_cost: int | None
|
||||||
|
final_cycles: int | None
|
||||||
|
final_area: int | None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_user_id(obj) -> int:
|
||||||
|
return obj.submission.user.id
|
||||||
|
|
||||||
|
|
||||||
|
class UserDisplayOut(Schema):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
is_staff: bool
|
||||||
|
|
||||||
|
|
||||||
|
class RankingSchema(Schema):
|
||||||
|
users: list[UserDisplayOut]
|
||||||
|
puzzles: list[SteamCollectionItemOut]
|
||||||
|
responses_by_userid: dict[int, list[PuzzleResponseRankingOut]]
|
||||||
|
ranking_by_puzzle: dict[int, list[PuzzleResponseRankingOut]]
|
||||||
|
|
||||||
|
|
||||||
|
class WinnerFileOut(Schema):
|
||||||
|
"""Schema for winner submission file"""
|
||||||
|
|
||||||
|
file_url: str
|
||||||
|
original_filename: str
|
||||||
|
|
||||||
|
|
||||||
|
class WinnerResponseOut(Schema):
|
||||||
|
"""Schema for winner response with files"""
|
||||||
|
|
||||||
|
user_id: int
|
||||||
|
username: str
|
||||||
|
final_cost: Optional[int]
|
||||||
|
final_cycles: Optional[int]
|
||||||
|
final_area: Optional[int]
|
||||||
|
rank_points: Optional[int]
|
||||||
|
total_coef: Optional[int]
|
||||||
|
files: List[WinnerFileOut]
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzleSubmissionsOut(Schema):
|
||||||
|
"""Schema for puzzle with all top submissions"""
|
||||||
|
|
||||||
|
puzzle_id: int
|
||||||
|
puzzle_title: str
|
||||||
|
submissions: List[WinnerResponseOut]
|
||||||
|
|
||||||
|
|
||||||
|
class TournamentSubmissionsOut(Schema):
|
||||||
|
"""Schema for tournament top submissions results"""
|
||||||
|
|
||||||
|
submissions: List[PuzzleSubmissionsOut]
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzlePointsFactorOut(Schema):
|
||||||
|
"""Schema for puzzle points factor"""
|
||||||
|
|
||||||
|
cost: int
|
||||||
|
cycles: int
|
||||||
|
area: int
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzleSubmissionWithRankOut(Schema):
|
||||||
|
"""Schema for puzzle submission with rank"""
|
||||||
|
|
||||||
|
rank: int
|
||||||
|
user_id: int
|
||||||
|
username: str
|
||||||
|
final_cost: Optional[int]
|
||||||
|
final_cycles: Optional[int]
|
||||||
|
final_area: Optional[int]
|
||||||
|
rank_points: Optional[int]
|
||||||
|
total_coef: Optional[int]
|
||||||
|
files: List[WinnerFileOut]
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzleResultsOut(Schema):
|
||||||
|
"""Schema for puzzle-specific results with coefficients"""
|
||||||
|
|
||||||
|
puzzle_id: int
|
||||||
|
puzzle_title: str
|
||||||
|
points_factor: Optional[PuzzlePointsFactorOut]
|
||||||
|
submissions: List[PuzzleSubmissionWithRankOut]
|
||||||
|
|
||||||
|
|
||||||
|
class TournamentPuzzleResultsOut(Schema):
|
||||||
|
"""Schema for tournament puzzle-specific results"""
|
||||||
|
|
||||||
|
results: List[PuzzleResultsOut]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import Game
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Game)
|
||||||
|
class GameAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["name", "steam_app_id", "enabled", "updated_at"]
|
||||||
|
list_filter = ["enabled"]
|
||||||
|
search_fields = ["name", "steam_app_id"]
|
||||||
|
readonly_fields = ["created_at", "updated_at"]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
|
from ninja import Router
|
||||||
|
|
||||||
|
from .models import Game
|
||||||
|
from .schemas import GameOut
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response=List[GameOut])
|
||||||
|
def list_games(request):
|
||||||
|
return Game.objects.filter(enabled=True)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class GamesConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "games"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
|
||||||
|
from .models import Game
|
||||||
|
|
||||||
|
|
||||||
|
def require_game_enabled(steam_app_id: int):
|
||||||
|
def decorator(view_func):
|
||||||
|
@wraps(view_func)
|
||||||
|
def wrapper(request, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
game = Game.objects.get(steam_app_id=steam_app_id)
|
||||||
|
except Game.DoesNotExist:
|
||||||
|
raise PermissionDenied
|
||||||
|
if not game.enabled:
|
||||||
|
raise PermissionDenied
|
||||||
|
return view_func(request, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Game",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("steam_app_id", models.PositiveIntegerField(unique=True)),
|
||||||
|
("name", models.CharField(max_length=255)),
|
||||||
|
("enabled", models.BooleanField(default=True)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["name"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
NOITA_APP_ID = 881100
|
||||||
|
OPUS_MAGNUM_APP_ID = 558990
|
||||||
|
|
||||||
|
|
||||||
|
def seed_games(apps, schema_editor):
|
||||||
|
Game = apps.get_model("games", "Game")
|
||||||
|
Game.objects.get_or_create(
|
||||||
|
steam_app_id=NOITA_APP_ID,
|
||||||
|
defaults={"name": "Noita", "enabled": True},
|
||||||
|
)
|
||||||
|
Game.objects.get_or_create(
|
||||||
|
steam_app_id=OPUS_MAGNUM_APP_ID,
|
||||||
|
defaults={"name": "Opus Magnum", "enabled": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def unseed_games(apps, schema_editor):
|
||||||
|
Game = apps.get_model("games", "Game")
|
||||||
|
Game.objects.filter(steam_app_id__in=[NOITA_APP_ID, OPUS_MAGNUM_APP_ID]).delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("games", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(seed_games, reverse_code=unseed_games),
|
||||||
|
]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
NOITA_APP_ID = 881100
|
||||||
|
OPUS_MAGNUM_APP_ID = 558990
|
||||||
|
|
||||||
|
PATHS = {
|
||||||
|
NOITA_APP_ID: "/noita",
|
||||||
|
OPUS_MAGNUM_APP_ID: "/opus-magnum",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def set_paths(apps, schema_editor):
|
||||||
|
Game = apps.get_model("games", "Game")
|
||||||
|
for app_id, path in PATHS.items():
|
||||||
|
Game.objects.filter(steam_app_id=app_id).update(path=path)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("games", "0002_seed_noita_and_opus_magnum"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="game",
|
||||||
|
name="path",
|
||||||
|
field=models.CharField(default="", max_length=100),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.RunPython(set_paths, reverse_code=migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Game(models.Model):
|
||||||
|
steam_app_id = models.PositiveIntegerField(unique=True)
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
path = models.CharField(max_length=100)
|
||||||
|
enabled = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.name} ({self.steam_app_id})"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from ninja import Schema
|
||||||
|
|
||||||
|
|
||||||
|
class GameOut(Schema):
|
||||||
|
steam_app_id: int
|
||||||
|
name: str
|
||||||
|
path: str
|
||||||
@@ -7,7 +7,7 @@ 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:
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from market.models import Market, MarketOption, UserBet, UserPointChange
|
||||||
|
|
||||||
|
|
||||||
|
class MarketOptionInline(admin.TabularInline):
|
||||||
|
model = MarketOption
|
||||||
|
extra = 1
|
||||||
|
fields = ["text"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Market)
|
||||||
|
class MarketAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["title", "status", "end_date", "created_by", "created_at"]
|
||||||
|
list_filter = ["status", "created_at"]
|
||||||
|
search_fields = ["uuid", "title"]
|
||||||
|
readonly_fields = [
|
||||||
|
"uuid",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"created_by",
|
||||||
|
"winning_option",
|
||||||
|
]
|
||||||
|
inlines = [MarketOptionInline]
|
||||||
|
fieldsets = (
|
||||||
|
("Info", {"fields": ["uuid", "title", "description"]}),
|
||||||
|
("Configuration", {"fields": ["end_date", "multiplier"]}),
|
||||||
|
("Status", {"fields": ["status", "winning_option"]}),
|
||||||
|
("Metadata", {"fields": ["created_by", "created_at", "updated_at"]}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def has_change_permission(self, request, obj=None):
|
||||||
|
# Prevent any changes to resolved markets
|
||||||
|
if obj and obj.status == Market.Status.RESOLVED:
|
||||||
|
return False
|
||||||
|
return super().has_change_permission(request, obj)
|
||||||
|
|
||||||
|
def save_model(self, request, obj, form, change):
|
||||||
|
if not change: # Creating new market
|
||||||
|
obj.created_by = request.user
|
||||||
|
super().save_model(request, obj, form, change)
|
||||||
|
|
||||||
|
@admin.action(description="Publish selected draft markets")
|
||||||
|
def publish_markets(self, request, queryset):
|
||||||
|
updated = queryset.filter(status=Market.Status.DRAFT).update(
|
||||||
|
status=Market.Status.OPEN
|
||||||
|
)
|
||||||
|
self.message_user(request, f"Published {updated} market(s).")
|
||||||
|
|
||||||
|
@admin.action(description="Close selected markets")
|
||||||
|
def close_markets(self, request, queryset):
|
||||||
|
updated = queryset.filter(status=Market.Status.OPEN).update(
|
||||||
|
status=Market.Status.CLOSED
|
||||||
|
)
|
||||||
|
self.message_user(request, f"Closed {updated} market(s).")
|
||||||
|
|
||||||
|
actions = ["publish_markets", "close_markets"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(MarketOption)
|
||||||
|
class MarketOptionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["text", "market"]
|
||||||
|
list_filter = ["market"]
|
||||||
|
search_fields = ["uuid", "text", "market__title"]
|
||||||
|
readonly_fields = ["uuid"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UserBet)
|
||||||
|
class UserBetAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["user", "option", "amount", "created_at"]
|
||||||
|
list_filter = ["user", "created_at", "option__market"]
|
||||||
|
search_fields = ["uuid", "user__username", "option__text"]
|
||||||
|
readonly_fields = ["uuid", "user", "option", "amount", "created_at", "updated_at"]
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UserPointChange)
|
||||||
|
class UserPointChangeAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["user", "market", "amount", "reason", "created_at"]
|
||||||
|
list_filter = ["user", "reason", "created_at", "market"]
|
||||||
|
search_fields = ["uuid", "user__username", "market__title"]
|
||||||
|
readonly_fields = ["uuid", "created_at", "updated_at"]
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
from typing import List
|
||||||
|
from ninja import Router
|
||||||
|
from ninja.errors import HttpError
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.db.models import Sum, Prefetch
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from market.models import Market, MarketOption, UserBet, UserPointChange
|
||||||
|
from market.schemas import (
|
||||||
|
MarketListSchema,
|
||||||
|
ResolveMarketSchema,
|
||||||
|
UserBetCreateSchema,
|
||||||
|
UserBetSchema,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = Router(tags=["market"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response=List[MarketListSchema])
|
||||||
|
def list_markets(request):
|
||||||
|
"""List all markets (excludes draft markets)."""
|
||||||
|
markets = Market.objects.exclude(status=Market.Status.DRAFT)
|
||||||
|
# Prefetch options with total_bets annotation sorted by total_bets desc, then text asc
|
||||||
|
options_queryset = MarketOption.objects.annotate(
|
||||||
|
total_bets=Coalesce(Sum("user_bets__amount"), 0)
|
||||||
|
).order_by("-total_bets", "text")
|
||||||
|
|
||||||
|
return markets.prefetch_related(Prefetch("options", queryset=options_queryset))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user/bets", response=List[UserBetSchema])
|
||||||
|
def list_user_bets(request):
|
||||||
|
"""List all bets placed by the current user."""
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
raise HttpError(401, "Authentication required")
|
||||||
|
|
||||||
|
return (
|
||||||
|
UserBet.objects.filter(user=request.user)
|
||||||
|
.select_related("option__market")
|
||||||
|
.prefetch_related("option__market__options")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{market_uuid}/actions/close")
|
||||||
|
def close_market(request, market_uuid: str):
|
||||||
|
"""Close a market. Admin only."""
|
||||||
|
if not request.user.is_staff:
|
||||||
|
raise HttpError(403, "Permission denied")
|
||||||
|
|
||||||
|
market = get_object_or_404(Market, uuid=market_uuid)
|
||||||
|
market.status = Market.Status.CLOSED
|
||||||
|
market.save(update_fields=["status", "updated_at"])
|
||||||
|
return {"status": Market.Status.CLOSED}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{market_uuid}/actions/resolve", response=MarketListSchema)
|
||||||
|
def resolve_market(request, market_uuid: str, payload: ResolveMarketSchema):
|
||||||
|
"""Resolve a market with a winning option. Admin only."""
|
||||||
|
if not request.user.is_staff:
|
||||||
|
raise HttpError(403, "Permission denied")
|
||||||
|
|
||||||
|
market = get_object_or_404(Market, uuid=market_uuid)
|
||||||
|
winning_option = get_object_or_404(MarketOption, uuid=payload.winning_option_uuid)
|
||||||
|
|
||||||
|
if winning_option.market_id != market.id:
|
||||||
|
raise HttpError(400, "Option does not belong to this market")
|
||||||
|
|
||||||
|
market.winning_option = winning_option
|
||||||
|
market.status = Market.Status.RESOLVED
|
||||||
|
market.save(update_fields=["winning_option", "status", "updated_at"])
|
||||||
|
|
||||||
|
# Calculate and distribute winnings
|
||||||
|
all_bets = list(
|
||||||
|
UserBet.objects.filter(option__market=market).select_related("user")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate total pot
|
||||||
|
total_pot = sum(bet.amount for bet in all_bets)
|
||||||
|
if total_pot == 0:
|
||||||
|
return market
|
||||||
|
|
||||||
|
# Separate winning and losing bets
|
||||||
|
winning_bets = [bet for bet in all_bets if bet.option_id == winning_option.id]
|
||||||
|
losing_bets = [bet for bet in all_bets if bet.option_id != winning_option.id]
|
||||||
|
|
||||||
|
total_winning = sum(bet.amount for bet in winning_bets)
|
||||||
|
|
||||||
|
point_changes = []
|
||||||
|
users_to_update = []
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
# Award payouts to winners with multiplier
|
||||||
|
if total_winning > 0:
|
||||||
|
for bet in winning_bets:
|
||||||
|
payout = round(
|
||||||
|
bet.amount / total_winning * total_pot * market.multiplier
|
||||||
|
)
|
||||||
|
bet.user.points += payout
|
||||||
|
users_to_update.append(bet.user)
|
||||||
|
point_changes.append(
|
||||||
|
UserPointChange(
|
||||||
|
user=bet.user,
|
||||||
|
market=market,
|
||||||
|
amount=payout,
|
||||||
|
reason=UserPointChange.Reason.BET_WON,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Record losing bets (points already deducted)
|
||||||
|
for bet in losing_bets:
|
||||||
|
point_changes.append(
|
||||||
|
UserPointChange(
|
||||||
|
user=bet.user,
|
||||||
|
market=market,
|
||||||
|
amount=-bet.amount,
|
||||||
|
reason=UserPointChange.Reason.BET_LOST,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bulk update users
|
||||||
|
for user in users_to_update:
|
||||||
|
user.save(update_fields=["points"])
|
||||||
|
|
||||||
|
# Bulk create point changes
|
||||||
|
UserPointChange.objects.bulk_create(point_changes)
|
||||||
|
|
||||||
|
return market
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{market_uuid}/bets", response=UserBetSchema)
|
||||||
|
def create_bet(request, market_uuid: str, payload: UserBetCreateSchema):
|
||||||
|
"""Place a bet on a market option."""
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
raise HttpError(401, "Authentication required")
|
||||||
|
|
||||||
|
market = get_object_or_404(Market, uuid=market_uuid)
|
||||||
|
option = get_object_or_404(MarketOption, uuid=payload.option_uuid)
|
||||||
|
|
||||||
|
if option.market_id != market.id:
|
||||||
|
raise HttpError(400, "Option does not belong to this market")
|
||||||
|
|
||||||
|
if market.status != Market.Status.OPEN:
|
||||||
|
raise HttpError(400, "Market is not open for betting")
|
||||||
|
|
||||||
|
# Check if user already has a bet on a different option in this market
|
||||||
|
existing_bet_on_market = (
|
||||||
|
UserBet.objects.filter(user=request.user, option__market=market)
|
||||||
|
.exclude(option=option)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing_bet_on_market:
|
||||||
|
raise HttpError(400, "You can only bet on one option per market")
|
||||||
|
|
||||||
|
# Check if user already has a bet on this option
|
||||||
|
existing_bet = UserBet.objects.filter(user=request.user, option=option).first()
|
||||||
|
if existing_bet and payload.amount < existing_bet.amount:
|
||||||
|
raise HttpError(400, "Cannot decrease bet amount. You can only increase it.")
|
||||||
|
|
||||||
|
# Calculate delta (amount to deduct from user's points)
|
||||||
|
delta = payload.amount - (existing_bet.amount if existing_bet else 0)
|
||||||
|
|
||||||
|
# Check if user has enough points
|
||||||
|
if request.user.points < delta:
|
||||||
|
raise HttpError(400, "Insufficient points for this bet")
|
||||||
|
|
||||||
|
user_bet, created = UserBet.objects.update_or_create(
|
||||||
|
user=request.user,
|
||||||
|
option=option,
|
||||||
|
defaults={"amount": payload.amount},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Deduct points and record the change
|
||||||
|
request.user.points -= delta
|
||||||
|
request.user.save(update_fields=["points"])
|
||||||
|
UserPointChange.objects.create(
|
||||||
|
user=request.user,
|
||||||
|
market=market,
|
||||||
|
amount=-delta,
|
||||||
|
reason=UserPointChange.Reason.BET_PLACED,
|
||||||
|
)
|
||||||
|
|
||||||
|
return user_bet
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class MarketConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "market"
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-23 15:45
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Market",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"uuid",
|
||||||
|
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("title", models.CharField(max_length=255)),
|
||||||
|
("description", models.TextField(blank=True)),
|
||||||
|
(
|
||||||
|
"type",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("yes_no", "Yes/No"), ("multiple", "Multiple Choice")],
|
||||||
|
default="yes_no",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"status",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("open", "Open"),
|
||||||
|
("closed", "Closed"),
|
||||||
|
("resolved", "Resolved"),
|
||||||
|
],
|
||||||
|
default="open",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("end_date", models.DateTimeField()),
|
||||||
|
(
|
||||||
|
"created_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["-created_at"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="MarketOption",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"uuid",
|
||||||
|
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("text", models.CharField(max_length=255)),
|
||||||
|
("position", models.PositiveIntegerField(default=0)),
|
||||||
|
(
|
||||||
|
"market",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="options",
|
||||||
|
to="market.market",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["position"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="market",
|
||||||
|
name="winning_option",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="market_won",
|
||||||
|
to="market.marketoption",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="UserBet",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"uuid",
|
||||||
|
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("amount", models.PositiveIntegerField()),
|
||||||
|
(
|
||||||
|
"option",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="user_bets",
|
||||||
|
to="market.marketoption",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="bets",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="marketoption",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["market", "position"], name="market_mark_market__8679ce_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="marketoption",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("market", "position"), name="unique_market_option_position"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="market",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["status", "-created_at"], name="market_mark_status_1ef6c3_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="market",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["end_date"], name="market_mark_end_dat_26bec0_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="userbet",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["user", "option"], name="market_user_user_id_5e43d9_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="userbet",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("user", "option"), name="unique_user_bet_per_option"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-23 18:11
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("market", "0001_initial"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="UserPointChange",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"uuid",
|
||||||
|
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("amount", models.IntegerField()),
|
||||||
|
(
|
||||||
|
"reason",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("bet_placed", "Bet Placed"),
|
||||||
|
("bet_won", "Bet Won"),
|
||||||
|
("bet_lost", "Bet Lost"),
|
||||||
|
],
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"market",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="point_changes",
|
||||||
|
to="market.market",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="point_changes",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["-created_at"],
|
||||||
|
"indexes": [
|
||||||
|
models.Index(
|
||||||
|
fields=["user", "-created_at"],
|
||||||
|
name="market_user_user_id_631ba9_idx",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-23 18:19
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("market", "0002_userpointchange"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="market",
|
||||||
|
name="type",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-23 18:20
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("market", "0003_remove_market_type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="marketoption",
|
||||||
|
options={"ordering": ["text"]},
|
||||||
|
),
|
||||||
|
migrations.RemoveConstraint(
|
||||||
|
model_name="marketoption",
|
||||||
|
name="unique_market_option_position",
|
||||||
|
),
|
||||||
|
migrations.RemoveIndex(
|
||||||
|
model_name="marketoption",
|
||||||
|
name="market_mark_market__8679ce_idx",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="marketoption",
|
||||||
|
name="position",
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="marketoption",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["market"], name="market_mark_market__67f63b_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="marketoption",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("market", "text"), name="unique_market_option_text"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-23 18:34
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("market", "0004_alter_marketoption_options_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="market",
|
||||||
|
name="multiplier",
|
||||||
|
field=models.FloatField(default=1.0),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-23 18:40
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("market", "0005_market_multiplier"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="market",
|
||||||
|
name="status",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("draft", "Draft"),
|
||||||
|
("open", "Open"),
|
||||||
|
("closed", "Closed"),
|
||||||
|
("resolved", "Resolved"),
|
||||||
|
],
|
||||||
|
default="draft",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import uuid
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class BaseModel(models.Model):
|
||||||
|
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
|
class Market(BaseModel):
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
DRAFT = "draft", "Draft"
|
||||||
|
OPEN = "open", "Open"
|
||||||
|
CLOSED = "closed", "Closed"
|
||||||
|
RESOLVED = "resolved", "Resolved"
|
||||||
|
|
||||||
|
title = models.CharField(max_length=255)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=10, choices=Status.choices, default=Status.DRAFT
|
||||||
|
)
|
||||||
|
end_date = models.DateTimeField()
|
||||||
|
multiplier = models.FloatField(default=1.0)
|
||||||
|
created_by = models.ForeignKey("accounts.CustomUser", on_delete=models.PROTECT)
|
||||||
|
winning_option = models.ForeignKey(
|
||||||
|
"MarketOption",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="market_won",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["status", "-created_at"]),
|
||||||
|
models.Index(fields=["end_date"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
|
||||||
|
class MarketOption(BaseModel):
|
||||||
|
market = models.ForeignKey(Market, on_delete=models.CASCADE, related_name="options")
|
||||||
|
text = models.CharField(max_length=255)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["text"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["market", "text"],
|
||||||
|
name="unique_market_option_text",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["market"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.market.title} - {self.text}"
|
||||||
|
|
||||||
|
|
||||||
|
class UserBet(BaseModel):
|
||||||
|
user = models.ForeignKey(
|
||||||
|
"accounts.CustomUser", on_delete=models.CASCADE, related_name="bets"
|
||||||
|
)
|
||||||
|
option = models.ForeignKey(
|
||||||
|
MarketOption, on_delete=models.CASCADE, related_name="user_bets"
|
||||||
|
)
|
||||||
|
amount = models.PositiveIntegerField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "option"],
|
||||||
|
name="unique_user_bet_per_option",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["user", "option"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.username} bet {self.amount} on {self.option.text}"
|
||||||
|
|
||||||
|
|
||||||
|
class UserPointChange(BaseModel):
|
||||||
|
class Reason(models.TextChoices):
|
||||||
|
BET_PLACED = "bet_placed", "Bet Placed"
|
||||||
|
BET_WON = "bet_won", "Bet Won"
|
||||||
|
BET_LOST = "bet_lost", "Bet Lost"
|
||||||
|
|
||||||
|
user = models.ForeignKey(
|
||||||
|
"accounts.CustomUser", on_delete=models.CASCADE, related_name="point_changes"
|
||||||
|
)
|
||||||
|
market = models.ForeignKey(
|
||||||
|
Market, on_delete=models.CASCADE, related_name="point_changes"
|
||||||
|
)
|
||||||
|
amount = models.IntegerField()
|
||||||
|
reason = models.CharField(max_length=20, choices=Reason.choices)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["user", "-created_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.username} {self.reason}: {self.amount} pts on {self.market.title}"
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional, Any
|
||||||
|
from uuid import UUID
|
||||||
|
from ninja import Schema
|
||||||
|
from pydantic import field_serializer, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class MarketOptionSchema(Schema):
|
||||||
|
uuid: UUID
|
||||||
|
text: str
|
||||||
|
total_bets: int = 0
|
||||||
|
|
||||||
|
@field_serializer("uuid")
|
||||||
|
def serialize_uuid(self, value: UUID) -> str:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
class MarketListSchema(Schema):
|
||||||
|
uuid: UUID
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
status: str
|
||||||
|
end_date: datetime
|
||||||
|
multiplier: float = 1.0
|
||||||
|
created_at: datetime
|
||||||
|
options: List[MarketOptionSchema]
|
||||||
|
winning_option: Optional[MarketOptionSchema] = None
|
||||||
|
|
||||||
|
@field_serializer("uuid")
|
||||||
|
def serialize_uuid(self, value: UUID) -> str:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveMarketSchema(Schema):
|
||||||
|
winning_option_uuid: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserBetCreateSchema(Schema):
|
||||||
|
option_uuid: str
|
||||||
|
amount: int
|
||||||
|
|
||||||
|
|
||||||
|
class UserBetSchema(Schema):
|
||||||
|
uuid: UUID
|
||||||
|
amount: int
|
||||||
|
created_at: datetime
|
||||||
|
option: MarketOptionSchema
|
||||||
|
market: Optional[MarketListSchema] = None
|
||||||
|
|
||||||
|
@field_serializer("uuid")
|
||||||
|
def serialize_uuid(self, value: UUID) -> str:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def resolve_market_from_option(cls, data: Any) -> Any:
|
||||||
|
if hasattr(data, "option") and hasattr(data.option, "market"):
|
||||||
|
data.market = data.option.market
|
||||||
|
return data
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Create your tests here.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.http import HttpRequest
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def market_home(request: HttpRequest):
|
||||||
|
return render(request, "market.html", {})
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from noita.services.objectives import parse_objectives_and_store
|
||||||
|
from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(LogfileSubmission)
|
||||||
|
class LogfileSubmissionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"id",
|
||||||
|
"user",
|
||||||
|
"content_type",
|
||||||
|
"file_size",
|
||||||
|
"created_at",
|
||||||
|
"processed",
|
||||||
|
)
|
||||||
|
list_filter = ("content_type", "processed", "created_at")
|
||||||
|
search_fields = ("id", "user__username")
|
||||||
|
readonly_fields = ("id", "created_at", "updated_at")
|
||||||
|
fieldsets = (
|
||||||
|
("Identification", {"fields": ("id",)}),
|
||||||
|
("File Information", {"fields": ("file", "content_type", "file_size")}),
|
||||||
|
("User", {"fields": ("user",)}),
|
||||||
|
("Timestamps", {"fields": ("created_at", "updated_at")}),
|
||||||
|
("Processing", {"fields": ("processed",)}),
|
||||||
|
)
|
||||||
|
|
||||||
|
actions = ["validate_submission"]
|
||||||
|
|
||||||
|
def validate_submission(self, request, queryset):
|
||||||
|
for logfile in queryset:
|
||||||
|
parse_objectives_and_store(logfile)
|
||||||
|
|
||||||
|
self.message_user(request, f"{queryset.count()} submissions validated.")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Objectiv)
|
||||||
|
class ObjectivAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("objectiv_id", "user", "first_seen_at", "get_user_objectiv_count")
|
||||||
|
list_filter = ("objectiv_id", "user")
|
||||||
|
search_fields = ("objectiv_id", "user__username")
|
||||||
|
readonly_fields = ("user", "first_seen_at")
|
||||||
|
|
||||||
|
def get_user_objectiv_count(self, obj):
|
||||||
|
return Objectiv.objects.filter(
|
||||||
|
objectiv_id=obj.objectiv_id, user=obj.user
|
||||||
|
).count()
|
||||||
|
|
||||||
|
get_user_objectiv_count.short_description = "Count"
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ObjectivPoint)
|
||||||
|
class ObjectivPointAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("objectiv_id", "display_string", "max_count", "point")
|
||||||
|
list_filter = ("objectiv_id",)
|
||||||
|
search_fields = ("objectiv_id", "display_string")
|
||||||
|
fieldsets = (
|
||||||
|
("Objective Information", {"fields": ("objectiv_id", "display_string")}),
|
||||||
|
("Scoring", {"fields": ("max_count", "point")}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(DeathCounter)
|
||||||
|
class DeathCounterAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("user_id", "seed", "seen_at")
|
||||||
|
list_filter = ("user_id", "seed")
|
||||||
|
search_fields = ("user_id", "seed")
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
("User Information", {"fields": ("user_id",)}),
|
||||||
|
("Scoring", {"fields": ("seed",)}),
|
||||||
|
)
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
from django.http import HttpRequest
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.db.models import (
|
||||||
|
F,
|
||||||
|
Case,
|
||||||
|
When,
|
||||||
|
Count,
|
||||||
|
IntegerField,
|
||||||
|
Subquery,
|
||||||
|
OuterRef,
|
||||||
|
)
|
||||||
|
from ninja import Router, File
|
||||||
|
from ninja.files import UploadedFile
|
||||||
|
from ninja.decorators import decorate_view
|
||||||
|
|
||||||
|
from noita.schemas import ResultsOut, LeaderboardOut
|
||||||
|
from noita.services.objectives import parse_objectives_and_store
|
||||||
|
from games.decorators import require_game_enabled
|
||||||
|
|
||||||
|
from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter
|
||||||
|
from .schemas import NoitaSubmissionOut
|
||||||
|
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
NOITA_APP_ID = 881100
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("results", response=ResultsOut)
|
||||||
|
@decorate_view(require_game_enabled(NOITA_APP_ID))
|
||||||
|
def get_results(request: HttpRequest):
|
||||||
|
cache_key = f"api:noita:results:{request.user.id}"
|
||||||
|
cached_data = cache.get(cache_key)
|
||||||
|
|
||||||
|
if cached_data is not None:
|
||||||
|
return cached_data
|
||||||
|
"""
|
||||||
|
Get the user's score based on their objectives.
|
||||||
|
|
||||||
|
Calculates points as: ObjectivPoint.point * min(max_count, count) for each objective
|
||||||
|
Uses Django ORM annotate for efficient queryset computation.
|
||||||
|
"""
|
||||||
|
# Group objectives by objectiv_id and count occurrences
|
||||||
|
user_objectives = (
|
||||||
|
Objectiv.objects.filter(user=request.user)
|
||||||
|
.values("objectiv_id")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch points from ObjectivPoint using Subquery
|
||||||
|
user_objectives = user_objectives.annotate(
|
||||||
|
# Get points per objective from ObjectivPoint
|
||||||
|
points_per_objectiv=Subquery(
|
||||||
|
ObjectivPoint.objects.filter(objectiv_id=OuterRef("objectiv_id")).values(
|
||||||
|
"point"
|
||||||
|
)[:1],
|
||||||
|
output_field=IntegerField(),
|
||||||
|
),
|
||||||
|
# Get max_count from ObjectivPoint
|
||||||
|
max_objectives=Subquery(
|
||||||
|
ObjectivPoint.objects.filter(objectiv_id=OuterRef("objectiv_id")).values(
|
||||||
|
"max_count"
|
||||||
|
)[:1],
|
||||||
|
output_field=IntegerField(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle negative max_count (means unlimited)
|
||||||
|
user_objectives = user_objectives.annotate(
|
||||||
|
effective_max=Case(
|
||||||
|
When(max_objectives__lt=0, then=F("count")),
|
||||||
|
default=F("max_objectives"),
|
||||||
|
output_field=IntegerField(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate capped count and total points
|
||||||
|
user_objectives = user_objectives.annotate(
|
||||||
|
capped_count=Case(
|
||||||
|
When(effective_max__lt=F("count"), then=F("effective_max")),
|
||||||
|
default=F("count"),
|
||||||
|
output_field=IntegerField(),
|
||||||
|
),
|
||||||
|
total_points=F("points_per_objectiv") * F("capped_count"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Annotate seed + first-seen-at
|
||||||
|
user_objectives = user_objectives.annotate(
|
||||||
|
seed=F("seed"),
|
||||||
|
first_seen_at=F("first_seen_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build response with all objectives and compute total score
|
||||||
|
total_score = 0
|
||||||
|
with_points = {}
|
||||||
|
for obj in ObjectivPoint.objects.all():
|
||||||
|
with_points[obj.objectiv_id] = {
|
||||||
|
"objectiv_id": obj.objectiv_id,
|
||||||
|
"display_string": obj.display_string,
|
||||||
|
"count": 0,
|
||||||
|
"max_count": obj.max_count,
|
||||||
|
"points_per_objectiv": obj.point,
|
||||||
|
"total_points": 0,
|
||||||
|
"first_seen_at": None,
|
||||||
|
"seed": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
for obj in user_objectives.order_by("-total_points"):
|
||||||
|
points = obj["total_points"] or 0
|
||||||
|
with_points[obj["objectiv_id"]].update(
|
||||||
|
{
|
||||||
|
"count": obj["count"],
|
||||||
|
"total_points": points,
|
||||||
|
"first_seen_at": obj["first_seen_at"],
|
||||||
|
"seed": obj["seed"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
total_score += points
|
||||||
|
|
||||||
|
# Count deaths for the user
|
||||||
|
deaths_count = DeathCounter.objects.filter(user=request.user).count()
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"total_score": total_score,
|
||||||
|
"deaths_count": deaths_count,
|
||||||
|
"objectives": list(with_points.values()),
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set(f"api:noita:results:{request.user.id}", data, 300)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("leaderboard", response=LeaderboardOut)
|
||||||
|
@decorate_view(require_game_enabled(NOITA_APP_ID))
|
||||||
|
def get_leaderboard(request: HttpRequest):
|
||||||
|
"""
|
||||||
|
Get the global leaderboard for all users ranked by total score.
|
||||||
|
|
||||||
|
Uses Window functions to rank users by their total score in descending order.
|
||||||
|
"""
|
||||||
|
cache_key = "api:noita:leaderboard"
|
||||||
|
cached_data = cache.get(cache_key)
|
||||||
|
|
||||||
|
if cached_data is not None:
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
# Get all objectives with calculated points (grouped by objectiv_id and user)
|
||||||
|
all_objectives = (
|
||||||
|
Objectiv.objects.values("user", "objectiv_id")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.annotate(
|
||||||
|
# Fetch points from ObjectivPoint using Subquery
|
||||||
|
points_per_objectiv=Subquery(
|
||||||
|
ObjectivPoint.objects.filter(
|
||||||
|
objectiv_id=OuterRef("objectiv_id")
|
||||||
|
).values("point")[:1],
|
||||||
|
output_field=IntegerField(),
|
||||||
|
),
|
||||||
|
# Get max_count from ObjectivPoint
|
||||||
|
max_objectives=Subquery(
|
||||||
|
ObjectivPoint.objects.filter(
|
||||||
|
objectiv_id=OuterRef("objectiv_id")
|
||||||
|
).values("max_count")[:1],
|
||||||
|
output_field=IntegerField(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
# Handle negative max_count (means unlimited)
|
||||||
|
effective_max=Case(
|
||||||
|
When(max_objectives__lt=0, then=F("count")),
|
||||||
|
default=F("max_objectives"),
|
||||||
|
output_field=IntegerField(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
# Calculate capped count and total points
|
||||||
|
capped_count=Case(
|
||||||
|
When(effective_max__lt=F("count"), then=F("effective_max")),
|
||||||
|
default=F("count"),
|
||||||
|
output_field=IntegerField(),
|
||||||
|
),
|
||||||
|
total_points=F("points_per_objectiv") * F("capped_count"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build user totals by iterating through objectives
|
||||||
|
user_totals_dict = {}
|
||||||
|
for obj in all_objectives:
|
||||||
|
user_id = obj["user"]
|
||||||
|
points = obj["total_points"] or 0
|
||||||
|
if user_id not in user_totals_dict:
|
||||||
|
user_totals_dict[user_id] = 0
|
||||||
|
user_totals_dict[user_id] += points
|
||||||
|
|
||||||
|
# Get unique users and their scores, then apply ranking
|
||||||
|
users_with_scores = []
|
||||||
|
for user_id, total_score in user_totals_dict.items():
|
||||||
|
user = User.objects.get(id=user_id)
|
||||||
|
objectives_count = (
|
||||||
|
Objectiv.objects.filter(user_id=user_id)
|
||||||
|
.values("objectiv_id")
|
||||||
|
.distinct()
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
deaths_count = DeathCounter.objects.filter(user_id=user_id).count()
|
||||||
|
users_with_scores.append(
|
||||||
|
{
|
||||||
|
"user_id": user_id,
|
||||||
|
"user": user,
|
||||||
|
"total_score": total_score,
|
||||||
|
"objectives_count": objectives_count,
|
||||||
|
"deaths_count": deaths_count,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort by score and add rank
|
||||||
|
users_with_scores.sort(key=lambda x: x["total_score"], reverse=True)
|
||||||
|
leaderboard = [
|
||||||
|
{
|
||||||
|
"rank": idx + 1,
|
||||||
|
"username": entry["user"].username,
|
||||||
|
"is_staff": entry["user"].is_staff,
|
||||||
|
"total_score": entry["total_score"],
|
||||||
|
"objectives_count": entry["objectives_count"],
|
||||||
|
"deaths_count": entry["deaths_count"],
|
||||||
|
}
|
||||||
|
for idx, entry in enumerate(users_with_scores)
|
||||||
|
]
|
||||||
|
|
||||||
|
data = {"leaderboard": leaderboard}
|
||||||
|
cache.set("api:noita:leaderboard", data, 300)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("submit", response={200: NoitaSubmissionOut, 400: dict})
|
||||||
|
@decorate_view(require_game_enabled(NOITA_APP_ID))
|
||||||
|
def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
|
||||||
|
"""
|
||||||
|
Submit a Noita run file (log file, screenshot, or video).
|
||||||
|
|
||||||
|
Accepts:
|
||||||
|
- Text files (.txt) for polylan_mod_log.txt
|
||||||
|
- Images (.png, .jpg, .gif)
|
||||||
|
- Videos (.mp4, .webm)
|
||||||
|
|
||||||
|
Max file size: 256 MB
|
||||||
|
"""
|
||||||
|
# Validate file type
|
||||||
|
allowed_types = [
|
||||||
|
"text/plain",
|
||||||
|
"text/x-log",
|
||||||
|
]
|
||||||
|
|
||||||
|
if file.content_type not in allowed_types:
|
||||||
|
return 400, {
|
||||||
|
"detail": f"Invalid file type: {file.content_type}. Allowed types: {', '.join(allowed_types)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate file size (256MB limit)
|
||||||
|
if file.size > 256 * 1024 * 1024:
|
||||||
|
return 400, {"detail": "File too large (max 256MB)"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create submission
|
||||||
|
submission = LogfileSubmission.objects.create(
|
||||||
|
user=request.user if request.user.is_authenticated else None,
|
||||||
|
content_type=file.content_type,
|
||||||
|
file_size=file.size,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save the file
|
||||||
|
submission.file.save(file.name, ContentFile(file.read()), save=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
parse_objectives_and_store(submission)
|
||||||
|
submission.processed = True
|
||||||
|
submission.save(update_fields=["processed"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Invalidate caches on successful submission
|
||||||
|
if submission.user:
|
||||||
|
cache.delete(f"api:noita:results:{submission.user.id}")
|
||||||
|
cache.delete("api:noita:leaderboard")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(submission.id),
|
||||||
|
"user_id": submission.user_id,
|
||||||
|
"username": submission.user.username if submission.user else None,
|
||||||
|
"file_size": submission.file_size,
|
||||||
|
"content_type": submission.content_type,
|
||||||
|
"created_at": submission.created_at,
|
||||||
|
"processed": submission.processed,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return 500, {"detail": f"Error creating submission: {str(e)}"}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class NoitaConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "noita"
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from noita.models import ObjectivPoint
|
||||||
|
from noita.services.decode import POINTS
|
||||||
|
from noita.services.spells import ALL_PERKS, ALL_SPELLS
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Load ObjectivPoints from the POINTS dictionary in services.decode"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--clear",
|
||||||
|
action="store_true",
|
||||||
|
help="Clear all existing ObjectivPoints before loading",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
if options["clear"]:
|
||||||
|
ObjectivPoint.objects.all().delete()
|
||||||
|
self.stdout.write(self.style.SUCCESS("Cleared existing ObjectivPoints"))
|
||||||
|
|
||||||
|
created_count = 0
|
||||||
|
updated_count = 0
|
||||||
|
|
||||||
|
for objectiv_id, point_value in POINTS.items():
|
||||||
|
# Skip special entries
|
||||||
|
if objectiv_id in {"-", "DEBUG"}:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get display string from objectiv_id (convert to title case)
|
||||||
|
display_string = objectiv_id.replace("_", " ").title()
|
||||||
|
|
||||||
|
# Create or update ObjectivPoint
|
||||||
|
obj, created = ObjectivPoint.objects.get_or_create(
|
||||||
|
objectiv_id=objectiv_id,
|
||||||
|
defaults={
|
||||||
|
"display_string": display_string,
|
||||||
|
"point": point_value,
|
||||||
|
"max_count": 1, # Default max_count is 1
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if created:
|
||||||
|
created_count += 1
|
||||||
|
self.stdout.write(f"✓ Created: {objectiv_id} - {point_value} points")
|
||||||
|
else:
|
||||||
|
# Update if points changed
|
||||||
|
if obj.point != point_value or obj.display_string != display_string:
|
||||||
|
obj.point = point_value
|
||||||
|
obj.display_string = display_string
|
||||||
|
obj.save()
|
||||||
|
updated_count += 1
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(
|
||||||
|
f"↻ Updated: {objectiv_id} - {point_value} points"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for op in ObjectivPoint.objects.filter(objectiv_id__in=ALL_SPELLS):
|
||||||
|
op.display_string = f"Spell: {op.display_string}"
|
||||||
|
op.save()
|
||||||
|
|
||||||
|
for op in ObjectivPoint.objects.filter(objectiv_id__in=ALL_PERKS):
|
||||||
|
op.display_string = f"Perk: {op.display_string}"
|
||||||
|
op.save()
|
||||||
|
|
||||||
|
for op in ObjectivPoint.objects.filter(objectiv_id__startswith="BOSS_KILL_"):
|
||||||
|
b, k, c = op.display_string.split(" ")
|
||||||
|
op.display_string = f"Perk: {op.display_string}"
|
||||||
|
op.display_string = f"{b} {k} (with {c} orbs)"
|
||||||
|
op.save()
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"\nCreated: {created_count}"))
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"Updated: {updated_count}"))
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f"Total ObjectivPoints: {ObjectivPoint.objects.count()}")
|
||||||
|
)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-09 22:53
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import noita.models
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Submission",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"content_type",
|
||||||
|
models.CharField(help_text="MIME type of the file", max_length=100),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"file_size",
|
||||||
|
models.PositiveIntegerField(help_text="File size in bytes"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"file",
|
||||||
|
models.FileField(
|
||||||
|
help_text="Uploaded file (image/gif)",
|
||||||
|
upload_to=noita.models.submission_file_upload_path,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("processed", models.BooleanField(default=False)),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
help_text="User who made the submission (null for anonymous)",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="noita_submissions",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-09 22:55
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0001_initial"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameModel(
|
||||||
|
old_name="Submission",
|
||||||
|
new_name="LogfileSubmission",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-09 23:05
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0002_rename_submission_logfilesubmission"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Objectiv",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("updated_at", models.DateTimeField()),
|
||||||
|
("objectiv_id", models.CharField(max_length=64)),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-09 23:06
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0003_objectiv"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="objectiv",
|
||||||
|
name="count",
|
||||||
|
field=models.IntegerField(default=1),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-09 23:21
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0004_objectiv_count"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="objectiv",
|
||||||
|
name="updated_at",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-09 23:44
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0005_remove_objectiv_updated_at"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ObjectivPoint",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("objectiv_id", models.CharField(max_length=64, unique=True)),
|
||||||
|
("display_string", models.CharField(max_length=255)),
|
||||||
|
("max_count", models.IntegerField(default=1)),
|
||||||
|
("point", models.IntegerField(default=0)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-11 08:20
|
||||||
|
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0006_objectivpoint"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="objectiv",
|
||||||
|
name="count",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="objectiv",
|
||||||
|
name="first_seen_at",
|
||||||
|
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="objectiv",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("objectiv_id", "user", "first_seen_at"),
|
||||||
|
name="unique_objectiv_per_user_timestamp",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-11 08:27
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0007_remove_objectiv_count_objectiv_first_seen_at_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="objectiv",
|
||||||
|
name="seed",
|
||||||
|
field=models.CharField(default="", max_length=32),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-11 08:31
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0008_objectiv_seed"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="objectiv",
|
||||||
|
name="submission",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
default=1,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="noita.logfilesubmission",
|
||||||
|
),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-14 23:36
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def fw_func(apps, _schema_editor):
|
||||||
|
Objectiv = apps.get_model("noita", "Objectiv")
|
||||||
|
Objectiv.objects.filter(objectiv_id="DEATH").delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0009_objectiv_submission"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="DeathCounter",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("seed", models.CharField(max_length=32)),
|
||||||
|
("seen_at", models.DateTimeField()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.RunPython(fw_func, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-14 23:43
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0010_deathcounter"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="deathcounter",
|
||||||
|
name="user",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
default=1,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-14 23:45
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0011_deathcounter_user"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="deathcounter",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("user_id", "seen_at"), name="unique_death_per_seen_at"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
def submission_file_upload_path(instance, filename):
|
||||||
|
"""Generate upload path for submission files"""
|
||||||
|
# Create path: submissions/{submission_id}/{uuid}_{filename}
|
||||||
|
ext = filename.split(".")[-1] if "." in filename else ""
|
||||||
|
new_filename = f"{uuid.uuid4()}_{filename}" if ext else str(uuid.uuid4())
|
||||||
|
return f"noita-submissions/{instance.id}/{new_filename}"
|
||||||
|
|
||||||
|
|
||||||
|
class LogfileSubmission(models.Model):
|
||||||
|
"""Model representing a submission containing multiple puzzle responses"""
|
||||||
|
|
||||||
|
# Identification
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
|
||||||
|
content_type = models.CharField(max_length=100, help_text="MIME type of the file")
|
||||||
|
file_size = models.PositiveIntegerField(help_text="File size in bytes")
|
||||||
|
file = models.FileField(
|
||||||
|
upload_to=submission_file_upload_path,
|
||||||
|
help_text="Uploaded file (image/gif)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# User information (optional for anonymous submissions)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="User who made the submission (null for anonymous)",
|
||||||
|
related_name="noita_submissions",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
processed = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Objectiv(models.Model):
|
||||||
|
objectiv_id = models.CharField(max_length=64)
|
||||||
|
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
first_seen_at = models.DateTimeField(default=timezone.now)
|
||||||
|
seed = models.CharField(max_length=32)
|
||||||
|
submission = models.ForeignKey("LogfileSubmission", on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["objectiv_id", "user", "first_seen_at"],
|
||||||
|
name="unique_objectiv_per_user_timestamp",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectivPoint(models.Model):
|
||||||
|
objectiv_id = models.CharField(max_length=64, unique=True)
|
||||||
|
display_string = models.CharField(max_length=255)
|
||||||
|
max_count = models.IntegerField(default=1)
|
||||||
|
point = models.IntegerField(default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class DeathCounter(models.Model):
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
seed = models.CharField(max_length=32)
|
||||||
|
seen_at = models.DateTimeField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user_id", "seen_at"],
|
||||||
|
name="unique_death_per_seen_at",
|
||||||
|
)
|
||||||
|
]
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from ninja import Schema
|
||||||
|
|
||||||
|
|
||||||
|
class NoitaSubmissionOut(Schema):
|
||||||
|
id: str
|
||||||
|
user_id: Optional[int]
|
||||||
|
username: Optional[str]
|
||||||
|
file_size: int
|
||||||
|
content_type: str
|
||||||
|
created_at: datetime
|
||||||
|
processed: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectivResultOut(Schema):
|
||||||
|
objectiv_id: str
|
||||||
|
display_string: str
|
||||||
|
first_seen_at: datetime | None
|
||||||
|
count: int
|
||||||
|
max_count: int
|
||||||
|
seed: str | None
|
||||||
|
points_per_objectiv: int
|
||||||
|
total_points: int | None
|
||||||
|
|
||||||
|
|
||||||
|
class ResultsOut(Schema):
|
||||||
|
total_score: int
|
||||||
|
deaths_count: int
|
||||||
|
objectives: list[ObjectivResultOut]
|
||||||
|
|
||||||
|
|
||||||
|
class LeaderboardEntryOut(Schema):
|
||||||
|
rank: int
|
||||||
|
username: str
|
||||||
|
is_staff: bool
|
||||||
|
total_score: int
|
||||||
|
objectives_count: int
|
||||||
|
deaths_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class LeaderboardOut(Schema):
|
||||||
|
leaderboard: list[LeaderboardEntryOut]
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
"""
|
||||||
|
Decode a polylan_mod_log.txt.
|
||||||
|
|
||||||
|
Hash format: sha1(seed|timestamp|id) — timestamp is bound into the hash so
|
||||||
|
it cannot be altered without breaking the signature.
|
||||||
|
|
||||||
|
Seed-less entries (INIT, DEBUG, mod checks) use sha1(id) with no seed or
|
||||||
|
timestamp — those are resolved via a static lookup.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python decode_log.py [path/to/polylan_mod_log.txt]
|
||||||
|
|
||||||
|
Default path: ~/.local/share/Steam/steamapps/common/Noita/polylan_mod_log.txt
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
from functools import cache
|
||||||
|
from noita.services.spells import ALL_SPELLS, ALL_PERKS
|
||||||
|
|
||||||
|
|
||||||
|
SEED_POOL = [
|
||||||
|
# General good seeds
|
||||||
|
3154823,
|
||||||
|
3718311,
|
||||||
|
10064758,
|
||||||
|
123156801,
|
||||||
|
1024089369,
|
||||||
|
1026967166,
|
||||||
|
# Pacifist seeds
|
||||||
|
177795258,
|
||||||
|
520542929,
|
||||||
|
10600249,
|
||||||
|
25300740,
|
||||||
|
21875589,
|
||||||
|
24085389,
|
||||||
|
59775105,
|
||||||
|
44190726,
|
||||||
|
1039649471,
|
||||||
|
1072607354,
|
||||||
|
# Perk combo seeds
|
||||||
|
839747651,
|
||||||
|
839844768,
|
||||||
|
840909713,
|
||||||
|
839959129,
|
||||||
|
840016192,
|
||||||
|
840039886,
|
||||||
|
840398606,
|
||||||
|
840439045,
|
||||||
|
840457463,
|
||||||
|
840492754,
|
||||||
|
840507802,
|
||||||
|
840513742,
|
||||||
|
840542079,
|
||||||
|
840574169,
|
||||||
|
840610974,
|
||||||
|
840626894,
|
||||||
|
840872436,
|
||||||
|
840894605,
|
||||||
|
841221188,
|
||||||
|
]
|
||||||
|
|
||||||
|
DEATH_PENALTY = 1
|
||||||
|
|
||||||
|
# All scoreable events: base 1 pt for every spell and perk, overrides below.
|
||||||
|
POINTS = {
|
||||||
|
# ── Spells ───────────────────────────────────────────────────────────────
|
||||||
|
**{sid: 3 for sid in ALL_SPELLS},
|
||||||
|
"ADD_TRIGGER": 10,
|
||||||
|
"ADD_TIMER": 10,
|
||||||
|
"ADD_DEATH_TRIGGER": 10,
|
||||||
|
"NOLLA": 10,
|
||||||
|
"CHAOTIC_TRANSMUTATION": 10,
|
||||||
|
"DUPLICATE": 5,
|
||||||
|
"BURST_2": 10,
|
||||||
|
"BURST_3": 15,
|
||||||
|
"BURST_4": 20,
|
||||||
|
"BURST_8": 20,
|
||||||
|
"BURST_X": 20,
|
||||||
|
"HEAL_BULLET": 5,
|
||||||
|
"ANTIHEAL": 5,
|
||||||
|
"NUKE": 5,
|
||||||
|
"NUKE_GIGA": 5,
|
||||||
|
"TELEPORT_PROJECTILE": 5,
|
||||||
|
"TELEPORT_PROJECTILE_SHORT": 5,
|
||||||
|
"TOUCH_BLOOD": 100,
|
||||||
|
"TOUCH_GOLD": 100,
|
||||||
|
"TOUCH_PISS": 100,
|
||||||
|
"TOUCH_GRASS": 100,
|
||||||
|
"TOUCH_OIL": 100,
|
||||||
|
"TOUCH_SMOKE": 100,
|
||||||
|
"TOUCH_ALCOHOL": 100,
|
||||||
|
"TOUCH_WATER": 100,
|
||||||
|
"SPELLS_TO_POWER": 10,
|
||||||
|
"ALL_SPELLS": 100,
|
||||||
|
"DIVIDE_2": 10,
|
||||||
|
"DIVIDE_3": 15,
|
||||||
|
"DIVIDE_4": 20,
|
||||||
|
"DIVIDE_10": 50,
|
||||||
|
"ALPHA": 50,
|
||||||
|
"GAMMA": 50,
|
||||||
|
"MU": 50,
|
||||||
|
"OMEGA": 50,
|
||||||
|
"PHI": 50,
|
||||||
|
"SIGMA": 50,
|
||||||
|
"TAU": 50,
|
||||||
|
"ZETA": 50,
|
||||||
|
"DISC_BULLET_BIGGER": 20,
|
||||||
|
"SUMMON_WANDGHOST": 50,
|
||||||
|
"ALL_BLACKHOLES": 20,
|
||||||
|
"ALL_DEATHCROSSES": 20,
|
||||||
|
"ALL_ROCKETS": 20,
|
||||||
|
"ALL_NUKES": 20,
|
||||||
|
"ALL_DISCS": 20,
|
||||||
|
# ── Perks ─────────────────────────────────────────────────────────────────
|
||||||
|
**{pid: 15 for pid in ALL_PERKS},
|
||||||
|
"PROTECTION_FIRE": 30,
|
||||||
|
"PROTECTION_RADIOACTIVITY": 30,
|
||||||
|
"PROTECTION_EXPLOSION": 30,
|
||||||
|
"PROTECTION_MELEE": 30,
|
||||||
|
"PROTECTION_ELECTRICITY": 30,
|
||||||
|
# ── Spell combos ─────────────────────────────────────────────────────────
|
||||||
|
"PING_PONG_DRILL": 40,
|
||||||
|
"HEAVY_SHOT_DISC": 40,
|
||||||
|
"TWO_ARC_MODIFIERS": 50,
|
||||||
|
"TWO_TRAIL_MODIFIERS": 50,
|
||||||
|
# ── Perk combos ──────────────────────────────────────────────────────────
|
||||||
|
"CRIMSON_ALCHEMIST": 50,
|
||||||
|
"GREEDY_GOBLIN_KING": 50,
|
||||||
|
"STORM_TOUCHED_ASCENDANT": 50,
|
||||||
|
"ARCHMAGE_OF_CONTROL": 50,
|
||||||
|
"HAUNTED_MAGE": 50,
|
||||||
|
"GLASS_CANNON_MESSIAH": 50,
|
||||||
|
"INFINITE_ENGINE": 50,
|
||||||
|
"PERFECT_ACCURACY_LOOP": 50,
|
||||||
|
"HOMING_DEATH_SWARM": 50,
|
||||||
|
"UNTOUCHABLE_FIELD": 50,
|
||||||
|
"ELECTRIC_SUSTAIN_LOOP": 50,
|
||||||
|
"IMMORTAL_LEECH_CORE": 50,
|
||||||
|
"PROJECTILE_OVERLOAD": 50,
|
||||||
|
"CLOSE_RANGE_DEATH_MACHINE": 50,
|
||||||
|
"CRITICAL_MASS": 50,
|
||||||
|
"HOLY_MOUNTAIN_ABUSER": 50,
|
||||||
|
"DEFLECTOR_MATRIX": 50,
|
||||||
|
"STORMBORNE_LEVITATOR": 50,
|
||||||
|
# ── Objectives ───────────────────────────────────────────────────────────
|
||||||
|
"HP_200": 50,
|
||||||
|
"HP_500": 50,
|
||||||
|
"HP_2000": 100,
|
||||||
|
"HP_5000": 500,
|
||||||
|
"GOLD_1000": 5,
|
||||||
|
"GOLD_10000": 50,
|
||||||
|
"GOLD_100000": 100,
|
||||||
|
"GOLD_1000000": 200,
|
||||||
|
**{f"ORB_{i}": 25 for i in range(34)},
|
||||||
|
**{f"BOSS_KILL_{i}": 75 * i for i in range(34)},
|
||||||
|
"WAND_MANA_500": 10,
|
||||||
|
"WAND_MANA_1000": 10,
|
||||||
|
"WAND_MANA_1500": 10,
|
||||||
|
"WAND_CAPACITY_10": 10,
|
||||||
|
"WAND_CAPACITY_20": 10,
|
||||||
|
"BOSS_KILL": 100,
|
||||||
|
"CRIMSON_ALCHEMIST-BOSS_KILL": 100,
|
||||||
|
"GREEDY_GOBLIN_KING-BOSS_KILL": 100,
|
||||||
|
"STORM_TOUCHED_ASCENDANT-BOSS_KILL": 100,
|
||||||
|
"ARCHMAGE_OF_CONTROL-BOSS_KILL": 100,
|
||||||
|
"HAUNTED_MAGE-BOSS_KILL": 100,
|
||||||
|
"GLASS_CANNON_MESSIAH-BOSS_KILL": 100,
|
||||||
|
"INFINITE_ENGINE-BOSS_KILL": 100,
|
||||||
|
"PERFECT_ACCURACY_LOOP-BOSS_KILL": 100,
|
||||||
|
"HOMING_DEATH_SWARM-BOSS_KILL": 100,
|
||||||
|
"UNTOUCHABLE_FIELD-BOSS_KILL": 100,
|
||||||
|
"ELECTRIC_SUSTAIN_LOOP-BOSS_KILL": 100,
|
||||||
|
"IMMORTAL_LEECH_CORE-BOSS_KILL": 100,
|
||||||
|
"PROJECTILE_OVERLOAD-BOSS_KILL": 100,
|
||||||
|
"CLOSE_RANGE_DEATH_MACHINE-BOSS_KILL": 100,
|
||||||
|
"CRITICAL_MASS-BOSS_KILL": 100,
|
||||||
|
"HOLY_MOUNTAIN_ABUSER-BOSS_KILL": 100,
|
||||||
|
"DEFLECTOR_MATRIX-BOSS_KILL": 100,
|
||||||
|
"STORMBORNE_LEVITATOR-BOSS_KILL": 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Perk-combo IDs — used to award a per-combo boss-kill bonus.
|
||||||
|
PERK_COMBO_IDS = {
|
||||||
|
"CRIMSON_ALCHEMIST",
|
||||||
|
"GREEDY_GOBLIN_KING",
|
||||||
|
"STORM_TOUCHED_ASCENDANT",
|
||||||
|
"ARCHMAGE_OF_CONTROL",
|
||||||
|
"HAUNTED_MAGE",
|
||||||
|
"GLASS_CANNON_MESSIAH",
|
||||||
|
"INFINITE_ENGINE",
|
||||||
|
"PERFECT_ACCURACY_LOOP",
|
||||||
|
"HOMING_DEATH_SWARM",
|
||||||
|
"UNTOUCHABLE_FIELD",
|
||||||
|
"ELECTRIC_SUSTAIN_LOOP",
|
||||||
|
"IMMORTAL_LEECH_CORE",
|
||||||
|
"PROJECTILE_OVERLOAD",
|
||||||
|
"CLOSE_RANGE_DEATH_MACHINE",
|
||||||
|
"CRITICAL_MASS",
|
||||||
|
"HOLY_MOUNTAIN_ABUSER",
|
||||||
|
"DEFLECTOR_MATRIX",
|
||||||
|
"STORMBORNE_LEVITATOR",
|
||||||
|
}
|
||||||
|
|
||||||
|
BOSS_KILL_COMBO_BONUS = 100 # default bonus per active perk combo on boss kill
|
||||||
|
|
||||||
|
# IDs tried with the seed+timestamp scheme.
|
||||||
|
_ALL_IDS = list(POINTS.keys()) + ["DEATH"]
|
||||||
|
|
||||||
|
# Seed-less hashes (sha1(id) only, no seed, no timestamp).
|
||||||
|
_STATIC_LOOKUP = {
|
||||||
|
hashlib.sha1(k.encode()).hexdigest(): k
|
||||||
|
for k in ["-", "DEBUG", "polylan-mod", "cheatgui", "alchemy_recipes_display"]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sha1(text: str) -> str:
|
||||||
|
return hashlib.sha1(text.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def resolve(
|
||||||
|
hash_val: str, ts: str, preferred_seed: int | None = None
|
||||||
|
) -> tuple[str | None, int | None]:
|
||||||
|
"""Return (name, seed) or (None, None). preferred_seed is tried first."""
|
||||||
|
if hash_val in _STATIC_LOOKUP:
|
||||||
|
return _STATIC_LOOKUP[hash_val], None
|
||||||
|
|
||||||
|
seeds = (
|
||||||
|
[preferred_seed] + [s for s in SEED_POOL if s != preferred_seed]
|
||||||
|
if preferred_seed is not None
|
||||||
|
else SEED_POOL
|
||||||
|
)
|
||||||
|
|
||||||
|
for seed in seeds:
|
||||||
|
prefix = f"{seed}|{ts}|"
|
||||||
|
for name in _ALL_IDS:
|
||||||
|
if _sha1(prefix + name) == hash_val:
|
||||||
|
return name, seed
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_log(file) -> list:
|
||||||
|
entries = []
|
||||||
|
for il, line in enumerate(file.split("\n")):
|
||||||
|
m = re.match(r"\[(.+?)\] ([0-9a-f]{40})", line.rstrip())
|
||||||
|
if m:
|
||||||
|
entries.append({"ts": m.group(1), "hash": m.group(2)})
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"Unable to parse line number {il:>4}: {line.strip()}??")
|
||||||
|
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def decode(file) -> None:
|
||||||
|
entries = parse_log(file)
|
||||||
|
|
||||||
|
if not entries:
|
||||||
|
print("No entries found in log.")
|
||||||
|
return
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
total = 0
|
||||||
|
deaths = 0
|
||||||
|
have_init = False
|
||||||
|
have_debug = False
|
||||||
|
known_seed = None # cached once first seeded entry is resolved
|
||||||
|
current_run_perk_combos = set() # perk combos seen since the last "-" entry
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
name, seed = resolve(entry["hash"], entry["ts"], known_seed)
|
||||||
|
|
||||||
|
if name is None:
|
||||||
|
print(f" ???? (unresolved) {entry['hash']} ts: {entry['ts']}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if seed is not None and known_seed is None:
|
||||||
|
known_seed = seed
|
||||||
|
|
||||||
|
if name == "-":
|
||||||
|
have_init = True
|
||||||
|
current_run_perk_combos = set()
|
||||||
|
continue
|
||||||
|
|
||||||
|
if name == "DEBUG":
|
||||||
|
have_debug = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
if name == "DEATH":
|
||||||
|
deaths += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# skip mod-presence entries
|
||||||
|
if name not in POINTS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if name in seen:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if name not in {"BOSS_KILL"}:
|
||||||
|
seen.add(name)
|
||||||
|
|
||||||
|
if name in PERK_COMBO_IDS:
|
||||||
|
current_run_perk_combos.add(name)
|
||||||
|
|
||||||
|
pts = POINTS.get(name, 0)
|
||||||
|
if pts:
|
||||||
|
print(f" +{pts:>4} {name:<40} first seen: {entry['ts']}")
|
||||||
|
total += pts
|
||||||
|
|
||||||
|
if name == "BOSS_KILL":
|
||||||
|
for combo_id in sorted(current_run_perk_combos):
|
||||||
|
bonus_key = f"{combo_id}-BOSS_KILL"
|
||||||
|
if bonus_key not in seen:
|
||||||
|
seen.add(bonus_key)
|
||||||
|
bonus_pts = POINTS.get(bonus_key, BOSS_KILL_COMBO_BONUS)
|
||||||
|
print(f" +{bonus_pts:>4} {bonus_key:<40} combo bonus")
|
||||||
|
total += bonus_pts
|
||||||
|
|
||||||
|
death_deduction = deaths * DEATH_PENALTY
|
||||||
|
if deaths:
|
||||||
|
print(
|
||||||
|
f" -{death_deduction:>4} ({deaths} death{'s' if deaths > 1 else ''} × {DEATH_PENALTY} pts)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if have_init:
|
||||||
|
print(
|
||||||
|
f"\nTotal: {total - death_deduction} pts ({total} earned − {death_deduction} penalty)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if have_debug:
|
||||||
|
print("Note: DEBUG mode was active during at least one session.")
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
from noita.models import LogfileSubmission, Objectiv, DeathCounter
|
||||||
|
from noita.services.decode import parse_log, resolve
|
||||||
|
|
||||||
|
|
||||||
|
def parse_objectives_from_logfile(
|
||||||
|
logfile: LogfileSubmission,
|
||||||
|
) -> list[tuple[str, str, str]]:
|
||||||
|
"""Parse a log file, and output a count for each ID."""
|
||||||
|
file_data = logfile.file.read().decode()
|
||||||
|
|
||||||
|
entries: list[tuple[str, str, str]] = []
|
||||||
|
for entry in parse_log(file_data):
|
||||||
|
idx, seed = resolve(entry["hash"], entry["ts"])
|
||||||
|
|
||||||
|
if idx and seed:
|
||||||
|
entries.append((idx, str(seed), entry["ts"]))
|
||||||
|
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
|
||||||
|
"""Parse a logfile and store output."""
|
||||||
|
|
||||||
|
if not logfile.user:
|
||||||
|
return
|
||||||
|
|
||||||
|
objectives = []
|
||||||
|
deaths = []
|
||||||
|
for idx, seed, ts in parse_objectives_from_logfile(logfile):
|
||||||
|
if idx in {"-", "DEBUG", "polylan-mod"}:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if idx == "DEATH":
|
||||||
|
deaths.append(DeathCounter(user=logfile.user, seed=seed, seen_at=ts))
|
||||||
|
continue
|
||||||
|
|
||||||
|
objectives.append(
|
||||||
|
Objectiv(
|
||||||
|
objectiv_id=idx,
|
||||||
|
user=logfile.user,
|
||||||
|
first_seen_at=ts,
|
||||||
|
seed=seed,
|
||||||
|
submission=logfile,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Objectiv.objects.bulk_create(
|
||||||
|
objectives,
|
||||||
|
update_conflicts=True,
|
||||||
|
update_fields=["seed", "submission"],
|
||||||
|
unique_fields=["objectiv_id", "user", "first_seen_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
DeathCounter.objects.bulk_create(deaths, ignore_conflicts=True)
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
ALL_SPELLS = [
|
||||||
|
"FUNKY_SPELL",
|
||||||
|
"ACIDSHOT",
|
||||||
|
"BLACK_HOLE",
|
||||||
|
"BLACK_HOLE_DEATH_TRIGGER",
|
||||||
|
"BOMB",
|
||||||
|
"BOMB_CART",
|
||||||
|
"BUBBLESHOT",
|
||||||
|
"BUBBLESHOT_TRIGGER",
|
||||||
|
"AIR_BULLET",
|
||||||
|
"CHAIN_BOLT",
|
||||||
|
"CHAINSAW",
|
||||||
|
"CURSED_ORB",
|
||||||
|
"ANTIHEAL",
|
||||||
|
"DEATH_CROSS",
|
||||||
|
"DEATH_CROSS_BIG",
|
||||||
|
"LASER_EMITTER_FOUR",
|
||||||
|
"POWERDIGGER",
|
||||||
|
"DIGGER",
|
||||||
|
"PIPE_BOMB",
|
||||||
|
"PIPE_BOMB_DEATH_TRIGGER",
|
||||||
|
"GRENADE_LARGE",
|
||||||
|
"DYNAMITE",
|
||||||
|
"CRUMBLING_EARTH",
|
||||||
|
"TENTACLE_PORTAL",
|
||||||
|
"SLOW_BULLET",
|
||||||
|
"SLOW_BULLET_TRIGGER",
|
||||||
|
"SLOW_BULLET_TIMER",
|
||||||
|
"EXPANDING_ORB",
|
||||||
|
"FIREBALL",
|
||||||
|
"GRENADE",
|
||||||
|
"GRENADE_TRIGGER",
|
||||||
|
"GRENADE_TIER_2",
|
||||||
|
"GRENADE_TIER_3",
|
||||||
|
"GRENADE_ANTI",
|
||||||
|
"FIREBOMB",
|
||||||
|
"FIREWORK",
|
||||||
|
"FLAMETHROWER",
|
||||||
|
"GLITTER_BOMB",
|
||||||
|
"LANCE",
|
||||||
|
"GLUE_SHOT",
|
||||||
|
"HEAL_BULLET",
|
||||||
|
"LANCE_HOLY",
|
||||||
|
"BOMB_HOLY",
|
||||||
|
"BOMB_HOLY_GIGA",
|
||||||
|
"HOOK",
|
||||||
|
"ICEBALL",
|
||||||
|
"LASER",
|
||||||
|
"LIGHTNING",
|
||||||
|
"THUNDERBALL",
|
||||||
|
"BALL_LIGHTNING",
|
||||||
|
"LUMINOUS_DRILL",
|
||||||
|
"LASER_LUMINOUS_DRILL",
|
||||||
|
"BULLET",
|
||||||
|
"BULLET_TRIGGER",
|
||||||
|
"BULLET_TIMER",
|
||||||
|
"HEAVY_BULLET",
|
||||||
|
"HEAVY_BULLET_TRIGGER",
|
||||||
|
"HEAVY_BULLET_TIMER",
|
||||||
|
"MAGIC_SHIELD",
|
||||||
|
"BIG_MAGIC_SHIELD",
|
||||||
|
"ROCKET",
|
||||||
|
"ROCKET_TIER_2",
|
||||||
|
"ROCKET_TIER_3",
|
||||||
|
"METEOR",
|
||||||
|
"MIST_BLOOD",
|
||||||
|
"MIST_ALCOHOL",
|
||||||
|
"MIST_SLIME",
|
||||||
|
"MIST_RADIOACTIVE",
|
||||||
|
"BUCKSHOT",
|
||||||
|
"MEGALASER",
|
||||||
|
"EXPLODING_DUCKS",
|
||||||
|
"FREEZING_GAZE",
|
||||||
|
"INFESTATION",
|
||||||
|
"NUKE",
|
||||||
|
"NUKE_GIGA",
|
||||||
|
"DARKFLAME",
|
||||||
|
"GLOWING_BOLT",
|
||||||
|
"LASER_EMITTER",
|
||||||
|
"LASER_EMITTER_CUTTER",
|
||||||
|
"POLLEN",
|
||||||
|
"SPORE_POD",
|
||||||
|
"PROPANE_TANK",
|
||||||
|
"RANDOM_PROJECTILE",
|
||||||
|
"SUMMON_ROCK",
|
||||||
|
"DISC_BULLET",
|
||||||
|
"DISC_BULLET_BIG",
|
||||||
|
"DISC_BULLET_BIGGER",
|
||||||
|
"SLIMEBALL",
|
||||||
|
"LIGHT_BULLET",
|
||||||
|
"LIGHT_BULLET_TRIGGER",
|
||||||
|
"LIGHT_BULLET_TRIGGER_2",
|
||||||
|
"LIGHT_BULLET_TIMER",
|
||||||
|
"RUBBER_BALL",
|
||||||
|
"ARROW",
|
||||||
|
"BOUNCY_ORB",
|
||||||
|
"BOUNCY_ORB_TIMER",
|
||||||
|
"SPIRAL_SHOT",
|
||||||
|
"SPITTER",
|
||||||
|
"SPITTER_TIMER",
|
||||||
|
"SPITTER_TIER_2",
|
||||||
|
"SPITTER_TIER_2_TIMER",
|
||||||
|
"SPITTER_TIER_3",
|
||||||
|
"SPITTER_TIER_3_TIMER",
|
||||||
|
"EXPLODING_DEER",
|
||||||
|
"SUMMON_EGG",
|
||||||
|
"TNTBOX",
|
||||||
|
"TNTBOX_BIG",
|
||||||
|
"FISH",
|
||||||
|
"SUMMON_HOLLOW_EGG",
|
||||||
|
"MISSILE",
|
||||||
|
"PEBBLE",
|
||||||
|
"TENTACLE",
|
||||||
|
"TENTACLE_TIMER",
|
||||||
|
"TELEPORT_PROJECTILE_CLOSER",
|
||||||
|
"TELEPORT_PROJECTILE_STATIC",
|
||||||
|
"SWAPPER_PROJECTILE",
|
||||||
|
"TELEPORT_PROJECTILE",
|
||||||
|
"TELEPORT_PROJECTILE_SHORT",
|
||||||
|
"MINE",
|
||||||
|
"MINE_DEATH_TRIGGER",
|
||||||
|
"WHITE_HOLE",
|
||||||
|
"WORM_SHOT",
|
||||||
|
"WALL_HORIZONTAL",
|
||||||
|
"WALL_VERTICAL",
|
||||||
|
"WALL_SQUARE",
|
||||||
|
"REGENERATION_FIELD",
|
||||||
|
"FREEZE_FIELD",
|
||||||
|
"LEVITATION_FIELD",
|
||||||
|
"TELEPORTATION_FIELD",
|
||||||
|
"BERSERK_FIELD",
|
||||||
|
"SHIELD_FIELD",
|
||||||
|
"ELECTROCUTION_FIELD",
|
||||||
|
"POLYMORPH_FIELD",
|
||||||
|
"CHAOS_POLYMORPH_FIELD",
|
||||||
|
"CLOUD_WATER",
|
||||||
|
"CLOUD_OIL",
|
||||||
|
"CLOUD_BLOOD",
|
||||||
|
"CLOUD_ACID",
|
||||||
|
"CLOUD_THUNDER",
|
||||||
|
"DESTRUCTION",
|
||||||
|
"BOMB_DETONATOR",
|
||||||
|
"PURPLE_EXPLOSION_FIELD",
|
||||||
|
"WORM_RAIN",
|
||||||
|
"METEOR_RAIN",
|
||||||
|
"SWARM_FLY",
|
||||||
|
"SWARM_FIREBUG",
|
||||||
|
"SWARM_WASP",
|
||||||
|
"DELAYED_SPELL",
|
||||||
|
"MASS_POLYMORPH",
|
||||||
|
"PROJECTILE_THUNDER_FIELD",
|
||||||
|
"PROJECTILE_GRAVITY_FIELD",
|
||||||
|
"PROJECTILE_TRANSMUTATION_FIELD",
|
||||||
|
"RANDOM_STATIC_PROJECTILE",
|
||||||
|
"WHITE_HOLE_BIG",
|
||||||
|
"BLACK_HOLE_BIG",
|
||||||
|
"BLACK_HOLE_GIGA",
|
||||||
|
"THUNDER_BLAST",
|
||||||
|
"FIRE_BLAST",
|
||||||
|
"EXPLOSION_LIGHT",
|
||||||
|
"POISON_BLAST",
|
||||||
|
"EXPLOSION",
|
||||||
|
"ALCOHOL_BLAST",
|
||||||
|
"WHITE_HOLE_GIGA",
|
||||||
|
"FRIEND_FLY",
|
||||||
|
"VACUUM_POWDER",
|
||||||
|
"VACUUM_LIQUID",
|
||||||
|
"VACUUM_ENTITIES",
|
||||||
|
"CLUSTERMOD",
|
||||||
|
"MANA_REDUCE",
|
||||||
|
"ARC_POISON",
|
||||||
|
"ARC_FIRE",
|
||||||
|
"ARC_GUNPOWDER",
|
||||||
|
"ARC_ELECTRIC",
|
||||||
|
"ROCKET_DOWNWARDS",
|
||||||
|
"ROCKET_OCTAGON",
|
||||||
|
"BOUNCE",
|
||||||
|
"BOUNCE_SPARK",
|
||||||
|
"BOUNCE_LASER",
|
||||||
|
"REMOVE_BOUNCE",
|
||||||
|
"BOUNCE_SMALL_EXPLOSION",
|
||||||
|
"BOUNCE_HOLE",
|
||||||
|
"BOUNCE_EXPLOSION",
|
||||||
|
"BOUNCE_LARPA",
|
||||||
|
"BOUNCE_LIGHTNING",
|
||||||
|
"BOUNCE_LASER_EMITTER",
|
||||||
|
"EXPLOSION_TINY",
|
||||||
|
"HITFX_CRITICAL_BLOOD",
|
||||||
|
"HITFX_CRITICAL_OIL",
|
||||||
|
"HITFX_CRITICAL_WATER",
|
||||||
|
"HITFX_BURNING_CRITICAL_HIT",
|
||||||
|
"CRITICAL_HIT",
|
||||||
|
"AREA_DAMAGE",
|
||||||
|
"BLOODLUST",
|
||||||
|
"DAMAGE",
|
||||||
|
"DAMAGE_RANDOM",
|
||||||
|
"DAMAGE_FOREVER",
|
||||||
|
"ZERO_DAMAGE",
|
||||||
|
"HEAVY_SHOT",
|
||||||
|
"LIGHT_SHOT",
|
||||||
|
"CRUMBLING_EARTH_PROJECTILE",
|
||||||
|
"FREEZE",
|
||||||
|
"ELECTRIC_CHARGE",
|
||||||
|
"HITFX_EXPLOSION_ALCOHOL",
|
||||||
|
"HITFX_EXPLOSION_ALCOHOL_GIGA",
|
||||||
|
"HITFX_EXPLOSION_SLIME",
|
||||||
|
"HITFX_EXPLOSION_SLIME_GIGA",
|
||||||
|
"HITFX_TOXIC_CHARM",
|
||||||
|
"COLOUR_RED",
|
||||||
|
"COLOUR_ORANGE",
|
||||||
|
"COLOUR_YELLOW",
|
||||||
|
"COLOUR_GREEN",
|
||||||
|
"COLOUR_BLUE",
|
||||||
|
"COLOUR_PURPLE",
|
||||||
|
"COLOUR_RAINBOW",
|
||||||
|
"COLOUR_INVIS",
|
||||||
|
"HEAVY_SPREAD",
|
||||||
|
"KNOCKBACK",
|
||||||
|
"LARPA_CHAOS_2",
|
||||||
|
"LARPA_CHAOS",
|
||||||
|
"LARPA_DOWNWARDS",
|
||||||
|
"LARPA_DEATH",
|
||||||
|
"LARPA_UPWARDS",
|
||||||
|
"LIFETIME_DOWN",
|
||||||
|
"LIFETIME",
|
||||||
|
"CHAIN_SHOT",
|
||||||
|
"NOLLA",
|
||||||
|
"LIGHT",
|
||||||
|
"NECROMANCY",
|
||||||
|
"ORBIT_DISCS",
|
||||||
|
"ORBIT_NUKES",
|
||||||
|
"ORBIT_FIREBALLS",
|
||||||
|
"ORBIT_LARPA",
|
||||||
|
"ORBIT_LASERS",
|
||||||
|
"LINE_ARC",
|
||||||
|
"HORIZONTAL_ARC",
|
||||||
|
"GRAVITY",
|
||||||
|
"GRAVITY_ANTI",
|
||||||
|
"FLY_UPWARDS",
|
||||||
|
"FLY_DOWNWARDS",
|
||||||
|
"ORBIT_SHOT",
|
||||||
|
"TRUE_ORBIT",
|
||||||
|
"SPIRALING_SHOT",
|
||||||
|
"PINGPONG_PATH",
|
||||||
|
"PHASING_ARC",
|
||||||
|
"CHAOTIC_ARC",
|
||||||
|
"SINEWAVE",
|
||||||
|
"HOMING_WAND",
|
||||||
|
"HOMING_CURSOR",
|
||||||
|
"HOMING_SHORT",
|
||||||
|
"AUTOAIM",
|
||||||
|
"HOMING",
|
||||||
|
"HOMING_ROTATE",
|
||||||
|
"HOMING_SHOOTER",
|
||||||
|
"ANTI_HOMING",
|
||||||
|
"HOMING_ACCELERATING",
|
||||||
|
"HOMING_AREA",
|
||||||
|
"FIREBALL_RAY_ENEMY",
|
||||||
|
"GRAVITY_FIELD_ENEMY",
|
||||||
|
"TENTACLE_RAY_ENEMY",
|
||||||
|
"LIGHTNING_RAY_ENEMY",
|
||||||
|
"HITFX_PETRIFY",
|
||||||
|
"PIERCING_SHOT",
|
||||||
|
"LASER_EMITTER_WIDER",
|
||||||
|
"QUANTUM_SPLIT",
|
||||||
|
"RANDOM_EXPLOSION",
|
||||||
|
"RANDOM_MODIFIER",
|
||||||
|
"RECOIL",
|
||||||
|
"RECOIL_DAMPER",
|
||||||
|
"RECHARGE",
|
||||||
|
"SPREAD_REDUCE",
|
||||||
|
"EXPLOSION_REMOVE",
|
||||||
|
"SLOW_BUT_STEADY",
|
||||||
|
"ENERGY_SHIELD_SHOT",
|
||||||
|
"SPEED",
|
||||||
|
"DECELERATING_SHOT",
|
||||||
|
"ACCELERATING_SHOT",
|
||||||
|
"FIZZLE",
|
||||||
|
"FLOATING_ARC",
|
||||||
|
"AVOIDING_ARC",
|
||||||
|
"CLIPPING_SHOT",
|
||||||
|
"UNSTABLE_GUNPOWDER",
|
||||||
|
"MATTER_EATER",
|
||||||
|
"EXPLOSIVE_PROJECTILE",
|
||||||
|
"FIREBALL_RAY_LINE",
|
||||||
|
"FIREBALL_RAY",
|
||||||
|
"LIGHTNING_RAY",
|
||||||
|
"TENTACLE_RAY",
|
||||||
|
"LASER_EMITTER_RAY",
|
||||||
|
"SPELLS_TO_POWER",
|
||||||
|
"ESSENCE_TO_POWER",
|
||||||
|
"ACID_TRAIL",
|
||||||
|
"FIRE_TRAIL",
|
||||||
|
"GUNPOWDER_TRAIL",
|
||||||
|
"OIL_TRAIL",
|
||||||
|
"POISON_TRAIL",
|
||||||
|
"RAINBOW_TRAIL",
|
||||||
|
"WATER_TRAIL",
|
||||||
|
"BURN_TRAIL",
|
||||||
|
"WATER_TO_POISON",
|
||||||
|
"BLOOD_TO_ACID",
|
||||||
|
"LAVA_TO_BLOOD",
|
||||||
|
"LIQUID_TO_EXPLOSION",
|
||||||
|
"TOXIC_TO_ACID",
|
||||||
|
"STATIC_TO_SAND",
|
||||||
|
"TRANSMUTATION",
|
||||||
|
"CURSE_WITHER_ELECTRICITY",
|
||||||
|
"CURSE_WITHER_EXPLOSION",
|
||||||
|
"CURSE_WITHER_MELEE",
|
||||||
|
"CURSE_WITHER_PROJECTILE",
|
||||||
|
"CURSE",
|
||||||
|
"BURST_2",
|
||||||
|
"BURST_3",
|
||||||
|
"BURST_4",
|
||||||
|
"BURST_8",
|
||||||
|
"BURST_X",
|
||||||
|
"SCATTER_2",
|
||||||
|
"SCATTER_3",
|
||||||
|
"SCATTER_4",
|
||||||
|
"I_SHAPE",
|
||||||
|
"T_SHAPE",
|
||||||
|
"PENTAGRAM_SHAPE",
|
||||||
|
"CIRCLE_SHAPE",
|
||||||
|
"Y_SHAPE",
|
||||||
|
"W_SHAPE",
|
||||||
|
"TOUCH_PISS",
|
||||||
|
"TOUCH_GRASS",
|
||||||
|
"SOILBALL",
|
||||||
|
"CIRCLE_FIRE",
|
||||||
|
"CIRCLE_ACID",
|
||||||
|
"CIRCLE_OIL",
|
||||||
|
"CIRCLE_WATER",
|
||||||
|
"MATERIAL_BLOOD",
|
||||||
|
"MATERIAL_CEMENT",
|
||||||
|
"MATERIAL_OIL",
|
||||||
|
"MATERIAL_ACID",
|
||||||
|
"MATERIAL_WATER",
|
||||||
|
"SEA_LAVA",
|
||||||
|
"SEA_ALCOHOL",
|
||||||
|
"SEA_OIL",
|
||||||
|
"SEA_WATER",
|
||||||
|
"SEA_ACID",
|
||||||
|
"SEA_ACID_GAS",
|
||||||
|
"SEA_SWAMP",
|
||||||
|
"SEA_MIMIC",
|
||||||
|
"TOUCH_BLOOD",
|
||||||
|
"TOUCH_GOLD",
|
||||||
|
"TOUCH_OIL",
|
||||||
|
"TOUCH_SMOKE",
|
||||||
|
"TOUCH_ALCOHOL",
|
||||||
|
"TOUCH_WATER",
|
||||||
|
"X_RAY",
|
||||||
|
"BLOOD_MAGIC",
|
||||||
|
"CASTER_CAST",
|
||||||
|
"I_SHOT",
|
||||||
|
"Y_SHOT",
|
||||||
|
"T_SHOT",
|
||||||
|
"W_SHOT",
|
||||||
|
"QUAD_SHOT",
|
||||||
|
"PENTA_SHOT",
|
||||||
|
"HEXA_SHOT",
|
||||||
|
"SUPER_TELEPORT_CAST",
|
||||||
|
"TELEPORT_CAST",
|
||||||
|
"LONG_DISTANCE_CAST",
|
||||||
|
"ALL_ACID",
|
||||||
|
"ALL_BLACKHOLES",
|
||||||
|
"ALL_DEATHCROSSES",
|
||||||
|
"ALL_ROCKETS",
|
||||||
|
"ALL_NUKES",
|
||||||
|
"ALL_DISCS",
|
||||||
|
"SUMMON_WANDGHOST",
|
||||||
|
"TEMPORARY_PLATFORM",
|
||||||
|
"TEMPORARY_WALL",
|
||||||
|
"MONEY_MAGIC",
|
||||||
|
"BLOOD_TO_POWER",
|
||||||
|
"RESET",
|
||||||
|
"ENERGY_SHIELD",
|
||||||
|
"ENERGY_SHIELD_SECTOR",
|
||||||
|
"TINY_GHOST",
|
||||||
|
"TORCH",
|
||||||
|
"TORCH_ELECTRIC",
|
||||||
|
"ADD_TRIGGER",
|
||||||
|
"ADD_TIMER",
|
||||||
|
"ADD_DEATH_TRIGGER",
|
||||||
|
"CESSATION",
|
||||||
|
"DIVIDE_2",
|
||||||
|
"DIVIDE_3",
|
||||||
|
"DIVIDE_4",
|
||||||
|
"DIVIDE_10",
|
||||||
|
"OMEGA",
|
||||||
|
"ZETA",
|
||||||
|
"TAU",
|
||||||
|
"SIGMA",
|
||||||
|
"PHI",
|
||||||
|
"MU",
|
||||||
|
"ALPHA",
|
||||||
|
"GAMMA",
|
||||||
|
"KANTELE_A",
|
||||||
|
"KANTELE_D",
|
||||||
|
"KANTELE_DIS",
|
||||||
|
"KANTELE_E",
|
||||||
|
"KANTELE_G",
|
||||||
|
"OCARINA_A",
|
||||||
|
"OCARINA_B",
|
||||||
|
"OCARINA_C",
|
||||||
|
"OCARINA_D",
|
||||||
|
"OCARINA_E",
|
||||||
|
"OCARINA_F",
|
||||||
|
"OCARINA_GSHARP",
|
||||||
|
"OCARINA_A2",
|
||||||
|
"RANDOM_SPELL",
|
||||||
|
"DRAW_RANDOM",
|
||||||
|
"DRAW_RANDOM_X3",
|
||||||
|
"DRAW_3_RANDOM",
|
||||||
|
"IF_PROJECTILE",
|
||||||
|
"IF_HP",
|
||||||
|
"IF_ENEMY",
|
||||||
|
"IF_HALF",
|
||||||
|
"IF_ELSE",
|
||||||
|
"IF_END",
|
||||||
|
"DUPLICATE",
|
||||||
|
"SUMMON_PORTAL",
|
||||||
|
"ALL_SPELLS",
|
||||||
|
]
|
||||||
|
|
||||||
|
ALL_PERKS = [
|
||||||
|
"CRITICAL_HIT",
|
||||||
|
"BREATH_UNDERWATER",
|
||||||
|
"EXTRA_MONEY",
|
||||||
|
"EXTRA_MONEY_TRICK_KILL",
|
||||||
|
"GOLD_IS_FOREVER",
|
||||||
|
"TRICK_BLOOD_MONEY",
|
||||||
|
"EXPLODING_GOLD",
|
||||||
|
"HOVER_BOOST",
|
||||||
|
"FASTER_LEVITATION",
|
||||||
|
"MOVEMENT_FASTER",
|
||||||
|
"LOW_GRAVITY",
|
||||||
|
"HIGH_GRAVITY",
|
||||||
|
"SPEED_DIVER",
|
||||||
|
"STRONG_KICK",
|
||||||
|
"TELEKINESIS",
|
||||||
|
"REPELLING_CAPE",
|
||||||
|
"EXPLODING_CORPSES",
|
||||||
|
"SAVING_GRACE",
|
||||||
|
"INVISIBILITY",
|
||||||
|
"GLOBAL_GORE",
|
||||||
|
"REMOVE_FOG_OF_WAR",
|
||||||
|
"LEVITATION_TRAIL",
|
||||||
|
"VAMPIRISM",
|
||||||
|
"EXTRA_HP",
|
||||||
|
"HEARTS_MORE_EXTRA_HP",
|
||||||
|
"GLASS_CANNON",
|
||||||
|
"LOW_HP_DAMAGE_BOOST",
|
||||||
|
"RESPAWN",
|
||||||
|
"WORM_ATTRACTOR",
|
||||||
|
"WORM_DETRACTOR",
|
||||||
|
"RADAR_ENEMY",
|
||||||
|
"FOOD_CLOCK",
|
||||||
|
"WAND_RADAR",
|
||||||
|
"ITEM_RADAR",
|
||||||
|
"MOON_RADAR",
|
||||||
|
"PROTECTION_FIRE",
|
||||||
|
"PROTECTION_RADIOACTIVITY",
|
||||||
|
"PROTECTION_EXPLOSION",
|
||||||
|
"PROTECTION_MELEE",
|
||||||
|
"PROTECTION_ELECTRICITY",
|
||||||
|
"TELEPORTITIS",
|
||||||
|
"TELEPORTITIS_DODGE",
|
||||||
|
"STAINLESS_ARMOUR",
|
||||||
|
"EDIT_WANDS_EVERYWHERE",
|
||||||
|
"NO_WAND_EDITING",
|
||||||
|
"WAND_EXPERIMENTER",
|
||||||
|
"ADVENTURER",
|
||||||
|
"ABILITY_ACTIONS_MATERIALIZED",
|
||||||
|
"PROJECTILE_HOMING",
|
||||||
|
"PROJECTILE_HOMING_SHOOTER",
|
||||||
|
"UNLIMITED_SPELLS",
|
||||||
|
"FREEZE_FIELD",
|
||||||
|
"FIRE_GAS",
|
||||||
|
"DISSOLVE_POWDERS",
|
||||||
|
"BLEED_SLIME",
|
||||||
|
"BLEED_OIL",
|
||||||
|
"BLEED_GAS",
|
||||||
|
"SHIELD",
|
||||||
|
"REVENGE_EXPLOSION",
|
||||||
|
"REVENGE_TENTACLE",
|
||||||
|
"REVENGE_RATS",
|
||||||
|
"REVENGE_BULLET",
|
||||||
|
"ATTACK_FOOT",
|
||||||
|
"LEGGY_FEET",
|
||||||
|
"PLAGUE_RATS",
|
||||||
|
"VOMIT_RATS",
|
||||||
|
"CORDYCEPS",
|
||||||
|
"MOLD",
|
||||||
|
"WORM_SMALLER_HOLES",
|
||||||
|
"PROJECTILE_REPULSION",
|
||||||
|
"RISKY_CRITICAL",
|
||||||
|
"FUNGAL_DISEASE",
|
||||||
|
"PROJECTILE_SLOW_FIELD",
|
||||||
|
"PROJECTILE_REPULSION_SECTOR",
|
||||||
|
"PROJECTILE_EATER_SECTOR",
|
||||||
|
"ORBIT",
|
||||||
|
"ANGRY_GHOST",
|
||||||
|
"HUNGRY_GHOST",
|
||||||
|
"DEATH_GHOST",
|
||||||
|
"HOMUNCULUS",
|
||||||
|
"ELECTRICITY",
|
||||||
|
"ATTRACT_ITEMS",
|
||||||
|
"EXTRA_KNOCKBACK",
|
||||||
|
"LOWER_SPREAD",
|
||||||
|
"LOW_RECOIL",
|
||||||
|
"BOUNCE",
|
||||||
|
"FAST_PROJECTILES",
|
||||||
|
"ALWAYS_CAST",
|
||||||
|
"EXTRA_MANA",
|
||||||
|
"NO_MORE_SHUFFLE",
|
||||||
|
"NO_MORE_KNOCKBACK",
|
||||||
|
"DUPLICATE_PROJECTILE",
|
||||||
|
"FASTER_WANDS",
|
||||||
|
"EXTRA_SLOTS",
|
||||||
|
"CONTACT_DAMAGE",
|
||||||
|
"EXTRA_PERK",
|
||||||
|
"PERKS_LOTTERY",
|
||||||
|
"GAMBLE",
|
||||||
|
"EXTRA_SHOP_ITEM",
|
||||||
|
"GENOME_MORE_HATRED",
|
||||||
|
"GENOME_MORE_LOVE",
|
||||||
|
"PEACE_WITH_GODS",
|
||||||
|
"MANA_FROM_KILLS",
|
||||||
|
"ANGRY_LEVITATION",
|
||||||
|
"LASER_AIM",
|
||||||
|
"PERSONAL_LASER",
|
||||||
|
"MEGA_BEAM_STONE",
|
||||||
|
"IRON_STOMACH",
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Create your tests here.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Create your views here.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from '@hey-api/openapi-ts';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
input: `http://localhost:7777/api/openapi.json`,
|
||||||
|
output: 'src/api/',
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from .models import (
|
from opus_magnum.models import (
|
||||||
SteamAPIKey,
|
SteamAPIKey,
|
||||||
SteamCollection,
|
SteamCollection,
|
||||||
SteamCollectionItem,
|
SteamCollectionItem,
|
||||||
@@ -81,6 +81,7 @@ class SteamCollectionAdmin(admin.ModelAdmin):
|
|||||||
"current_favorites",
|
"current_favorites",
|
||||||
"last_fetched",
|
"last_fetched",
|
||||||
"is_active",
|
"is_active",
|
||||||
|
"accepting_submissions",
|
||||||
]
|
]
|
||||||
list_filter = ["is_active", "last_fetched", "created_at"]
|
list_filter = ["is_active", "last_fetched", "created_at"]
|
||||||
search_fields = ["title", "steam_id", "author_name", "description"]
|
search_fields = ["title", "steam_id", "author_name", "description"]
|
||||||
@@ -115,7 +116,7 @@ class SteamCollectionAdmin(admin.ModelAdmin):
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
("Status", {"fields": ("fetch_error",)}),
|
("Status", {"fields": ("fetch_error", "accepting_submissions")}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -148,6 +149,7 @@ class SteamCollectionItemAdmin(admin.ModelAdmin):
|
|||||||
("Author Information", {"fields": ("author_name", "author_steam_id")}),
|
("Author Information", {"fields": ("author_name", "author_steam_id")}),
|
||||||
("Metadata", {"fields": ("tags",)}),
|
("Metadata", {"fields": ("tags",)}),
|
||||||
("Timestamps", {"fields": ("created_at", "updated_at")}),
|
("Timestamps", {"fields": ("created_at", "updated_at")}),
|
||||||
|
("Points factor", {"fields": ("points_factor", "points_value")}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1,26 +1,40 @@
|
|||||||
from ninja import Router, File
|
from ninja import Router, File
|
||||||
from ninja.files import UploadedFile
|
from ninja.files import UploadedFile
|
||||||
from ninja.pagination import paginate
|
from ninja.pagination import paginate
|
||||||
|
from ninja.errors import HttpError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.http import Http404
|
from django.core.cache import cache
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from .models import Submission, PuzzleResponse, SubmissionFile, SteamCollectionItem
|
from games.decorators import require_game_enabled
|
||||||
|
from opus_magnum.utils import verify_and_validate_ocr_date_for_submission
|
||||||
|
from ninja.decorators import decorate_view
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
Submission,
|
||||||
|
PuzzleResponse,
|
||||||
|
SubmissionFile,
|
||||||
|
SteamCollectionItem,
|
||||||
|
SteamCollection,
|
||||||
|
)
|
||||||
from .schemas import (
|
from .schemas import (
|
||||||
SubmissionIn,
|
SubmissionIn,
|
||||||
SubmissionOut,
|
SubmissionOut,
|
||||||
PuzzleResponseOut,
|
PuzzleResponseOut,
|
||||||
ValidationIn,
|
ValidationIn,
|
||||||
SteamCollectionItemOut,
|
SteamCollectionItemOut,
|
||||||
|
SteamCollectionOut,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
OPUS_MAGNUM_APP_ID = 558990
|
||||||
|
|
||||||
|
|
||||||
@router.get("/puzzles", response=List[SteamCollectionItemOut])
|
@router.get("/puzzles", response=List[SteamCollectionItemOut])
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def list_puzzles(request):
|
def list_puzzles(request):
|
||||||
"""Get list of available puzzles"""
|
"""Get list of available puzzles"""
|
||||||
return SteamCollectionItem.objects.select_related("collection").filter(
|
return SteamCollectionItem.objects.select_related("collection").filter(
|
||||||
@@ -28,18 +42,26 @@ def list_puzzles(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/collection", response=SteamCollectionOut)
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
|
def get_collection(request):
|
||||||
|
"""Get the active collection details"""
|
||||||
|
collection = get_object_or_404(SteamCollection, is_active=True)
|
||||||
|
return collection
|
||||||
|
|
||||||
|
|
||||||
@router.get("/submissions", response=List[SubmissionOut])
|
@router.get("/submissions", response=List[SubmissionOut])
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
@paginate
|
@paginate
|
||||||
def list_submissions(request):
|
def list_submissions(request):
|
||||||
"""Get paginated list of submissions"""
|
"""Get paginated list of submissions"""
|
||||||
return (
|
return Submission.objects.prefetch_related(
|
||||||
Submission.objects.prefetch_related("responses__files", "responses__puzzle")
|
"responses__files", "responses__puzzle"
|
||||||
.filter(user=request.user)
|
).filter(user=request.user)
|
||||||
.filter()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/submissions/{submission_id}", response=SubmissionOut)
|
@router.get("/submissions/{submission_id}", response=SubmissionOut)
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def get_submission(request, submission_id: str):
|
def get_submission(request, submission_id: str):
|
||||||
"""Get detailed submission by ID"""
|
"""Get detailed submission by ID"""
|
||||||
return get_object_or_404(
|
return get_object_or_404(
|
||||||
@@ -51,6 +73,7 @@ def get_submission(request, submission_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/submissions", response=SubmissionOut)
|
@router.post("/submissions", response=SubmissionOut)
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def create_submission(
|
def create_submission(
|
||||||
request, data: SubmissionIn, files: List[UploadedFile] = File(...)
|
request, data: SubmissionIn, files: List[UploadedFile] = File(...)
|
||||||
):
|
):
|
||||||
@@ -65,23 +88,31 @@ def create_submission(
|
|||||||
if len(files) < len(data.responses):
|
if len(files) < len(data.responses):
|
||||||
return 400, {"detail": "Not enough files for all responses"}
|
return 400, {"detail": "Not enough files for all responses"}
|
||||||
|
|
||||||
print(data, files)
|
# Check if collection is accepting submissions
|
||||||
|
if data.responses:
|
||||||
|
for puzzle in data.responses:
|
||||||
|
if not get_object_or_404(
|
||||||
|
SteamCollectionItem, id=puzzle.puzzle_id
|
||||||
|
).collection.accepting_submissions:
|
||||||
|
raise HttpError(
|
||||||
|
403, "This tournament is no longer accepting submissions"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
# Check if any confidence score is below 50% to auto-request validation
|
# Check if any confidence score is below 80% to auto-request validation
|
||||||
auto_request_validation = any(
|
auto_request_validation = any(
|
||||||
(
|
(
|
||||||
response_data.ocr_confidence_cost is not None
|
response_data.ocr_confidence_cost is not None
|
||||||
and response_data.ocr_confidence_cost < 0.5
|
and response_data.ocr_confidence_cost < 0.8
|
||||||
)
|
)
|
||||||
or (
|
or (
|
||||||
response_data.ocr_confidence_cycles is not None
|
response_data.ocr_confidence_cycles is not None
|
||||||
and response_data.ocr_confidence_cycles < 0.5
|
and response_data.ocr_confidence_cycles < 0.8
|
||||||
)
|
)
|
||||||
or (
|
or (
|
||||||
response_data.ocr_confidence_area is not None
|
response_data.ocr_confidence_area is not None
|
||||||
and response_data.ocr_confidence_area < 0.5
|
and response_data.ocr_confidence_area < 0.8
|
||||||
)
|
)
|
||||||
for response_data in data.responses
|
for response_data in data.responses
|
||||||
)
|
)
|
||||||
@@ -97,12 +128,9 @@ def create_submission(
|
|||||||
file_index = 0
|
file_index = 0
|
||||||
for response_data in data.responses:
|
for response_data in data.responses:
|
||||||
# Get the puzzle
|
# Get the puzzle
|
||||||
try:
|
puzzle = get_object_or_404(
|
||||||
puzzle = SteamCollectionItem.objects.get(id=response_data.puzzle_id)
|
SteamCollectionItem, id=response_data.puzzle_id
|
||||||
except SteamCollectionItem.DoesNotExist:
|
)
|
||||||
return 400, {
|
|
||||||
"detail": f"Puzzle with id {response_data.puzzle_id} not found"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Create the puzzle response
|
# Create the puzzle response
|
||||||
response = PuzzleResponse.objects.create(
|
response = PuzzleResponse.objects.create(
|
||||||
@@ -116,6 +144,14 @@ def create_submission(
|
|||||||
ocr_confidence_cost=response_data.ocr_confidence_cost,
|
ocr_confidence_cost=response_data.ocr_confidence_cost,
|
||||||
ocr_confidence_cycles=response_data.ocr_confidence_cycles,
|
ocr_confidence_cycles=response_data.ocr_confidence_cycles,
|
||||||
ocr_confidence_area=response_data.ocr_confidence_area,
|
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
|
# Process files for this response
|
||||||
@@ -160,14 +196,17 @@ def create_submission(
|
|||||||
"responses__files", "responses__puzzle"
|
"responses__files", "responses__puzzle"
|
||||||
).get(id=submission.id)
|
).get(id=submission.id)
|
||||||
|
|
||||||
|
# Invalidate results cache on successful submission
|
||||||
|
cache.delete("api:results:results")
|
||||||
|
|
||||||
return submission
|
return submission
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
|
||||||
return 500, {"detail": f"Error creating submission: {str(e)}"}
|
return 500, {"detail": f"Error creating submission: {str(e)}"}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/responses/{response_id}/validate", response=PuzzleResponseOut)
|
@router.put("/responses/{response_id}/validate", response=PuzzleResponseOut)
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def validate_response(request, response_id: int, data: ValidationIn):
|
def validate_response(request, response_id: int, data: ValidationIn):
|
||||||
"""Manually validate a puzzle response"""
|
"""Manually validate a puzzle response"""
|
||||||
|
|
||||||
@@ -196,10 +235,30 @@ def validate_response(request, response_id: int, data: ValidationIn):
|
|||||||
|
|
||||||
response.save()
|
response.save()
|
||||||
|
|
||||||
|
# Invalidate results cache when a response is validated
|
||||||
|
cache.delete("api:results:results")
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/responses/{response_id}/validate/auto", response=PuzzleResponseOut)
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
|
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:
|
||||||
|
return 403, {"detail": "Admin access required"}
|
||||||
|
|
||||||
|
response = get_object_or_404(PuzzleResponse, id=response_id)
|
||||||
|
|
||||||
|
for file in response.files.all():
|
||||||
|
verify_and_validate_ocr_date_for_submission(file)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.get("/responses/needs-validation", response=List[PuzzleResponseOut])
|
@router.get("/responses/needs-validation", response=List[PuzzleResponseOut])
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def list_responses_needing_validation(request):
|
def list_responses_needing_validation(request):
|
||||||
"""Get all responses that need manual validation"""
|
"""Get all responses that need manual validation"""
|
||||||
|
|
||||||
@@ -215,6 +274,7 @@ def list_responses_needing_validation(request):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/submissions/{submission_id}/validate", response=SubmissionOut)
|
@router.post("/submissions/{submission_id}/validate", response=SubmissionOut)
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def validate_submission(request, submission_id: str):
|
def validate_submission(request, submission_id: str):
|
||||||
"""Mark entire submission as validated"""
|
"""Mark entire submission as validated"""
|
||||||
|
|
||||||
@@ -236,10 +296,14 @@ def validate_submission(request, submission_id: str):
|
|||||||
"responses__files", "responses__puzzle"
|
"responses__files", "responses__puzzle"
|
||||||
).get(id=submission.id)
|
).get(id=submission.id)
|
||||||
|
|
||||||
|
# Invalidate results cache when submission is validated
|
||||||
|
cache.delete("api:results:results")
|
||||||
|
|
||||||
return submission
|
return submission
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/submissions/{submission_id}")
|
@router.delete("/submissions/{submission_id}")
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def delete_submission(request, submission_id: str):
|
def delete_submission(request, submission_id: str):
|
||||||
"""Delete a submission (admin only)"""
|
"""Delete a submission (admin only)"""
|
||||||
|
|
||||||
@@ -248,10 +312,15 @@ def delete_submission(request, submission_id: str):
|
|||||||
|
|
||||||
submission = get_object_or_404(Submission, id=submission_id)
|
submission = get_object_or_404(Submission, id=submission_id)
|
||||||
submission.delete()
|
submission.delete()
|
||||||
|
|
||||||
|
# Invalidate results cache when submission is deleted
|
||||||
|
cache.delete("api:results:results")
|
||||||
|
|
||||||
return {"detail": "Submission deleted successfully"}
|
return {"detail": "Submission deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def get_stats(request):
|
def get_stats(request):
|
||||||
"""Get submission statistics"""
|
"""Get submission statistics"""
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class OpusMagnumConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "opus_magnum"
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user