diff --git a/opus_submitter/package.json b/opus_submitter/package.json index 30f32db..b267e08 100644 --- a/opus_submitter/package.json +++ b/opus_submitter/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@tailwindcss/vite": "^4.1.16", + "@vueuse/core": "^14.0.0", "install": "^0.13.0", "pinia": "^3.0.3", "tailwindcss": "^4.1.16", diff --git a/opus_submitter/pnpm-lock.yaml b/opus_submitter/pnpm-lock.yaml index e94413a..7a92bc2 100644 --- a/opus_submitter/pnpm-lock.yaml +++ b/opus_submitter/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@tailwindcss/vite': specifier: ^4.1.16 version: 4.1.16(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)) + '@vueuse/core': + specifier: ^14.0.0 + version: 14.0.0(vue@3.5.22(typescript@5.9.3)) install: specifier: ^0.13.0 version: 0.13.0 @@ -455,6 +458,9 @@ packages: '@types/node@24.9.2': resolution: {integrity: sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==} + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@vitejs/plugin-vue@6.0.1': resolution: {integrity: sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -528,6 +534,19 @@ packages: vue: optional: true + '@vueuse/core@14.0.0': + resolution: {integrity: sha512-d6tKRWkZE8IQElX2aHBxXOMD478fHIYV+Dzm2y9Ag122ICBpNKtGICiXKOhWU3L1kKdttDD9dCMS4bGP3jhCTQ==} + peerDependencies: + vue: ^3.5.0 + + '@vueuse/metadata@14.0.0': + resolution: {integrity: sha512-6yoGqbJcMldVCevkFiHDBTB1V5Hq+G/haPlGIuaFZHpXC0HADB0EN1ryQAAceiW+ryS3niUwvdFbGiqHqBrfVA==} + + '@vueuse/shared@14.0.0': + resolution: {integrity: sha512-mTCA0uczBgurRlwVaQHfG0Ja7UdGe4g9mwffiJmvLiTtp1G4AQyIjej6si/k8c8pUwTfVpNufck+23gXptPAkw==} + peerDependencies: + vue: ^3.5.0 + alien-signals@3.0.3: resolution: {integrity: sha512-2JXjom6R7ZwrISpUphLhf4htUq1aKRCennTJ6u9kFfr3sLmC9+I4CxxVi+McoFnIg+p1HnVrfLT/iCt4Dlz//Q==} @@ -1107,6 +1126,8 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/web-bluetooth@0.0.21': {} + '@vitejs/plugin-vue@6.0.1(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2))(vue@3.5.22(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.29 @@ -1214,6 +1235,19 @@ snapshots: typescript: 5.9.3 vue: 3.5.22(typescript@5.9.3) + '@vueuse/core@14.0.0(vue@3.5.22(typescript@5.9.3))': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 14.0.0 + '@vueuse/shared': 14.0.0(vue@3.5.22(typescript@5.9.3)) + vue: 3.5.22(typescript@5.9.3) + + '@vueuse/metadata@14.0.0': {} + + '@vueuse/shared@14.0.0(vue@3.5.22(typescript@5.9.3))': + dependencies: + vue: 3.5.22(typescript@5.9.3) + alien-signals@3.0.3: {} birpc@2.6.1: {} diff --git a/opus_submitter/src/App.vue b/opus_submitter/src/App.vue index 7cef0fc..287d6df 100644 --- a/opus_submitter/src/App.vue +++ b/opus_submitter/src/App.vue @@ -7,6 +7,7 @@ import { apiService, errorHelpers } from '@/services/apiService' import { usePuzzlesStore } from '@/stores/puzzles' import { useSubmissionsStore } from '@/stores/submissions' import type { SteamCollection, PuzzleResponse, UserInfo } from '@/types' +import { useCountdown } from '@vueuse/core' const props = defineProps<{collectionTitle: string, collectionUrl: string, collectionDescription: string}>() @@ -19,8 +20,6 @@ const userInfo = ref(null) const isLoading = ref(true) const error = ref('') -// Mock data removed - using API data only - // Computed properties const isSuperuser = computed(() => { return userInfo.value?.is_superuser || false @@ -42,7 +41,7 @@ const responsesByPuzzle = computed(() => { return grouped }) -onMounted(async () => { +async function initialize() { try { isLoading.value = true error.value = '' @@ -78,6 +77,20 @@ onMounted(async () => { isLoading.value = false console.log('Loading state set to false') } + + if (userInfo.value.is_superuser) { + start() + } +} + +const { remaining, start } = useCountdown(60, { + onComplete() { + initialize() + } +}) + +onMounted(async () => { + await initialize() }) const handleSubmission = async (submissionData: { @@ -165,6 +178,15 @@ const reloadPage = () => {
+
+
+

