use api in front
This commit is contained in:
parent
07dd1bc0ff
commit
52723b200a
1
.gitignore
vendored
1
.gitignore
vendored
@ -176,3 +176,4 @@ pyrightconfig.json
|
||||
# End of https://www.toptal.com/developers/gitignore/api/python
|
||||
|
||||
tags
|
||||
media/
|
||||
|
||||
@ -39,7 +39,6 @@ INSTALLED_APPS = [
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"django_vite",
|
||||
"storages",
|
||||
"accounts",
|
||||
"submissions",
|
||||
]
|
||||
@ -140,28 +139,6 @@ STEAM_API_KEY = os.environ.get('STEAM_API_KEY', None) # Set via environment var
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = BASE_DIR / 'media'
|
||||
|
||||
# AWS S3 Configuration
|
||||
USE_S3 = os.environ.get('USE_S3', 'False').lower() == 'true'
|
||||
|
||||
if USE_S3:
|
||||
# AWS S3 settings
|
||||
AWS_ACCESS_KEY_ID = os.environ.get('AWS_ACCESS_KEY_ID')
|
||||
AWS_SECRET_ACCESS_KEY = os.environ.get('AWS_SECRET_ACCESS_KEY')
|
||||
AWS_STORAGE_BUCKET_NAME = os.environ.get('AWS_STORAGE_BUCKET_NAME')
|
||||
AWS_S3_REGION_NAME = os.environ.get('AWS_S3_REGION_NAME', 'us-east-1')
|
||||
AWS_S3_CUSTOM_DOMAIN = f'{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com'
|
||||
AWS_DEFAULT_ACL = None
|
||||
AWS_S3_OBJECT_PARAMETERS = {
|
||||
'CacheControl': 'max-age=86400',
|
||||
}
|
||||
|
||||
# S3 Static and Media settings
|
||||
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
|
||||
STATICFILES_STORAGE = 'storages.backends.s3boto3.S3StaticStorage'
|
||||
|
||||
# Override media URL to use S3
|
||||
MEDIA_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/media/'
|
||||
|
||||
# File Upload Limits
|
||||
FILE_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
|
||||
@ -2,14 +2,16 @@
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import PuzzleCard from './components/PuzzleCard.vue'
|
||||
import SubmissionForm from './components/SubmissionForm.vue'
|
||||
import { puzzleHelpers, submissionHelpers, errorHelpers } from './services/apiService'
|
||||
import type { SteamCollection, SteamCollectionItem, Submission, PuzzleResponse } from './types'
|
||||
|
||||
// Mock data - replace with actual API calls later
|
||||
// API data
|
||||
const collections = ref<SteamCollection[]>([])
|
||||
const puzzles = ref<SteamCollectionItem[]>([])
|
||||
const submissions = ref<Submission[]>([])
|
||||
const isLoading = ref(true)
|
||||
const showSubmissionModal = ref(false)
|
||||
const error = ref<string>('')
|
||||
|
||||
// Mock data for development
|
||||
const mockCollections: SteamCollection[] = [
|
||||
@ -105,31 +107,83 @@ const responsesByPuzzle = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// Simulate API loading
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
|
||||
collections.value = mockCollections
|
||||
puzzles.value = mockPuzzles
|
||||
isLoading.value = false
|
||||
// Load puzzles from API
|
||||
const loadedPuzzles = await puzzleHelpers.loadPuzzles()
|
||||
puzzles.value = loadedPuzzles
|
||||
|
||||
// Create mock collection from loaded puzzles for display
|
||||
if (loadedPuzzles.length > 0) {
|
||||
collections.value = [{
|
||||
id: 1,
|
||||
steam_id: '3479142989',
|
||||
title: 'PolyLAN 41',
|
||||
description: 'Puzzle collection for PolyLAN 41 fil rouge',
|
||||
author_name: 'Flame Legrems',
|
||||
total_items: loadedPuzzles.length,
|
||||
unique_visitors: 31,
|
||||
current_favorites: 1,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}]
|
||||
}
|
||||
|
||||
// Load existing submissions
|
||||
const loadedSubmissions = await submissionHelpers.loadSubmissions()
|
||||
submissions.value = loadedSubmissions
|
||||
|
||||
} catch (err) {
|
||||
error.value = errorHelpers.getErrorMessage(err)
|
||||
console.error('Failed to load data:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const handleSubmission = (submission: Submission) => {
|
||||
console.log('Submission received:', submission)
|
||||
const handleSubmission = async (submissionData: {
|
||||
files: any[],
|
||||
notes?: string
|
||||
}) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
|
||||
// Add submission to the list
|
||||
submissions.value.push({
|
||||
...submission,
|
||||
id: Date.now(), // Simple ID generation for demo
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
// Create submission via API
|
||||
const response = await submissionHelpers.createFromFiles(
|
||||
submissionData.files,
|
||||
puzzles.value,
|
||||
submissionData.notes
|
||||
)
|
||||
|
||||
// Show success message
|
||||
const puzzleNames = submission.responses.map(r => r.puzzle_name).join(', ')
|
||||
alert(`Solutions submitted for puzzles: ${puzzleNames}`)
|
||||
if (response.error) {
|
||||
error.value = response.error
|
||||
alert(`Submission failed: ${response.error}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Close modal
|
||||
showSubmissionModal.value = false
|
||||
if (response.data) {
|
||||
// Add to local submissions list
|
||||
submissions.value.unshift(response.data)
|
||||
|
||||
// Show success message
|
||||
const puzzleNames = response.data.responses.map(r => r.puzzle_name).join(', ')
|
||||
alert(`Solutions submitted successfully for puzzles: ${puzzleNames}`)
|
||||
|
||||
// Close modal
|
||||
showSubmissionModal.value = false
|
||||
}
|
||||
|
||||
} 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 = () => {
|
||||
@ -142,22 +196,7 @@ const closeSubmissionModal = () => {
|
||||
|
||||
// Function to match puzzle name from OCR to actual puzzle
|
||||
const findPuzzleByName = (ocrPuzzleName: string): SteamCollectionItem | null => {
|
||||
if (!ocrPuzzleName) return null
|
||||
|
||||
// Try exact match first
|
||||
let match = puzzles.value.find(p =>
|
||||
p.title.toLowerCase() === ocrPuzzleName.toLowerCase()
|
||||
)
|
||||
|
||||
if (!match) {
|
||||
// Try partial match
|
||||
match = puzzles.value.find(p =>
|
||||
p.title.toLowerCase().includes(ocrPuzzleName.toLowerCase()) ||
|
||||
ocrPuzzleName.toLowerCase().includes(p.title.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
return match || null
|
||||
return puzzleHelpers.findPuzzleByName(puzzles.value, ocrPuzzleName)
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -182,6 +221,19 @@ const findPuzzleByName = (ocrPuzzleName: string): SteamCollectionItem | null =>
|
||||
</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="window.location.reload()" 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 -->
|
||||
|
||||
268
opus_submitter/src/components/AdminPanel.vue
Normal file
268
opus_submitter/src/components/AdminPanel.vue
Normal file
@ -0,0 +1,268 @@
|
||||
<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_name }}</div>
|
||||
<div class="text-sm opacity-50">ID: {{ response.id }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-sm space-y-1">
|
||||
<div>Cost: {{ response.cost || '-' }}</div>
|
||||
<div>Cycles: {{ response.cycles || '-' }}</div>
|
||||
<div>Area: {{ response.area || '-' }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="badge badge-warning badge-sm">
|
||||
{{ response.ocr_confidence_score ? Math.round(response.ocr_confidence_score * 100) + '%' : 'Low' }}
|
||||
</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">
|
||||
<h3 class="font-bold text-lg mb-4">Validate Response</h3>
|
||||
|
||||
<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_name }}</div>
|
||||
<div class="text-sm">Review and correct the OCR data below</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<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'
|
||||
|
||||
// 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: {
|
||||
validated_cost: '',
|
||||
validated_cycles: '',
|
||||
validated_area: ''
|
||||
}
|
||||
})
|
||||
|
||||
// Methods
|
||||
const loadData = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
// Load stats
|
||||
const statsResponse = await apiService.getStats()
|
||||
if (statsResponse.data) {
|
||||
stats.value = statsResponse.data
|
||||
}
|
||||
|
||||
// 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 = {
|
||||
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 = {
|
||||
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()
|
||||
})
|
||||
|
||||
// Expose refresh method
|
||||
defineExpose({
|
||||
refresh: loadData
|
||||
})
|
||||
</script>
|
||||
@ -143,10 +143,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { ocrService, type OpusMagnumData } from '../services/ocrService'
|
||||
import type { SubmissionFile } from '@/types'
|
||||
import type { SubmissionFile, SteamCollectionItem } from '@/types'
|
||||
|
||||
interface Props {
|
||||
modelValue: SubmissionFile[]
|
||||
puzzles?: SteamCollectionItem[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@ -171,6 +172,14 @@ watch(files, (newFiles) => {
|
||||
emit('update:modelValue', newFiles)
|
||||
}, { deep: true })
|
||||
|
||||
// Watch for puzzle changes and update OCR service
|
||||
watch(() => props.puzzles, (newPuzzles) => {
|
||||
if (newPuzzles && newPuzzles.length > 0) {
|
||||
const puzzleNames = newPuzzles.map(puzzle => puzzle.title)
|
||||
ocrService.setAvailablePuzzleNames(puzzleNames)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const handleFileSelect = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (target.files) {
|
||||
|
||||
@ -61,29 +61,32 @@
|
||||
<tbody>
|
||||
<tr v-for="response in responses" :key="response.id" class="hover">
|
||||
<td>
|
||||
<span v-if="response.cost" class="badge badge-success badge-xs">
|
||||
{{ response.cost }}
|
||||
<span v-if="response.final_cost || response.cost" class="badge badge-success badge-xs">
|
||||
{{ response.final_cost || response.cost }}
|
||||
</span>
|
||||
<span v-else class="text-base-content/50">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="response.cycles" class="badge badge-info badge-xs">
|
||||
{{ response.cycles }}
|
||||
<span v-if="response.final_cycles || response.cycles" class="badge badge-info badge-xs">
|
||||
{{ response.final_cycles || response.cycles }}
|
||||
</span>
|
||||
<span v-else class="text-base-content/50">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="response.area" class="badge badge-warning badge-xs">
|
||||
{{ response.area }}
|
||||
<span v-if="response.final_area || response.area" class="badge badge-warning badge-xs">
|
||||
{{ response.final_area || response.area }}
|
||||
</span>
|
||||
<span v-else class="text-base-content/50">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="badge badge-ghost badge-xs">{{ response.files.length }}</span>
|
||||
<div class="tooltip" :data-tip="response.files.map(f => f.file.name).join(', ')">
|
||||
<span class="badge badge-ghost badge-xs">{{ response.files?.length || 0 }}</span>
|
||||
<div v-if="response.files?.length" class="tooltip" :data-tip="response.files.map(f => f.original_filename || f.file?.name).join(', ')">
|
||||
<i class="mdi mdi-information-outline text-xs"></i>
|
||||
</div>
|
||||
<div v-if="response.needs_manual_validation" class="tooltip" data-tip="Needs manual validation">
|
||||
<i class="mdi mdi-alert-circle text-xs text-warning"></i>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@ -22,7 +22,7 @@
|
||||
</div>
|
||||
|
||||
<!-- File Upload -->
|
||||
<FileUpload v-model="submissionFiles" />
|
||||
<FileUpload v-model="submissionFiles" :puzzles="puzzles" />
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="form-control">
|
||||
@ -57,7 +57,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import FileUpload from './FileUpload.vue'
|
||||
import type { SteamCollectionItem, SubmissionFile, Submission, PuzzleResponse } from '@/types'
|
||||
import type { SteamCollectionItem, SubmissionFile } from '@/types'
|
||||
|
||||
interface Props {
|
||||
puzzles: SteamCollectionItem[]
|
||||
@ -65,7 +65,7 @@ interface Props {
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
submit: [submission: Submission]
|
||||
submit: [submissionData: { files: SubmissionFile[], notes?: string }]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@ -108,31 +108,11 @@ const handleSubmit = async () => {
|
||||
isSubmitting.value = true
|
||||
|
||||
try {
|
||||
const responses: PuzzleResponse[] = []
|
||||
|
||||
// Create responses for each detected puzzle
|
||||
Object.entries(responsesByPuzzle.value).forEach(([puzzleName, data]) => {
|
||||
if (data.puzzle) {
|
||||
// Get OCR data from the first file with complete data
|
||||
const fileWithOCR = data.files.find(f => f.ocrData?.cost || f.ocrData?.cycles || f.ocrData?.area)
|
||||
|
||||
responses.push({
|
||||
puzzle_id: data.puzzle.id,
|
||||
puzzle_name: puzzleName,
|
||||
cost: fileWithOCR?.ocrData?.cost,
|
||||
cycles: fileWithOCR?.ocrData?.cycles,
|
||||
area: fileWithOCR?.ocrData?.area,
|
||||
files: data.files
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const submission: Submission = {
|
||||
responses,
|
||||
// Emit the files and notes for the parent to handle API submission
|
||||
emit('submit', {
|
||||
files: submissionFiles.value,
|
||||
notes: notes.value.trim() || undefined
|
||||
}
|
||||
|
||||
emit('submit', submission)
|
||||
})
|
||||
|
||||
// Reset form
|
||||
submissionFiles.value = []
|
||||
|
||||
314
opus_submitter/src/services/apiService.ts
Normal file
314
opus_submitter/src/services/apiService.ts
Normal file
@ -0,0 +1,314 @@
|
||||
import type {
|
||||
SteamCollectionItem,
|
||||
Submission,
|
||||
PuzzleResponse,
|
||||
SubmissionFile
|
||||
} from '../types'
|
||||
|
||||
// API Configuration
|
||||
const API_BASE_URL = '/api'
|
||||
|
||||
// API Response Types
|
||||
interface ApiResponse<T> {
|
||||
data?: T
|
||||
error?: string
|
||||
status: number
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
items: T[]
|
||||
count: number
|
||||
}
|
||||
|
||||
interface SubmissionStats {
|
||||
total_submissions: number
|
||||
total_responses: number
|
||||
needs_validation: number
|
||||
validated_submissions: number
|
||||
validation_rate: number
|
||||
}
|
||||
|
||||
// API Service Class
|
||||
export class ApiService {
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
error: data.detail || `HTTP ${response.status}`,
|
||||
status: response.status
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
status: response.status
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Network error',
|
||||
status: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadRequest<T>(
|
||||
endpoint: string,
|
||||
formData: FormData
|
||||
): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
error: data.detail || `HTTP ${response.status}`,
|
||||
status: response.status
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
status: response.status
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Network error',
|
||||
status: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Puzzle endpoints
|
||||
async getPuzzles(): Promise<ApiResponse<SteamCollectionItem[]>> {
|
||||
return this.request<SteamCollectionItem[]>('/submissions/puzzles')
|
||||
}
|
||||
|
||||
// Submission endpoints
|
||||
async getSubmissions(limit = 20, offset = 0): Promise<ApiResponse<PaginatedResponse<Submission>>> {
|
||||
return this.request<PaginatedResponse<Submission>>(
|
||||
`/submissions/submissions?limit=${limit}&offset=${offset}`
|
||||
)
|
||||
}
|
||||
|
||||
async getSubmission(id: string): Promise<ApiResponse<Submission>> {
|
||||
return this.request<Submission>(`/submissions/submissions/${id}`)
|
||||
}
|
||||
|
||||
async createSubmission(
|
||||
submissionData: {
|
||||
notes?: string
|
||||
responses: Array<{
|
||||
puzzle_id: number
|
||||
puzzle_name: string
|
||||
cost?: string
|
||||
cycles?: string
|
||||
area?: string
|
||||
needs_manual_validation?: boolean
|
||||
ocr_confidence_score?: number
|
||||
}>
|
||||
},
|
||||
files: File[]
|
||||
): Promise<ApiResponse<Submission>> {
|
||||
const formData = new FormData()
|
||||
|
||||
// Add JSON data
|
||||
formData.append('data', JSON.stringify(submissionData))
|
||||
|
||||
// Add files
|
||||
files.forEach((file, index) => {
|
||||
formData.append('files', file)
|
||||
})
|
||||
|
||||
return this.uploadRequest<Submission>('/submissions/submissions', formData)
|
||||
}
|
||||
|
||||
// Admin endpoints (require staff permissions)
|
||||
async validateResponse(
|
||||
responseId: number,
|
||||
validationData: {
|
||||
validated_cost?: string
|
||||
validated_cycles?: string
|
||||
validated_area?: string
|
||||
}
|
||||
): Promise<ApiResponse<PuzzleResponse>> {
|
||||
return this.request<PuzzleResponse>(`/submissions/responses/${responseId}/validate`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(validationData),
|
||||
})
|
||||
}
|
||||
|
||||
async getResponsesNeedingValidation(): Promise<ApiResponse<PuzzleResponse[]>> {
|
||||
return this.request<PuzzleResponse[]>('/submissions/responses/needs-validation')
|
||||
}
|
||||
|
||||
async validateSubmission(submissionId: string): Promise<ApiResponse<Submission>> {
|
||||
return this.request<Submission>(`/submissions/submissions/${submissionId}/validate`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async deleteSubmission(submissionId: string): Promise<ApiResponse<{ detail: string }>> {
|
||||
return this.request<{ detail: string }>(`/submissions/submissions/${submissionId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
// Statistics endpoint
|
||||
async getStats(): Promise<ApiResponse<SubmissionStats>> {
|
||||
return this.request<SubmissionStats>('/submissions/stats')
|
||||
}
|
||||
|
||||
// Health check
|
||||
async healthCheck(): Promise<ApiResponse<{ status: string; service: string }>> {
|
||||
return this.request<{ status: string; service: string }>('/health')
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const apiService = new ApiService()
|
||||
|
||||
// Helper functions for common operations
|
||||
export const puzzleHelpers = {
|
||||
async loadPuzzles(): Promise<SteamCollectionItem[]> {
|
||||
const response = await apiService.getPuzzles()
|
||||
if (response.error) {
|
||||
console.error('Failed to load puzzles:', response.error)
|
||||
return []
|
||||
}
|
||||
return response.data || []
|
||||
},
|
||||
|
||||
findPuzzleByName(puzzles: SteamCollectionItem[], name: string): SteamCollectionItem | null {
|
||||
if (!name) return null
|
||||
|
||||
// Try exact match first
|
||||
let match = puzzles.find(p =>
|
||||
p.title.toLowerCase() === name.toLowerCase()
|
||||
)
|
||||
|
||||
if (!match) {
|
||||
// Try partial match
|
||||
match = puzzles.find(p =>
|
||||
p.title.toLowerCase().includes(name.toLowerCase()) ||
|
||||
name.toLowerCase().includes(p.title.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
return match || null
|
||||
}
|
||||
}
|
||||
|
||||
export const submissionHelpers = {
|
||||
async createFromFiles(
|
||||
files: SubmissionFile[],
|
||||
puzzles: SteamCollectionItem[],
|
||||
notes?: string
|
||||
): Promise<ApiResponse<Submission>> {
|
||||
// Group files by detected puzzle
|
||||
const responsesByPuzzle: Record<string, {
|
||||
puzzle: SteamCollectionItem | null,
|
||||
files: SubmissionFile[]
|
||||
}> = {}
|
||||
|
||||
files.forEach(file => {
|
||||
if (file.ocrData?.puzzle) {
|
||||
const puzzleName = file.ocrData.puzzle
|
||||
if (!responsesByPuzzle[puzzleName]) {
|
||||
responsesByPuzzle[puzzleName] = {
|
||||
puzzle: puzzleHelpers.findPuzzleByName(puzzles, puzzleName),
|
||||
files: []
|
||||
}
|
||||
}
|
||||
responsesByPuzzle[puzzleName].files.push(file)
|
||||
}
|
||||
})
|
||||
|
||||
// Create responses array
|
||||
const responses = Object.entries(responsesByPuzzle)
|
||||
.filter(([_, data]) => data.puzzle) // Only include matched puzzles
|
||||
.map(([puzzleName, data]) => {
|
||||
// Get OCR data from the first file with complete data
|
||||
const fileWithOCR = data.files.find(f =>
|
||||
f.ocrData?.cost || f.ocrData?.cycles || f.ocrData?.area
|
||||
)
|
||||
|
||||
// Check if manual validation is needed
|
||||
const needsValidation = !fileWithOCR?.ocrData ||
|
||||
!fileWithOCR.ocrData.cost ||
|
||||
!fileWithOCR.ocrData.cycles ||
|
||||
!fileWithOCR.ocrData.area
|
||||
|
||||
return {
|
||||
puzzle_id: data.puzzle!.id,
|
||||
puzzle_name: puzzleName,
|
||||
cost: fileWithOCR?.ocrData?.cost,
|
||||
cycles: fileWithOCR?.ocrData?.cycles,
|
||||
area: fileWithOCR?.ocrData?.area,
|
||||
needs_manual_validation: needsValidation,
|
||||
ocr_confidence_score: needsValidation ? 0.5 : 0.9 // Rough estimate
|
||||
}
|
||||
})
|
||||
|
||||
if (responses.length === 0) {
|
||||
return {
|
||||
error: 'No valid puzzle responses found',
|
||||
status: 400
|
||||
}
|
||||
}
|
||||
|
||||
// Extract actual File objects for upload
|
||||
const fileObjects = files.map(f => f.file)
|
||||
|
||||
return apiService.createSubmission({ notes, responses }, fileObjects)
|
||||
},
|
||||
|
||||
async loadSubmissions(limit = 20, offset = 0): Promise<Submission[]> {
|
||||
const response = await apiService.getSubmissions(limit, offset)
|
||||
if (response.error) {
|
||||
console.error('Failed to load submissions:', response.error)
|
||||
return []
|
||||
}
|
||||
return response.data?.items || []
|
||||
}
|
||||
}
|
||||
|
||||
// Error handling utilities
|
||||
export const errorHelpers = {
|
||||
getErrorMessage(error: unknown): string {
|
||||
if (typeof error === 'string') return error
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'object' && error !== null && 'detail' in error) {
|
||||
return String((error as any).detail)
|
||||
}
|
||||
return 'An unknown error occurred'
|
||||
},
|
||||
|
||||
isNetworkError(error: unknown): boolean {
|
||||
return typeof error === 'string' && error.includes('Network')
|
||||
},
|
||||
|
||||
isValidationError(status: number): boolean {
|
||||
return status === 400
|
||||
},
|
||||
|
||||
isAuthError(status: number): boolean {
|
||||
return status === 401 || status === 403
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,7 @@ export interface OCRRegion {
|
||||
|
||||
export class OpusMagnumOCRService {
|
||||
private worker: Tesseract.Worker | null = null;
|
||||
private availablePuzzleNames: string[] = [];
|
||||
|
||||
// Regions based on main.py coordinates (adjusted for web usage)
|
||||
private readonly regions: Record<string, OCRRegion> = {
|
||||
@ -35,6 +36,13 @@ export class OpusMagnumOCRService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the list of available puzzle names for better OCR matching
|
||||
*/
|
||||
setAvailablePuzzleNames(puzzleNames: string[]): void {
|
||||
this.availablePuzzleNames = puzzleNames;
|
||||
}
|
||||
|
||||
async extractOpusMagnumData(imageFile: File): Promise<OpusMagnumData> {
|
||||
if (!this.worker) {
|
||||
await this.initialize();
|
||||
@ -123,8 +131,8 @@ export class OpusMagnumOCRService {
|
||||
// Ensure only digits remain
|
||||
cleanText = cleanText.replace(/[^0-9]/g, '');
|
||||
} else if (key === 'puzzle') {
|
||||
// Post-process puzzle names
|
||||
cleanText = this.processPuzzleName(cleanText);
|
||||
// Post-process puzzle names with fuzzy matching
|
||||
cleanText = this.findBestPuzzleMatch(cleanText);
|
||||
}
|
||||
|
||||
results[key as keyof OpusMagnumData] = cleanText;
|
||||
@ -171,39 +179,71 @@ export class OpusMagnumOCRService {
|
||||
}
|
||||
}
|
||||
|
||||
private processPuzzleName(rawText: string): string {
|
||||
let processed = rawText.trim();
|
||||
/**
|
||||
* Calculate Levenshtein distance between two strings
|
||||
*/
|
||||
private levenshteinDistance(str1: string, str2: string): number {
|
||||
const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null));
|
||||
|
||||
// If no dash is present but we have digits, try to insert one
|
||||
if (!processed.includes('-') && /\d/.test(processed)) {
|
||||
// Common pattern: "P4141" should become "P41-41"
|
||||
// Look for patterns like P[digits][digits] where the last part might be a separate number
|
||||
const match = processed.match(/^([A-Z]+\d+)(\d{1,3})$/);
|
||||
if (match) {
|
||||
processed = `${match[1]}-${match[2]}`;
|
||||
}
|
||||
// Handle cases like "4141" -> "41-41" (missing P prefix)
|
||||
else if (/^\d{3,4}$/.test(processed)) {
|
||||
const mid = Math.floor(processed.length / 2);
|
||||
processed = `P${processed.slice(0, mid)}-${processed.slice(mid)}`;
|
||||
for (let i = 0; i <= str1.length; i++) matrix[0][i] = i;
|
||||
for (let j = 0; j <= str2.length; j++) matrix[j][0] = j;
|
||||
|
||||
for (let j = 1; j <= str2.length; j++) {
|
||||
for (let i = 1; i <= str1.length; i++) {
|
||||
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
|
||||
matrix[j][i] = Math.min(
|
||||
matrix[j][i - 1] + 1, // deletion
|
||||
matrix[j - 1][i] + 1, // insertion
|
||||
matrix[j - 1][i - 1] + indicator // substitution
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up spacing around dashes
|
||||
processed = processed.replace(/\s*-\s*/g, '-');
|
||||
|
||||
// Ensure proper spacing
|
||||
processed = processed.replace(/([A-Z])(\d)/g, '$1$2');
|
||||
processed = processed.replace(/(\d)([A-Z])/g, '$1 $2');
|
||||
|
||||
// Add P prefix if missing and starts with digits
|
||||
if (/^\d/.test(processed) && !processed.startsWith('P')) {
|
||||
processed = 'P' + processed;
|
||||
}
|
||||
|
||||
return processed;
|
||||
return matrix[str2.length][str1.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best matching puzzle name from available options
|
||||
*/
|
||||
private findBestPuzzleMatch(ocrText: string): string {
|
||||
if (!this.availablePuzzleNames.length) {
|
||||
return ocrText.trim();
|
||||
}
|
||||
|
||||
const cleanedOcr = ocrText.trim();
|
||||
|
||||
// First try exact match (case insensitive)
|
||||
const exactMatch = this.availablePuzzleNames.find(
|
||||
name => name.toLowerCase() === cleanedOcr.toLowerCase()
|
||||
);
|
||||
if (exactMatch) return exactMatch;
|
||||
|
||||
// Then try fuzzy matching
|
||||
let bestMatch = cleanedOcr;
|
||||
let bestScore = Infinity;
|
||||
|
||||
for (const puzzleName of this.availablePuzzleNames) {
|
||||
// Calculate similarity scores
|
||||
const distance = this.levenshteinDistance(
|
||||
cleanedOcr.toLowerCase(),
|
||||
puzzleName.toLowerCase()
|
||||
);
|
||||
|
||||
// Normalize by length to get a similarity ratio
|
||||
const maxLength = Math.max(cleanedOcr.length, puzzleName.length);
|
||||
const similarity = 1 - (distance / maxLength);
|
||||
|
||||
// Consider it a good match if similarity is above 70%
|
||||
if (similarity > 0.7 && distance < bestScore) {
|
||||
bestScore = distance;
|
||||
bestMatch = puzzleName;
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
|
||||
async terminate(): Promise<void> {
|
||||
if (this.worker) {
|
||||
await this.worker.terminate();
|
||||
|
||||
@ -42,18 +42,34 @@ export interface SubmissionFile {
|
||||
|
||||
export interface PuzzleResponse {
|
||||
id?: number
|
||||
puzzle_id: number
|
||||
puzzle: number | SteamCollectionItem
|
||||
puzzle_name: string
|
||||
cost?: string
|
||||
cycles?: string
|
||||
area?: string
|
||||
files: SubmissionFile[]
|
||||
}
|
||||
|
||||
export interface Submission {
|
||||
id?: number
|
||||
responses: PuzzleResponse[]
|
||||
notes?: string
|
||||
needs_manual_validation?: boolean
|
||||
ocr_confidence_score?: 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
|
||||
total_responses?: number
|
||||
needs_validation?: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
@ -60,6 +60,8 @@ def create_submission(
|
||||
if len(files) < len(data.responses):
|
||||
return 400, {"detail": "Not enough files for all responses"}
|
||||
|
||||
print(data, files)
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Create the submission
|
||||
@ -135,6 +137,7 @@ def create_submission(
|
||||
return submission
|
||||
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return 500, {"detail": f"Error creating submission: {str(e)}"}
|
||||
|
||||
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-29 01:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('submissions', '0004_submission_puzzleresponse_submissionfile'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='submission',
|
||||
name='notes',
|
||||
field=models.TextField(blank=True, help_text='Optional notes about the submission', null=True),
|
||||
),
|
||||
]
|
||||
@ -14,34 +14,30 @@ class SteamAPIKey(models.Model):
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
unique=True,
|
||||
help_text="Descriptive name for this API key (e.g., 'Production Key', 'Development Key')"
|
||||
help_text="Descriptive name for this API key (e.g., 'Production Key', 'Development Key')",
|
||||
)
|
||||
api_key = models.CharField(
|
||||
max_length=64,
|
||||
help_text="Steam Web API key from https://steamcommunity.com/dev/apikey"
|
||||
help_text="Steam Web API key from https://steamcommunity.com/dev/apikey",
|
||||
)
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Whether this API key should be used"
|
||||
default=True, help_text="Whether this API key should be used"
|
||||
)
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
help_text="Optional description or notes about this API key"
|
||||
blank=True, help_text="Optional description or notes about this API key"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
last_used = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="When this API key was last used"
|
||||
null=True, blank=True, help_text="When this API key was last used"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Steam API Key"
|
||||
verbose_name_plural = "Steam API Keys"
|
||||
ordering = ['-is_active', 'name']
|
||||
ordering = ["-is_active", "name"]
|
||||
|
||||
def __str__(self):
|
||||
status = "Active" if self.is_active else "Inactive"
|
||||
@ -58,7 +54,9 @@ class SteamAPIKey(models.Model):
|
||||
try:
|
||||
int(self.api_key, 16)
|
||||
except ValueError:
|
||||
raise ValidationError("Steam API key should contain only hexadecimal characters (0-9, A-F)")
|
||||
raise ValidationError(
|
||||
"Steam API key should contain only hexadecimal characters (0-9, A-F)"
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.full_clean()
|
||||
@ -202,7 +200,7 @@ class SteamCollectionItem(models.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 ''
|
||||
ext = filename.split(".")[-1] if "." in filename else ""
|
||||
new_filename = f"{uuid.uuid4()}_{filename}" if ext else str(uuid.uuid4())
|
||||
return f"submissions/{instance.response.submission.id}/{new_filename}"
|
||||
|
||||
@ -219,32 +217,30 @@ class Submission(models.Model):
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="User who made the submission (null for anonymous)"
|
||||
help_text="User who made the submission (null for anonymous)",
|
||||
)
|
||||
|
||||
# Submission metadata
|
||||
notes = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Optional notes about the submission"
|
||||
help_text="Optional notes about the submission",
|
||||
)
|
||||
|
||||
# Status tracking
|
||||
is_validated = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this submission has been manually validated"
|
||||
default=False, help_text="Whether this submission has been manually validated"
|
||||
)
|
||||
validated_by = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='validated_submissions',
|
||||
help_text="Admin user who validated this submission"
|
||||
related_name="validated_submissions",
|
||||
help_text="Admin user who validated this submission",
|
||||
)
|
||||
validated_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="When this submission was validated"
|
||||
null=True, blank=True, help_text="When this submission was validated"
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
@ -252,7 +248,7 @@ class Submission(models.Model):
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
ordering = ["-created_at"]
|
||||
verbose_name = "Submission"
|
||||
verbose_name_plural = "Submissions"
|
||||
|
||||
@ -276,64 +272,42 @@ class PuzzleResponse(models.Model):
|
||||
|
||||
# Relationships
|
||||
submission = models.ForeignKey(
|
||||
Submission,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='responses'
|
||||
Submission, on_delete=models.CASCADE, related_name="responses"
|
||||
)
|
||||
puzzle = models.ForeignKey(
|
||||
SteamCollectionItem,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='responses',
|
||||
help_text="The puzzle this response is for"
|
||||
related_name="responses",
|
||||
help_text="The puzzle this response is for",
|
||||
)
|
||||
|
||||
# OCR extracted data
|
||||
puzzle_name = models.CharField(
|
||||
max_length=255,
|
||||
help_text="Puzzle name as detected by OCR"
|
||||
)
|
||||
cost = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
help_text="Cost value from OCR"
|
||||
max_length=255, help_text="Puzzle name as detected by OCR"
|
||||
)
|
||||
cost = models.CharField(max_length=20, blank=True, help_text="Cost value from OCR")
|
||||
cycles = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
help_text="Cycles value from OCR"
|
||||
)
|
||||
area = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
help_text="Area value from OCR"
|
||||
max_length=20, blank=True, help_text="Cycles value from OCR"
|
||||
)
|
||||
area = models.CharField(max_length=20, blank=True, help_text="Area value from OCR")
|
||||
|
||||
# Validation flags
|
||||
needs_manual_validation = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether OCR failed and manual validation is needed"
|
||||
default=False, help_text="Whether OCR failed and manual validation is needed"
|
||||
)
|
||||
ocr_confidence_score = models.FloatField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="OCR confidence score (0.0 to 1.0)"
|
||||
null=True, blank=True, help_text="OCR confidence score (0.0 to 1.0)"
|
||||
)
|
||||
|
||||
# Manual validation overrides
|
||||
validated_cost = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
help_text="Manually validated cost value"
|
||||
max_length=20, blank=True, help_text="Manually validated cost value"
|
||||
)
|
||||
validated_cycles = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
help_text="Manually validated cycles value"
|
||||
max_length=20, blank=True, help_text="Manually validated cycles value"
|
||||
)
|
||||
validated_area = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
help_text="Manually validated area value"
|
||||
max_length=20, blank=True, help_text="Manually validated area value"
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
@ -341,8 +315,8 @@ class PuzzleResponse(models.Model):
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['submission', 'puzzle__order_index']
|
||||
unique_together = ['submission', 'puzzle']
|
||||
ordering = ["submission", "puzzle__order_index"]
|
||||
unique_together = ["submission", "puzzle"]
|
||||
verbose_name = "Puzzle Response"
|
||||
verbose_name_plural = "Puzzle Responses"
|
||||
|
||||
@ -367,7 +341,7 @@ class PuzzleResponse(models.Model):
|
||||
def mark_for_validation(self, reason="OCR failed"):
|
||||
"""Mark this response as needing manual validation"""
|
||||
self.needs_manual_validation = True
|
||||
self.save(update_fields=['needs_manual_validation'])
|
||||
self.save(update_fields=["needs_manual_validation"])
|
||||
|
||||
|
||||
class SubmissionFile(models.Model):
|
||||
@ -375,49 +349,34 @@ class SubmissionFile(models.Model):
|
||||
|
||||
# Relationships
|
||||
response = models.ForeignKey(
|
||||
PuzzleResponse,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='files'
|
||||
PuzzleResponse, on_delete=models.CASCADE, related_name="files"
|
||||
)
|
||||
|
||||
# File information
|
||||
file = models.FileField(
|
||||
upload_to=submission_file_upload_path,
|
||||
help_text="Uploaded file (image/gif)"
|
||||
upload_to=submission_file_upload_path, help_text="Uploaded file (image/gif)"
|
||||
)
|
||||
original_filename = models.CharField(
|
||||
max_length=255,
|
||||
help_text="Original filename as uploaded by user"
|
||||
)
|
||||
file_size = models.PositiveIntegerField(
|
||||
help_text="File size in bytes"
|
||||
)
|
||||
content_type = models.CharField(
|
||||
max_length=100,
|
||||
help_text="MIME type of the file"
|
||||
max_length=255, help_text="Original filename as uploaded by user"
|
||||
)
|
||||
file_size = models.PositiveIntegerField(help_text="File size in bytes")
|
||||
content_type = models.CharField(max_length=100, help_text="MIME type of the file")
|
||||
|
||||
# OCR metadata
|
||||
ocr_processed = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether OCR has been processed for this file"
|
||||
default=False, help_text="Whether OCR has been processed for this file"
|
||||
)
|
||||
ocr_raw_data = models.JSONField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Raw OCR data as JSON"
|
||||
)
|
||||
ocr_error = models.TextField(
|
||||
blank=True,
|
||||
help_text="OCR processing error message"
|
||||
null=True, blank=True, help_text="Raw OCR data as JSON"
|
||||
)
|
||||
ocr_error = models.TextField(blank=True, help_text="OCR processing error message")
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['response', 'created_at']
|
||||
ordering = ["response", "created_at"]
|
||||
verbose_name = "Submission File"
|
||||
verbose_name_plural = "Submission Files"
|
||||
|
||||
@ -436,4 +395,3 @@ class SubmissionFile(models.Model):
|
||||
if self.file and not self.file_size:
|
||||
self.file_size = self.file.size
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@ -8,8 +8,6 @@ dependencies = [
|
||||
"django-vite>=3.1.0",
|
||||
"requests>=2.31.0",
|
||||
"django-ninja>=1.3.0",
|
||||
"django-storages>=1.14.0",
|
||||
"boto3>=1.35.0",
|
||||
"pillow>=10.0.0",
|
||||
]
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user