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 collections import defaultdict
from django.http import HttpRequest from django.http import HttpRequest
from ninja import Router from ninja import Router
from ninja.errors import HttpError
from django.core.cache import cache from django.core.cache import cache
from django.shortcuts import get_object_or_404
from accounts.models import CustomUser from accounts.models import CustomUser
from animations.schemas import RankingSchema from animations.schemas import (
from submissions.models import PuzzleResponse, SteamCollectionItem RankingSchema,
TournamentWinnersOut,
TournamentSubmissionsOut,
TournamentPuzzleResultsOut,
PuzzleWinnerOut,
PuzzleSubmissionsOut,
PuzzleResultsOut,
PuzzleSubmissionWithRankOut,
PuzzlePointsFactorOut,
WinnerResponseOut,
WinnerFileOut,
)
from submissions.models import PuzzleResponse, SteamCollectionItem, SteamCollection
router = Router() router = Router()
@ -46,3 +60,218 @@ def results(request: HttpRequest) -> dict:
cache.set("api:results:results", data, 300) cache.set("api:results:results", data, 300)
return data 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 ninja import ModelSchema, Schema
from typing import List, Optional
from submissions.models import PuzzleResponse from submissions.models import PuzzleResponse
from submissions.schemas import SteamCollectionItemOut from submissions.schemas import SteamCollectionItemOut
@ -41,3 +42,88 @@ class RankingSchema(Schema):
puzzles: list[SteamCollectionItemOut] puzzles: list[SteamCollectionItemOut]
responses_by_userid: dict[int, list[PuzzleResponseRankingOut]] responses_by_userid: dict[int, list[PuzzleResponseRankingOut]]
ranking_by_puzzle: 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 SubmissionForm from "@/components/SubmissionForm.vue";
import AdminPanel from "@/components/AdminPanel.vue"; import AdminPanel from "@/components/AdminPanel.vue";
import Results from "@/components/Results.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 { apiService, errorHelpers } from "@/services/apiService";
import { usePuzzlesStore } from "@/stores/puzzles"; import { usePuzzlesStore } from "@/stores/puzzles";
import { useSubmissionsStore } from "@/stores/submissions"; import { useSubmissionsStore } from "@/stores/submissions";
import type { PuzzleResponse, UserInfo } from "@/types"; import type { PuzzleResponse, UserInfo, SteamCollection } from "@/types";
import { useCountdown } from "@vueuse/core"; import { useCountdown } from "@vueuse/core";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
@ -26,6 +28,7 @@ const { openSubmissionModal, loadSubmissions, closeSubmissionModal } =
// Local state // Local state
const userInfo = ref<UserInfo | null>(null); const userInfo = ref<UserInfo | null>(null);
const collection = ref<SteamCollection | null>(null);
const isLoading = ref(true); const isLoading = ref(true);
const error = ref<string>(""); const error = ref<string>("");
@ -34,6 +37,10 @@ const isSuperuser = computed(() => {
return userInfo.value?.is_superuser || false; return userInfo.value?.is_superuser || false;
}); });
const isTournamentClosed = computed(() => {
return !!(collection.value && !collection.value.accepting_submissions);
});
// Computed property to get responses grouped by puzzle // Computed property to get responses grouped by puzzle
const responsesByPuzzle = computed(() => { const responsesByPuzzle = computed(() => {
const grouped: Record<number, PuzzleResponse[]> = {}; const grouped: Record<number, PuzzleResponse[]> = {};
@ -66,6 +73,16 @@ async function initialize() {
console.warn("User info error:", userResponse.error); 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 // Load puzzles from API using store
console.log("Loading puzzles..."); console.log("Loading puzzles...");
await puzzlesStore.loadPuzzles(); await puzzlesStore.loadPuzzles();
@ -173,6 +190,15 @@ const goHome = () => {
<!-- Main Content --> <!-- Main Content -->
<div v-else class="space-y-8"> <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 --> <!-- Collection Info -->
<div class="mb-8"> <div class="mb-8">
<div class="card bg-base-100 shadow-lg"> <div class="card bg-base-100 shadow-lg">
@ -182,11 +208,18 @@ const goHome = () => {
{{ props.collectionDescription }} {{ props.collectionDescription }}
</p> </p>
<div class="flex flex-wrap gap-4 mt-4"> <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> <i class="mdi mdi-plus mr-2"></i>
Submit Solution Submit Solution
</button> </button>
</div> </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> </div>
</div> </div>
@ -212,6 +245,7 @@ const goHome = () => {
Check back later for new puzzle collections! Check back later for new puzzle collections!
</p> </p>
</div> </div>
</template>
</div> </div>
</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 { import type {
SteamCollection,
SteamCollectionItem, SteamCollectionItem,
Submission, Submission,
PuzzleResponse, PuzzleResponse,
SubmissionFile, SubmissionFile,
UserInfo UserInfo,
TournamentWinners,
TournamentSubmissions,
TournamentPuzzleResults
} from '../types' } from '../types'
// API Configuration // API Configuration
@ -101,6 +105,22 @@ export class ApiService {
return this.request<SteamCollectionItem[]>('/submissions/puzzles') 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 // Submission endpoints
async getSubmissions(limit = 20, offset = 0): Promise<ApiResponse<PaginatedResponse<Submission>>> { async getSubmissions(limit = 20, offset = 0): Promise<ApiResponse<PaginatedResponse<Submission>>> {
return this.request<PaginatedResponse<Submission>>( return this.request<PaginatedResponse<Submission>>(

View File

@ -7,6 +7,7 @@ export interface SteamCollection {
total_items: number total_items: number
unique_visitors: number unique_visitors: number
current_favorites: number current_favorites: number
accepting_submissions: boolean
created_at: string created_at: string
updated_at: string updated_at: string
} }
@ -107,3 +108,68 @@ export interface UserInfo {
is_superuser: boolean is_superuser: boolean
cas_groups?: string[] 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": { "_RankBadge.vue_vue_type_script_setup_true_lang-IllWPCyW.js": {
"file": "assets/RankBadge.vue_vue_type_script_setup_true_lang-C5K0DD3W.js", "file": "assets/RankBadge.vue_vue_type_script_setup_true_lang-IllWPCyW.js",
"name": "RankBadge.vue_vue_type_script_setup_true_lang", "name": "RankBadge.vue_vue_type_script_setup_true_lang",
"imports": [ "imports": [
"_style-CufywNmO.js" "_style-C433w8gz.js"
] ]
}, },
"_style-CufywNmO.js": { "_style-C433w8gz.js": {
"file": "assets/style-CufywNmO.js", "file": "assets/style-C433w8gz.js",
"name": "style", "name": "style",
"css": [ "css": [
"assets/style-DaHD49X0.css" "assets/style-CMHHCLN4.css"
], ],
"assets": [ "assets": [
"assets/materialdesignicons-webfont-CSr8KVlo.eot", "assets/materialdesignicons-webfont-CSr8KVlo.eot",
@ -19,9 +19,9 @@
"assets/materialdesignicons-webfont-B7mPwVP_.ttf" "assets/materialdesignicons-webfont-B7mPwVP_.ttf"
] ]
}, },
"_style-DaHD49X0.css": { "_style-CMHHCLN4.css": {
"file": "assets/style-DaHD49X0.css", "file": "assets/style-CMHHCLN4.css",
"src": "_style-DaHD49X0.css" "src": "_style-CMHHCLN4.css"
}, },
"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.eot": {
"file": "assets/materialdesignicons-webfont-CSr8KVlo.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": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2"
}, },
"src/home.ts": { "src/home.ts": {
"file": "assets/home-2m6DwiDu.js", "file": "assets/home-f2CGvY1q.js",
"name": "home", "name": "home",
"src": "src/home.ts", "src": "src/home.ts",
"isEntry": true, "isEntry": true,
"imports": [ "imports": [
"_style-CufywNmO.js" "_style-C433w8gz.js"
] ]
}, },
"src/noita.ts": { "src/noita.ts": {
"file": "assets/noita-C7qVNYuP.js", "file": "assets/noita-BcY9X9eS.js",
"name": "noita", "name": "noita",
"src": "src/noita.ts", "src": "src/noita.ts",
"isEntry": true, "isEntry": true,
"imports": [ "imports": [
"_style-CufywNmO.js", "_style-C433w8gz.js",
"_RankBadge.vue_vue_type_script_setup_true_lang-C5K0DD3W.js" "_RankBadge.vue_vue_type_script_setup_true_lang-IllWPCyW.js"
] ]
}, },
"src/opus-magnum.ts": { "src/opus-magnum.ts": {
"file": "assets/opus_magnum-CgBh_a7R.js", "file": "assets/opus_magnum-CCZTjOvR.js",
"name": "opus_magnum", "name": "opus_magnum",
"src": "src/opus-magnum.ts", "src": "src/opus-magnum.ts",
"isEntry": true, "isEntry": true,
"imports": [ "imports": [
"_style-CufywNmO.js", "_style-C433w8gz.js",
"_RankBadge.vue_vue_type_script_setup_true_lang-C5K0DD3W.js" "_RankBadge.vue_vue_type_script_setup_true_lang-IllWPCyW.js"
] ]
} }
} }

View File

@ -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")}),
) )

View File

@ -1,6 +1,7 @@
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.core.cache import cache 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 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 ( from .schemas import (
SubmissionIn, SubmissionIn,
SubmissionOut, SubmissionOut,
PuzzleResponseOut, PuzzleResponseOut,
ValidationIn, ValidationIn,
SteamCollectionItemOut, SteamCollectionItemOut,
SteamCollectionOut,
) )
router = Router() 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]) @router.get("/submissions", response=List[SubmissionOut])
@paginate @paginate
def list_submissions(request): def list_submissions(request):
@ -65,7 +80,15 @@ 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():
@ -126,7 +149,6 @@ def create_submission(
# Process files for this response # Process files for this response
# For simplicity, we'll take one file per response # For simplicity, we'll take one file per response
# In a real implementation, you'd need better file-to-response mapping # In a real implementation, you'd need better file-to-response mapping
print("FI", file_index, files)
if file_index < len(files): if file_index < len(files):
uploaded_file = files[file_index] uploaded_file = files[file_index]
@ -172,7 +194,6 @@ def create_submission(
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)}"}

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( is_active = models.BooleanField(
default=True, help_text="Whether this collection is actively tracked" 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( fetch_error = models.TextField(
blank=True, help_text="Last error encountered when fetching data" 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 datetime import datetime
from uuid import UUID from uuid import UUID
from .models import Submission, PuzzleResponse, SubmissionFile, SteamCollectionItem from .models import Submission, PuzzleResponse, SubmissionFile, SteamCollectionItem, SteamCollection
# Input Schemas # Input Schemas
@ -140,6 +140,26 @@ class PuzzlePointsFactorOut(Schema):
area: int 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): class SteamCollectionItemOut(ModelSchema):
"""Schema for Steam collection item output""" """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"}