tournament outputs

This commit is contained in:
Loïc Gremaud 2026-05-22 06:18:11 +02:00
parent bf21a5eae6
commit 92dddca964
Signed by: Legrems
GPG Key ID: D4620E6DF3E0121D
21 changed files with 925 additions and 69 deletions

View File

@ -1,11 +1,25 @@
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
from submissions.models import PuzzleResponse, SteamCollectionItem
from animations.schemas import (
RankingSchema,
TournamentWinnersOut,
TournamentSubmissionsOut,
TournamentPuzzleResultsOut,
PuzzleWinnerOut,
PuzzleSubmissionsOut,
PuzzleResultsOut,
PuzzleSubmissionWithRankOut,
PuzzlePointsFactorOut,
WinnerResponseOut,
WinnerFileOut,
)
from submissions.models import PuzzleResponse, SteamCollectionItem, SteamCollection
router = Router()
@ -46,3 +60,218 @@ def results(request: HttpRequest) -> dict:
cache.set("api:results:results", data, 300)
return data
@router.get("winners", response=TournamentWinnersOut)
def winners(request: HttpRequest) -> TournamentWinnersOut:
"""Get tournament winners with submission file URLs. Only available when tournament is closed."""
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")
# Get best response for each puzzle
winners_by_puzzle = {}
for puzzle in puzzles:
# Get the best response for this puzzle (ranked by points)
best_response = (
PuzzleResponse.objects
.filter(puzzle=puzzle, needs_manual_validation=False)
.filter_user_best_response()
.annotate_rank_points()
.order_by("rank_points")
.first()
)
if best_response:
winners_by_puzzle[puzzle.id] = best_response
# Build response
winners_list = []
for puzzle in puzzles:
best_response = winners_by_puzzle.get(puzzle.id)
winner_data = None
if best_response:
# Get submission files
files = best_response.files.all()
winner_files = [
WinnerFileOut(
file_url=file.file_url or "",
original_filename=file.original_filename
)
for file in files
]
winner_data = WinnerResponseOut(
user_id=best_response.submission.user.id if best_response.submission.user else 0,
username=best_response.submission.user.username if best_response.submission.user else "Anonymous",
final_cost=best_response.final_cost,
final_cycles=best_response.final_cycles,
final_area=best_response.final_area,
rank_points=best_response.rank_points,
files=winner_files,
)
winners_list.append(
PuzzleWinnerOut(
puzzle_id=puzzle.id,
puzzle_title=puzzle.title,
winner=winner_data,
)
)
return TournamentWinnersOut(winners=winners_list)
@router.get("top-submissions", response=TournamentSubmissionsOut)
def top_submissions(request: HttpRequest, limit: int = 5) -> TournamentSubmissionsOut:
"""Get tournament top submissions for each puzzle. Only available when tournament is closed."""
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)
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,
)
)
return TournamentSubmissionsOut(submissions=submissions_list)
@router.get("puzzle-results", response=TournamentPuzzleResultsOut)
def puzzle_results(request: HttpRequest, limit: int = 5) -> TournamentPuzzleResultsOut:
"""Get tournament results organized by puzzle with coefficients. Only available when tournament is closed."""
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,
)
)
return TournamentPuzzleResultsOut(results=results_list)

View File

@ -1,4 +1,5 @@
from ninja import ModelSchema, Schema
from typing import List, Optional
from submissions.models import PuzzleResponse
from submissions.schemas import SteamCollectionItemOut
@ -41,3 +42,88 @@ class RankingSchema(Schema):
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 PuzzleWinnerOut(Schema):
"""Schema for puzzle with winner info"""
puzzle_id: int
puzzle_title: str
winner: Optional[WinnerResponseOut]
class PuzzleSubmissionsOut(Schema):
"""Schema for puzzle with all top submissions"""
puzzle_id: int
puzzle_title: str
submissions: List[WinnerResponseOut]
class TournamentWinnersOut(Schema):
"""Schema for tournament winners results"""
winners: List[PuzzleWinnerOut]
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]

View File