+ + Auto reload page in {{ remaining }} seconds ... +

+
+
+
diff --git a/opus_submitter/src/components/AdminPanel.vue b/opus_submitter/src/components/AdminPanel.vue index 6196874..3773c3c 100644 --- a/opus_submitter/src/components/AdminPanel.vue +++ b/opus_submitter/src/components/AdminPanel.vue @@ -25,6 +25,11 @@
{{ Math.round(stats.validation_rate * 100) }}%
+ +
@@ -268,6 +273,23 @@ const loadData = async () => { } } +const autoValidationResponse = async () => { + for (const response of Array.from(responsesNeedingValidation.value)) { + const {data, error} = await apiService.autoValidateResponses(response.id) + + if (data && !data.needs_manual_validation) { + // Remove from validation list + responsesNeedingValidation.value = responsesNeedingValidation.value.filter( + r => r.id !== response.id + ) + stats.value.needs_validation -= 1 + + } else if (error) { + break + } + } +} + const openValidationModal = (response: PuzzleResponse) => { validationModal.value.response = response validationModal.value.data = { diff --git a/opus_submitter/src/services/apiService.ts b/opus_submitter/src/services/apiService.ts index eaea3d8..98d6831 100644 --- a/opus_submitter/src/services/apiService.ts +++ b/opus_submitter/src/services/apiService.ts @@ -158,6 +158,12 @@ export class ApiService { }) } + async autoValidateResponses(responseId: number): Promise> { + return this.request(`/submissions/responses/${responseId}/validate/auto`, { + method: 'PUT', + }) + } + async getResponsesNeedingValidation(): Promise> { return this.request('/submissions/responses/needs-validation') } @@ -226,7 +232,7 @@ export const puzzleHelpers = { export const submissionHelpers = { async createFromFiles( - files: SubmissionFile[], + files: SubmissionFile[], puzzles: SteamCollectionItem[], notes?: string, manualValidationRequested?: boolean @@ -240,7 +246,7 @@ export const submissionHelpers = { files.forEach(file => { // Use manual puzzle selection if available, otherwise fall back to OCR const puzzleName = file.manualPuzzleSelection || file.ocrData?.puzzle - + if (puzzleName) { if (!responsesByPuzzle[puzzleName]) { responsesByPuzzle[puzzleName] = { @@ -290,10 +296,10 @@ export const submissionHelpers = { // Extract actual File objects for upload const fileObjects = files.map(f => f.file) - return apiService.createSubmission({ - notes, + return apiService.createSubmission({ + notes, manual_validation_requested: manualValidationRequested, - responses + responses }, fileObjects) }, diff --git a/opus_submitter/submissions/api.py b/opus_submitter/submissions/api.py index c533597..435be1b 100644 --- a/opus_submitter/submissions/api.py +++ b/opus_submitter/submissions/api.py @@ -7,6 +7,8 @@ from django.utils import timezone from django.shortcuts import get_object_or_404 from typing import List +from opus_submitter.submissions.utils import verify_and_validate_ocr_date_for_submission + from .models import Submission, PuzzleResponse, SubmissionFile, SteamCollectionItem from .schemas import ( SubmissionIn, @@ -196,6 +198,21 @@ def validate_response(request, response_id: int, data: ValidationIn): return response +@router.put("/responses/{response_id}/validate/auto", response=PuzzleResponseOut) +def validate_response(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 + + @router.get("/responses/needs-validation", response=List[PuzzleResponseOut]) def list_responses_needing_validation(request): """Get all responses that need manual validation"""