@ -4,10 +4,12 @@ import PuzzleCard from "@/components/PuzzleCard.vue";
import SubmissionForm from "@/components/SubmissionForm.vue";
import AdminPanel from "@/components/AdminPanel.vue";
import Results from "@/components/Results.vue";
import Winners from "@/components/Winners.vue";
import PuzzleResults from "@/components/PuzzleResults.vue";
import { apiService, errorHelpers } from "@/services/apiService";
import { usePuzzlesStore } from "@/stores/puzzles";
import { useSubmissionsStore } from "@/stores/submissions";
import type { PuzzleResponse, UserInfo } from "@/types";
import type { PuzzleResponse, UserInfo, SteamCollection } from "@/types";
import { useCountdown } from "@vueuse/core";
import { storeToRefs } from "pinia";
@ -26,6 +28,7 @@ const { openSubmissionModal, loadSubmissions, closeSubmissionModal } =
// Local state
const userInfo = ref<UserInfo | null>(null);
const collection = ref<SteamCollection | null>(null);
const isLoading = ref(true);
const error = ref<string>("");
@ -34,6 +37,10 @@ const isSuperuser = computed(() => {
return userInfo.value?.is_superuser || false;
});
const isTournamentClosed = computed(() => {
return !!(collection.value && !collection.value.accepting_submissions);
});
// Computed property to get responses grouped by puzzle
const responsesByPuzzle = computed(() => {
const grouped: Record<number, PuzzleResponse[]> = {};
@ -66,6 +73,16 @@ async function initialize() {
console.warn("User info error:", userResponse.error);
}
// Load collection data
console.log("Loading collection...");
const collectionResponse = await apiService.getCollection();
if (collectionResponse.data) {
collection.value = collectionResponse.data;
console.log("Collection loaded:", collectionResponse.data);
} else if (collectionResponse.error) {
console.warn("Collection error:", collectionResponse.error);
}
// Load puzzles from API using store
console.log("Loading puzzles...");
await puzzlesStore.loadPuzzles();
@ -173,6 +190,15 @@ const goHome = () => {
<!-- Main Content -->
<div v-else class="space-y-8">
<!-- Winners Section (only when tournament is closed) -->
<div v-if="isTournamentClosed" class="space-y-8">
<Winners />
<PuzzleResults />
</div>
<template v-else>
<!-- Collection Info -->
<div class="mb-8">
<div class="card bg-base-100 shadow-lg">
@ -182,11 +208,18 @@ const goHome = () => {
{{ props.collectionDescription }}
</p>
<div class="flex flex-wrap gap-4 mt-4">
<button @click="openSubmissionModal" class="btn btn-primary">
<button @click="openSubmissionModal" class="btn btn-primary" :disabled="isTournamentClosed">
<i class="mdi mdi-plus mr-2"></i>
Submit Solution
</button>
</div>
<div v-if="isTournamentClosed" class="alert alert-warning mt-4">
<i class="mdi mdi-alert-circle text-xl"></i>
<div>
<h3 class="font-bold">Tournament Closed</h3>
<div class="text-sm">This tournament is no longer accepting new submissions.</div>
</div>
</div>
</div>
</div>
</div>
@ -212,6 +245,7 @@ const goHome = () => {
Check back later for new puzzle collections!
</p>
</div>
</template>
</div>
</div>

View File

@ -0,0 +1,184 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { apiService } from "@/services/apiService";
import type { TournamentPuzzleResults } from "@/types";
const isLoading = ref(true);
const resultsData = ref<TournamentPuzzleResults | null>(null);
const error = ref<string>("");
// Modal state
const showImageModal = ref(false);
const selectedImageUrl = ref<string>("");
const selectedImageName = ref<string>("");
const fetchResults = async () => {
isLoading.value = true;
error.value = "";
try {
const response = await apiService.getPuzzleResults(5);
if (response.data) {
resultsData.value = response.data;
} else if (response.error) {
error.value = response.error;
console.error("Error fetching results:", response.error);
}
} catch (err) {
error.value = err instanceof Error ? err.message : "Failed to fetch results";
console.error("Error fetching results:", err);
} finally {
isLoading.value = false;
}
};
const formatNumber = (num: number | undefined) => {
return num !== undefined ? num.toLocaleString() : "—";
};
const openImageModal = (fileUrl: string, fileName: string) => {
selectedImageUrl.value = fileUrl;
selectedImageName.value = fileName;
showImageModal.value = true;
};
const closeImageModal = () => {
showImageModal.value = false;
selectedImageUrl.value = "";
selectedImageName.value = "";
};
const getRankBadge = (rank: number) => {
const badges = ["🥇", "🥈", "🥉"];
return badges[rank - 1] || `#${rank}`;
};
onMounted(() => {
fetchResults();
});
</script>
<template>
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<h2 class="card-title text-2xl flex items-center gap-2">
<i class="mdi mdi-table text-blue-500 text-3xl"></i>
Results by Puzzle
</h2>
<div v-if="isLoading" class="flex justify-center py-12">
<span class="loading loading-spinner loading-lg"></span>
</div>
<div v-else-if="error" class="alert alert-error">
<i class="mdi mdi-alert-circle text-xl"></i>
<div>{{ error }}</div>
</div>
<div v-else-if="!resultsData || resultsData.results.length === 0" class="text-center py-8">
<p class="text-base-content/70">No results available yet.</p>
</div>
<div v-else class="space-y-8">
<div v-for="puzzle in resultsData.results" :key="puzzle.puzzle_id" class="border-b pb-8 last:border-b-0">
<!-- Puzzle Header with Coefficients -->
<div class="mb-4">
<div class="bg-base-200 p-3 rounded-lg mb-4" v-if="puzzle.points_factor">
<p class="text-xs text-base-content/70 font-semibold mb-2">Points Coefficients</p>
<div class="grid grid-cols-3 gap-2">
<div class="text-center">
<span class="font-bold text-primary"><small>x</small>{{ puzzle.points_factor.cost }}</span>
<p class="text-xs text-base-content/70">Cost</p>
</div>
<div class="text-center">
<span class="font-bold text-primary"><small>x</small>{{ puzzle.points_factor.cycles }}</span>
<p class="text-xs text-base-content/70">Cycles</p>
</div>
<div class="text-center">
<span class="font-bold text-primary"><small>x</small>{{ puzzle.points_factor.area }}</span>
<p class="text-xs text-base-content/70">Area</p>
</div>
</div>
</div>
</div>
<!-- Results Table -->
<div v-if="puzzle.submissions.length > 0" class="overflow-x-auto">
<table class="table table-sm table-zebra">
<thead>
<tr>
<th class="w-12 text-center">Pos</th>
<th>User</th>
<th class="text-right">Cost</th>
<th class="text-right">Cycles</th>
<th class="text-right">Area</th>
<th class="text-right font-bold">Total Pts</th>
<th class="text-right font-bold">Total Coef</th>
<th class="text-center">GIF</th>
</tr>
</thead>
<tbody>
<tr v-for="submission in puzzle.submissions" :key="`${puzzle.puzzle_id}-${submission.user_id}`">
<td class="text-center text-lg font-bold">
{{ getRankBadge(submission.rank) }}
</td>
<td class="font-semibold">{{ submission.username }}</td>
<td class="text-right">{{ formatNumber(submission.final_cost) }}</td>
<td class="text-right">{{ formatNumber(submission.final_cycles) }}</td>
<td class="text-right">{{ formatNumber(submission.final_area) }}</td>
<td class="text-right font-bold" :class="{
'text-yellow-600': submission.rank === 1,
'text-gray-600': submission.rank === 2,
'text-orange-600': submission.rank === 3,
}">
{{ formatNumber(submission.rank_points) }}
</td>
<td class="text-right font-bold text-primary">
{{ formatNumber(submission.total_coef) }}
</td>
<td class="text-center">
<button v-if="submission.files.length > 0"
@click="openImageModal(submission.files[0].file_url, submission.files[0].original_filename)"
class="btn btn-xs btn-primary gap-1">
<i class="mdi mdi-image"></i>
View
</button>
<span v-else class="text-base-content/50"></span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="p-4 bg-base-200 rounded-lg text-center text-base-content/70">
No submissions yet
</div>
</div>
</div>
</div>
</div>
<!-- Image Modal -->
<div v-if="showImageModal" class="modal modal-open">
<div class="modal-box max-w-7xl w-full">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-lg">{{ selectedImageName }}</h3>
<button @click="closeImageModal" class="btn btn-sm btn-circle btn-ghost">
<i class="mdi mdi-close"></i>
</button>
</div>
<div class="flex justify-center bg-base-200 rounded-lg p-4">
<img :src="selectedImageUrl" :alt="selectedImageName" class="object-contain" />
</div>
<div class="modal-action mt-4">
<a :href="selectedImageUrl" target="_blank" class="btn btn-primary btn-sm">
<i class="mdi mdi-download"></i>
Download
</a>
<button @click="closeImageModal" class="btn btn-sm">Close</button>
</div>
</div>
<div class="modal-backdrop" @click="closeImageModal"></div>
</div>
</template>

View File

@ -0,0 +1,171 @@
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { apiService } from "@/services/apiService";
import type { TournamentSubmissions, WinnerResponse, PuzzleSubmissions } from "@/types";
const isLoading = ref(true);
const submissionsData = ref<TournamentSubmissions | null>(null);
const error = ref<string>("");
// Modal state
const showImageModal = ref(false);
const selectedImageUrl = ref<string>("");
const selectedImageName = ref<string>("");
const fetchSubmissions = async () => {
isLoading.value = true;
error.value = "";
try {
const response = await apiService.getTopSubmissions(5);
if (response.data) {
submissionsData.value = response.data;
} else if (response.error) {
error.value = response.error;
console.error("Error fetching submissions:", response.error);
}
} catch (err) {
error.value = err instanceof Error ? err.message : "Failed to fetch submissions";
console.error("Error fetching submissions:", err);
} finally {
isLoading.value = false;
}
};
const formatNumber = (num: number | undefined) => {
return num !== undefined ? num.toLocaleString() : "—";
};
// Flatten all submissions into a single table
const flattenedRows = computed(() => {
if (!submissionsData.value) return [];
const rows: Array<{
puzzleName: string;
username: string;
cost: number | undefined;
cycles: number | undefined;
area: number | undefined;
total: number | undefined;
totalCoef: number | undefined;
files: Array<{ url: string; name: string }>;
}> = [];
submissionsData.value.submissions.forEach((puzzle: PuzzleSubmissions) => {
puzzle.submissions.forEach((submission: WinnerResponse) => {
rows.push({
puzzleName: puzzle.puzzle_title,
username: submission.username,
cost: submission.final_cost,
cycles: submission.final_cycles,
area: submission.final_area,
total: submission.rank_points,
totalCoef: submission.total_coef,
files: submission.files.map(f => ({ url: f.file_url, name: f.original_filename })),
});
});
});
return rows;
});
const openImageModal = (fileUrl: string, fileName: string) => {
selectedImageUrl.value = fileUrl;
selectedImageName.value = fileName;
showImageModal.value = true;
};
const closeImageModal = () => {
showImageModal.value = false;
selectedImageUrl.value = "";
selectedImageName.value = "";
};
onMounted(() => {
fetchSubmissions();
});
</script>
<template>
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<h2 class="card-title text-2xl flex items-center gap-2">
<i class="mdi mdi-trophy text-yellow-500 text-3xl"></i>
Top Submissions
</h2>
<div v-if="isLoading" class="flex justify-center py-12">
<span class="loading loading-spinner loading-lg"></span>
</div>
<div v-else-if="error" class="alert alert-error">
<i class="mdi mdi-alert-circle text-xl"></i>
<div>{{ error }}</div>
</div>
<div v-else-if="flattenedRows.length === 0" class="text-center py-8">
<p class="text-base-content/70">No results available yet.</p>
</div>
<div v-else class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Puzzle</th>
<th>User</th>
<th class="text-right">Cost</th>
<th class="text-right">Cycles</th>
<th class="text-right">Area</th>
<th class="text-right">Total Pts</th>
<th class="text-right">Total Coef</th>
<th class="text-center">GIF</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in flattenedRows" :key="index">
<td class="font-semibold">{{ row.puzzleName }}</td>
<td>{{ row.username }}</td>
<td class="text-right">{{ formatNumber(row.cost) }}</td>
<td class="text-right">{{ formatNumber(row.cycles) }}</td>
<td class="text-right">{{ formatNumber(row.area) }}</td>
<td class="text-right font-bold">{{ formatNumber(row.total) }}</td>
<td class="text-right font-bold text-primary">{{ formatNumber(row.totalCoef) }}</td>
<td class="text-center">
<button v-if="row.files.length > 0" @click="openImageModal(row.files[0].url, row.files[0].name)"
class="btn btn-xs btn-primary gap-1">
<i class="mdi mdi-image"></i>
View
</button>
<span v-else class="text-base-content/50"></span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Image Modal -->
<div v-if="showImageModal" class="modal modal-open">
<div class="modal-box max-w-7xl w-full">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-lg">{{ selectedImageName }}</h3>
<button @click="closeImageModal" class="btn btn-sm btn-circle btn-ghost">
<i class="mdi mdi-close"></i>
</button>
</div>
<div class="flex justify-center bg-base-200 rounded-lg p-4">
<img :src="selectedImageUrl" :alt="selectedImageName" class="object-contain" />
</div>
<div class="modal-action mt-4">
<a :href="selectedImageUrl" target="_blank" class="btn btn-primary btn-sm">
<i class="mdi mdi-download"></i>
Download
</a>
<button @click="closeImageModal" class="btn btn-sm">Close</button>
</div>
</div>
<div class="modal-backdrop" @click="closeImageModal"></div>
</div>
</template>

View File

@ -1,9 +1,13 @@
import type {
SteamCollection,
SteamCollectionItem,
Submission,
PuzzleResponse,
SubmissionFile,
UserInfo
UserInfo,
TournamentWinners,
TournamentSubmissions,
TournamentPuzzleResults
} from '../types'
// API Configuration
@ -101,6 +105,22 @@ export class ApiService {
return this.request<SteamCollectionItem[]>('/submissions/puzzles')
}
async getCollection(): Promise<ApiResponse<SteamCollection>> {
return this.request<SteamCollection>('/submissions/collection')
}
async getWinners(): Promise<ApiResponse<TournamentWinners>> {
return this.request<TournamentWinners>('/results/winners')
}
async getTopSubmissions(limit = 5): Promise<ApiResponse<TournamentSubmissions>> {
return this.request<TournamentSubmissions>(`/results/top-submissions?limit=${limit}`)
}
async getPuzzleResults(limit = 5): Promise<ApiResponse<TournamentPuzzleResults>> {
return this.request<TournamentPuzzleResults>(`/results/puzzle-results?limit=${limit}`)
}
// Submission endpoints
async getSubmissions(limit = 20, offset = 0): Promise<ApiResponse<PaginatedResponse<Submission>>> {
return this.request<PaginatedResponse<Submission>>(

View File

@ -7,6 +7,7 @@ export interface SteamCollection {
total_items: number
unique_visitors: number
current_favorites: number
accepting_submissions: boolean
created_at: string
updated_at: string
}
@ -107,3 +108,68 @@ export interface UserInfo {
is_superuser: boolean
cas_groups?: string[]
}
export interface WinnerFile {
file_url: string
original_filename: string
}
export interface WinnerResponse {
user_id: number
username: string
final_cost?: number
final_cycles?: number
final_area?: number
rank_points?: number
total_coef?: number
files: WinnerFile[]
}
export interface PuzzleWinner {
puzzle_id: number
puzzle_title: string
winner?: WinnerResponse
}
export interface TournamentWinners {
winners: PuzzleWinner[]
}
export interface PuzzleSubmissions {
puzzle_id: number
puzzle_title: string
submissions: WinnerResponse[]
}
export interface TournamentSubmissions {
submissions: PuzzleSubmissions[]
}
export interface PuzzlePointsFactor {
cost: number
cycles: number
area: number
}
export interface PuzzleSubmissionWithRank {
rank: number
user_id: number
username: string
final_cost?: number
final_cycles?: number
final_area?: number
rank_points?: number
total_coef?: number
files: WinnerFile[]
}
export interface PuzzleResults {
puzzle_id: number
puzzle_title: string
points_factor?: PuzzlePointsFactor
submissions: PuzzleSubmissionWithRank[]
}
export interface TournamentPuzzleResults {
results: PuzzleResults[]
}

View File

@ -1 +1 @@
import{k as t,l as a,p as n,v as s}from"./style-CufywNmO.js";const c={key:0,class:"flex justify-center"},k={key:0,class:"badge badge-warning badge-lg"},d={key:1,class:"badge badge-lg"},l={key:2,class:"badge badge-lg"},o={key:3,class:"badge badge-lg"},g={key:1,class:"text-2xl text-base-content/50"},y=t({__name:"RankBadge",props:{rank:{}},setup(e){return(i,r)=>e.rank!==null?(n(),a("div",c,[e.rank===1?(n(),a("span",k," 🏆 #"+s(e.rank),1)):e.rank===2?(n(),a("span",d," 🥈 #"+s(e.rank),1)):e.rank===3?(n(),a("span",l," 🥉 #"+s(e.rank),1)):(n(),a("span",o," #"+s(e.rank),1))])):(n(),a("div",g," No rank yet "))}});export{y as _};
import{k as t,l as a,p as n,v as s}from"./style-C433w8gz.js";const c={key:0,class:"flex justify-center"},k={key:0,class:"badge badge-warning badge-lg"},d={key:1,class:"badge badge-lg"},l={key:2,class:"badge badge-lg"},o={key:3,class:"badge badge-lg"},g={key:1,class:"text-2xl text-base-content/50"},y=t({__name:"RankBadge",props:{rank:{}},setup(e){return(i,r)=>e.rank!==null?(n(),a("div",c,[e.rank===1?(n(),a("span",k," 🏆 #"+s(e.rank),1)):e.rank===2?(n(),a("span",d," 🥈 #"+s(e.rank),1)):e.rank===3?(n(),a("span",l," 🥉 #"+s(e.rank),1)):(n(),a("span",o," #"+s(e.rank),1))])):(n(),a("div",g," No rank yet "))}});export{y as _};

View File

@ -1 +1 @@
import{k as b,c as v,r as g,l as a,p as n,s as t,F as h,y as x,v as i,x as f,O as _}from"./style-CufywNmO.js";const y={class:"min-h-screen bg-base-300 flex items-center justify-center px-4"},w={class:"w-full max-w-6xl"},k={class:"grid grid-cols-1 md:grid-cols-2 gap-8"},S=["onClick"],I={class:"relative h-60 bg-base-300 overflow-hidden"},j=["src","alt","onError"],E={key:1,class:"w-full h-full bg-gradient-to-br from-blue-600 to-blue-400 flex items-center justify-center text-white"},N={class:"card-body"},C={class:"card-title text-2xl"},B={class:"text-base-content/70"},O=b({__name:"Home",setup(A){const c=v(()=>[{id:"opus-magnum",title:"Opus Magnum",description:"Submit your best Opus Magnum puzzle solutions",appId:558990,path:"/opus-magnum"},{id:"noita",title:"Noita",description:"Submit your greatest Noita achievements",appId:881100,path:"/noita"}]),r=g(new Set),d=o=>`https://cdn.akamai.steamstatic.com/steam/apps/${o}/header.jpg`,u=o=>{r.value.add(o)},p=o=>{window.location.href=o};return(o,e)=>(n(),a("div",y,[t("div",w,[e[3]||(e[3]=t("div",{class:"text-center mb-12"},[t("h1",{class:"text-5xl font-bold mb-4"},"PolyLAN Submitter"),t("p",{class:"text-xl text-base-content/70"}," Choose a game and submit your best solutions ")],-1)),t("div",k,[(n(!0),a(h,null,x(c.value,s=>(n(),a("div",{key:s.id,onClick:m=>p(s.path),class:"card card-xl bg-base-200 shadow-xl hover:shadow-2xl transition-all cursor-pointer transform hover:-translate-y-2 hover:scale-[1.05] hover:bg-base-100 overflow-hidden"},[t("figure",I,[r.value.has(s.appId)?(n(),a("div",E,[...e[0]||(e[0]=[t("i",{class:"mdi mdi-gamepad-variant text-5xl"},null,-1)])])):(n(),a("img",{key:0,src:d(s.appId),alt:s.title,onError:m=>u(s.appId),class:"w-full h-full object-cover"},null,40,j)),e[1]||(e[1]=t("div",{class:"absolute inset-0 bg-black/30 group-hover:bg-black/20 transition-colors"},null,-1))]),t("div",N,[t("h2",C,i(s.title),1),t("p",B,i(s.description),1),e[2]||(e[2]=t("div",{class:"card-actions justify-end mt-4"},[t("button",{class:"btn btn-primary"},[t("i",{class:"mdi mdi-arrow-right mr-2"}),f(" Submit results ")])],-1))])],8,S))),128))]),e[4]||(e[4]=t("div",{class:"text-center mt-12 text-base-content/50"},[t("p",null,"Select a game above to begin submitting")],-1))])]))}}),l="#app",$=document.querySelector(l),z=_(O,{...$?.dataset});z.mount(l);
import{k as b,c as v,r as g,l as a,p as n,s as t,F as h,y as x,v as i,x as f,O as _}from"./style-C433w8gz.js";const y={class:"min-h-screen bg-base-300 flex items-center justify-center px-4"},w={class:"w-full max-w-6xl"},k={class:"grid grid-cols-1 md:grid-cols-2 gap-8"},S=["onClick"],I={class:"relative h-60 bg-base-300 overflow-hidden"},j=["src","alt","onError"],E={key:1,class:"w-full h-full bg-gradient-to-br from-blue-600 to-blue-400 flex items-center justify-center text-white"},N={class:"card-body"},C={class:"card-title text-2xl"},B={class:"text-base-content/70"},O=b({__name:"Home",setup(A){const c=v(()=>[{id:"opus-magnum",title:"Opus Magnum",description:"Submit your best Opus Magnum puzzle solutions",appId:558990,path:"/opus-magnum"},{id:"noita",title:"Noita",description:"Submit your greatest Noita achievements",appId:881100,path:"/noita"}]),r=g(new Set),d=o=>`https://cdn.akamai.steamstatic.com/steam/apps/${o}/header.jpg`,u=o=>{r.value.add(o)},p=o=>{window.location.href=o};return(o,e)=>(n(),a("div",y,[t("div",w,[e[3]||(e[3]=t("div",{class:"text-center mb-12"},[t("h1",{class:"text-5xl font-bold mb-4"},"PolyLAN Submitter"),t("p",{class:"text-xl text-base-content/70"}," Choose a game and submit your best solutions ")],-1)),t("div",k,[(n(!0),a(h,null,x(c.value,s=>(n(),a("div",{key:s.id,onClick:m=>p(s.path),class:"card card-xl bg-base-200 shadow-xl hover:shadow-2xl transition-all cursor-pointer transform hover:-translate-y-2 hover:scale-[1.05] hover:bg-base-100 overflow-hidden"},[t("figure",I,[r.value.has(s.appId)?(n(),a("div",E,[...e[0]||(e[0]=[t("i",{class:"mdi mdi-gamepad-variant text-5xl"},null,-1)])])):(n(),a("img",{key:0,src:d(s.appId),alt:s.title,onError:m=>u(s.appId),class:"w-full h-full object-cover"},null,40,j)),e[1]||(e[1]=t("div",{class:"absolute inset-0 bg-black/30 group-hover:bg-black/20 transition-colors"},null,-1))]),t("div",N,[t("h2",C,i(s.title),1),t("p",B,i(s.description),1),e[2]||(e[2]=t("div",{class:"card-actions justify-end mt-4"},[t("button",{class:"btn btn-primary"},[t("i",{class:"mdi mdi-arrow-right mr-2"}),f(" Submit results ")])],-1))])],8,S))),128))]),e[4]||(e[4]=t("div",{class:"text-center mt-12 text-base-content/50"},[t("p",null,"Select a game above to begin submitting")],-1))])]))}}),l="#app",$=document.querySelector(l),z=_(O,{...$?.dataset});z.mount(l);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,16 +1,16 @@
{
"_RankBadge.vue_vue_type_script_setup_true_lang-C5K0DD3W.js": {
"file": "assets/RankBadge.vue_vue_type_script_setup_true_lang-C5K0DD3W.js",
"_RankBadge.vue_vue_type_script_setup_true_lang-IllWPCyW.js": {
"file": "assets/RankBadge.vue_vue_type_script_setup_true_lang-IllWPCyW.js",
"name": "RankBadge.vue_vue_type_script_setup_true_lang",
"imports": [
"_style-CufywNmO.js"
"_style-C433w8gz.js"
]
},
"_style-CufywNmO.js": {
"file": "assets/style-CufywNmO.js",
"_style-C433w8gz.js": {
"file": "assets/style-C433w8gz.js",
"name": "style",
"css": [
"assets/style-DaHD49X0.css"
"assets/style-CMHHCLN4.css"
],
"assets": [
"assets/materialdesignicons-webfont-CSr8KVlo.eot",
@ -19,9 +19,9 @@
"assets/materialdesignicons-webfont-B7mPwVP_.ttf"
]
},
"_style-DaHD49X0.css": {
"file": "assets/style-DaHD49X0.css",
"src": "_style-DaHD49X0.css"
"_style-CMHHCLN4.css": {
"file": "assets/style-CMHHCLN4.css",
"src": "_style-CMHHCLN4.css"
},
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot": {
"file": "assets/materialdesignicons-webfont-CSr8KVlo.eot",
@ -40,32 +40,32 @@
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2"
},
"src/home.ts": {
"file": "assets/home-2m6DwiDu.js",
"file": "assets/home-f2CGvY1q.js",
"name": "home",
"src": "src/home.ts",
"isEntry": true,
"imports": [
"_style-CufywNmO.js"
"_style-C433w8gz.js"
]
},
"src/noita.ts": {
"file": "assets/noita-C7qVNYuP.js",
"file": "assets/noita-BcY9X9eS.js",
"name": "noita",
"src": "src/noita.ts",
"isEntry": true,
"imports": [
"_style-CufywNmO.js",
"_RankBadge.vue_vue_type_script_setup_true_lang-C5K0DD3W.js"
"_style-C433w8gz.js",
"_RankBadge.vue_vue_type_script_setup_true_lang-IllWPCyW.js"
]
},
"src/opus-magnum.ts": {
"file": "assets/opus_magnum-CgBh_a7R.js",
"file": "assets/opus_magnum-CCZTjOvR.js",
"name": "opus_magnum",
"src": "src/opus-magnum.ts",
"isEntry": true,
"imports": [
"_style-CufywNmO.js",
"_RankBadge.vue_vue_type_script_setup_true_lang-C5K0DD3W.js"
"_style-C433w8gz.js",
"_RankBadge.vue_vue_type_script_setup_true_lang-IllWPCyW.js"
]
}
}

View File

@ -81,6 +81,7 @@ class SteamCollectionAdmin(admin.ModelAdmin):
"current_favorites",
"last_fetched",
"is_active",
"accepting_submissions",
]
list_filter = ["is_active", "last_fetched", "created_at"]
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")}),
)

View File

@ -1,6 +1,7 @@
from ninja import Router, File
from ninja.files import UploadedFile
from ninja.pagination import paginate
from ninja.errors import HttpError
from django.db import transaction
from django.core.files.base import ContentFile
from django.core.cache import cache
@ -10,13 +11,20 @@ from typing import List
from submissions.utils import verify_and_validate_ocr_date_for_submission
from .models import Submission, PuzzleResponse, SubmissionFile, SteamCollectionItem
from .models import (
Submission,
PuzzleResponse,
SubmissionFile,
SteamCollectionItem,
SteamCollection,
)
from .schemas import (
SubmissionIn,
SubmissionOut,
PuzzleResponseOut,
ValidationIn,
SteamCollectionItemOut,
SteamCollectionOut,
)
router = Router()
@ -30,6 +38,13 @@ def list_puzzles(request):
)
@router.get("/collection", response=SteamCollectionOut)
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])
@paginate
def list_submissions(request):
@ -65,7 +80,15 @@ def create_submission(
if len(files) < len(data.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:
with transaction.atomic():
@ -126,7 +149,6 @@ def create_submission(
# Process files for this response
# For simplicity, we'll take one file per response
# In a real implementation, you'd need better file-to-response mapping
print("FI", file_index, files)
if file_index < len(files):
uploaded_file = files[file_index]
@ -172,7 +194,6 @@ def create_submission(
return submission
except Exception as e:
print(e)
return 500, {"detail": f"Error creating submission: {str(e)}"}

View File

@ -0,0 +1,21 @@
# Generated by Django 5.2.7 on 2026-05-21 10:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("submissions", "0013_steamcollectionitem_points_value"),
]
operations = [
migrations.AddField(
model_name="steamcollection",
name="accepting_submissions",
field=models.BooleanField(
default=True,
help_text="Whether the tournament is accepting new submissions",
),
),
]

View File

@ -157,6 +157,9 @@ class SteamCollection(models.Model):
is_active = models.BooleanField(
default=True, help_text="Whether this collection is actively tracked"
)
accepting_submissions = models.BooleanField(
default=True, help_text="Whether the tournament is accepting new submissions"
)
fetch_error = models.TextField(
blank=True, help_text="Last error encountered when fetching data"
)

View File

@ -3,7 +3,7 @@ from typing import List, Optional
from datetime import datetime
from uuid import UUID
from .models import Submission, PuzzleResponse, SubmissionFile, SteamCollectionItem
from .models import Submission, PuzzleResponse, SubmissionFile, SteamCollectionItem, SteamCollection
# Input Schemas
@ -140,6 +140,26 @@ class PuzzlePointsFactorOut(Schema):
area: int
class SteamCollectionOut(ModelSchema):
"""Schema for Steam collection output"""
class Meta:
model = SteamCollection
fields = [
"id",
"steam_id",
"title",
"description",
"author_name",
"total_items",
"unique_visitors",
"current_favorites",
"accepting_submissions",
"created_at",
"updated_at",
]
class SteamCollectionItemOut(ModelSchema):
"""Schema for Steam collection item output"""

View File

@ -1 +1 @@
{"root":["./src/home.ts","./src/noita.ts","./src/opus-magnum.ts","./src/services/apiService.ts","./src/services/ocrService.ts","./src/stores/index.ts","./src/stores/puzzles.ts","./src/stores/submissions.ts","./src/stores/uploads.ts","./src/types/index.ts","./src/Home.vue","./src/Noita.vue","./src/OpusMagnum.vue","./src/components/AdminPanel.vue","./src/components/FileUpload.vue","./src/components/PuzzleCard.vue","./src/components/RankBadge.vue","./src/components/Results.vue","./src/components/SubmissionForm.vue"],"version":"5.9.3"}
{"root":["./src/home.ts","./src/noita.ts","./src/opus-magnum.ts","./src/services/apiService.ts","./src/services/ocrService.ts","./src/stores/index.ts","./src/stores/puzzles.ts","./src/stores/submissions.ts","./src/stores/uploads.ts","./src/types/index.ts","./src/Home.vue","./src/Noita.vue","./src/OpusMagnum.vue","./src/components/AdminPanel.vue","./src/components/FileUpload.vue","./src/components/PuzzleCard.vue","./src/components/PuzzleResults.vue","./src/components/RankBadge.vue","./src/components/Results.vue","./src/components/SubmissionForm.vue","./src/components/Winners.vue"],"version":"5.9.3"}