use api in front

This commit is contained in:
Loïc Gremaud 2025-10-29 02:57:10 +01:00
parent 07dd1bc0ff
commit 52723b200a
14 changed files with 900 additions and 263 deletions

1
.gitignore vendored
View File

@ -176,3 +176,4 @@ pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python # End of https://www.toptal.com/developers/gitignore/api/python
tags tags
media/

View File

@ -39,7 +39,6 @@ INSTALLED_APPS = [
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"django_vite", "django_vite",
"storages",
"accounts", "accounts",
"submissions", "submissions",
] ]
@ -140,28 +139,6 @@ STEAM_API_KEY = os.environ.get('STEAM_API_KEY', None) # Set via environment var
MEDIA_URL = '/media/' MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / '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 Limits
FILE_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 # 10MB FILE_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 # 10MB
DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 # 10MB DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 # 10MB

View File

@ -2,14 +2,16 @@
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import PuzzleCard from './components/PuzzleCard.vue' import PuzzleCard from './components/PuzzleCard.vue'
import SubmissionForm from './components/SubmissionForm.vue' import SubmissionForm from './components/SubmissionForm.vue'
import { puzzleHelpers, submissionHelpers, errorHelpers } from './services/apiService'
import type { SteamCollection, SteamCollectionItem, Submission, PuzzleResponse } from './types' import type { SteamCollection, SteamCollectionItem, Submission, PuzzleResponse } from './types'
// Mock data - replace with actual API calls later // API data
const collections = ref<SteamCollection[]>([]) const collections = ref<SteamCollection[]>([])
const puzzles = ref<SteamCollectionItem[]>([]) const puzzles = ref<SteamCollectionItem[]>([])
const submissions = ref<Submission[]>([]) const submissions = ref<Submission[]>([])
const isLoading = ref(true) const isLoading = ref(true)
const showSubmissionModal = ref(false) const showSubmissionModal = ref(false)
const error = ref<string>('')
// Mock data for development // Mock data for development
const mockCollections: SteamCollection[] = [ const mockCollections: SteamCollection[] = [
@ -105,31 +107,83 @@ const responsesByPuzzle = computed(() => {
}) })
onMounted(async () => { onMounted(async () => {
// Simulate API loading try {
await new Promise(resolve => setTimeout(resolve, 500)) isLoading.value = true
error.value = ''
collections.value = mockCollections // Load puzzles from API
puzzles.value = mockPuzzles const loadedPuzzles = await puzzleHelpers.loadPuzzles()
isLoading.value = false puzzles.value = loadedPuzzles
})
const handleSubmission = (submission: Submission) => { // Create mock collection from loaded puzzles for display
console.log('Submission received:', submission) if (loadedPuzzles.length > 0) {
collections.value = [{
// Add submission to the list id: 1,
submissions.value.push({ steam_id: '3479142989',
...submission, title: 'PolyLAN 41',
id: Date.now(), // Simple ID generation for demo 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(), created_at: new Date().toISOString(),
updated_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 = async (submissionData: {
files: any[],
notes?: string
}) => {
try {
isLoading.value = true
error.value = ''
// Create submission via API
const response = await submissionHelpers.createFromFiles(
submissionData.files,
puzzles.value,
submissionData.notes
)
if (response.error) {
error.value = response.error
alert(`Submission failed: ${response.error}`)
return
}
if (response.data) {
// Add to local submissions list
submissions.value.unshift(response.data)
// Show success message // Show success message
const puzzleNames = submission.responses.map(r => r.puzzle_name).join(', ') const puzzleNames = response.data.responses.map(r => r.puzzle_name).join(', ')
alert(`Solutions submitted for puzzles: ${puzzleNames}`) alert(`Solutions submitted successfully for puzzles: ${puzzleNames}`)
// Close modal // Close modal
showSubmissionModal.value = false 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 = () => { const openSubmissionModal = () => {
@ -142,22 +196,7 @@ const closeSubmissionModal = () => {
// Function to match puzzle name from OCR to actual puzzle // Function to match puzzle name from OCR to actual puzzle
const findPuzzleByName = (ocrPuzzleName: string): SteamCollectionItem | null => { const findPuzzleByName = (ocrPuzzleName: string): SteamCollectionItem | null => {
if (!ocrPuzzleName) return null return puzzleHelpers.findPuzzleByName(puzzles.value, ocrPuzzleName)
// 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
} }
</script> </script>
@ -182,6 +221,19 @@ const findPuzzleByName = (ocrPuzzleName: string): SteamCollectionItem | null =>
</div> </div>
</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 --> <!-- Main Content -->
<div v-else class="space-y-8"> <div v-else class="space-y-8">
<!-- Collection Info --> <!-- Collection Info -->

View 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>

View File

@ -143,10 +143,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, nextTick } from 'vue' import { ref, watch, nextTick } from 'vue'
import { ocrService, type OpusMagnumData } from '../services/ocrService' import { ocrService, type OpusMagnumData } from '../services/ocrService'
import type { SubmissionFile } from '@/types' import type { SubmissionFile, SteamCollectionItem } from '@/types'
interface Props { interface Props {
modelValue: SubmissionFile[] modelValue: SubmissionFile[]
puzzles?: SteamCollectionItem[]
} }
interface Emits { interface Emits {
@ -171,6 +172,14 @@ watch(files, (newFiles) => {
emit('update:modelValue', newFiles) emit('update:modelValue', newFiles)
}, { deep: true }) }, { 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 handleFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement const target = event.target as HTMLInputElement
if (target.files) { if (target.files) {

View File

@ -61,29 +61,32 @@
<tbody> <tbody>
<tr v-for="response in responses" :key="response.id" class="hover"> <tr v-for="response in responses" :key="response.id" class="hover">
<td> <td>
<span v-if="response.cost" class="badge badge-success badge-xs"> <span v-if="response.final_cost || response.cost" class="badge badge-success badge-xs">
{{ response.cost }} {{ response.final_cost || response.cost }}
</span> </span>
<span v-else class="text-base-content/50">-</span> <span v-else class="text-base-content/50">-</span>
</td> </td>
<td> <td>
<span v-if="response.cycles" class="badge badge-info badge-xs"> <span v-if="response.final_cycles || response.cycles" class="badge badge-info badge-xs">
{{ response.cycles }} {{ response.final_cycles || response.cycles }}
</span> </span>
<span v-else class="text-base-content/50">-</span> <span v-else class="text-base-content/50">-</span>
</td> </td>
<td> <td>
<span v-if="response.area" class="badge badge-warning badge-xs"> <span v-if="response.final_area || response.area" class="badge badge-warning badge-xs">
{{ response.area }} {{ response.final_area || response.area }}
</span> </span>
<span v-else class="text-base-content/50">-</span> <span v-else class="text-base-content/50">-</span>
</td> </td>
<td> <td>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="badge badge-ghost badge-xs">{{ response.files.length }}</span> <span class="badge badge-ghost badge-xs">{{ response.files?.length || 0 }}</span>
<div class="tooltip" :data-tip="response.files.map(f => f.file.name).join(', ')"> <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> <i class="mdi mdi-information-outline text-xs"></i>
</div> </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> </div>
</td> </td>
</tr> </tr>

View File

@ -22,7 +22,7 @@
</div> </div>
<!-- File Upload --> <!-- File Upload -->
<FileUpload v-model="submissionFiles" /> <FileUpload v-model="submissionFiles" :puzzles="puzzles" />
<!-- Notes --> <!-- Notes -->
<div class="form-control"> <div class="form-control">
@ -57,7 +57,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import FileUpload from './FileUpload.vue' import FileUpload from './FileUpload.vue'
import type { SteamCollectionItem, SubmissionFile, Submission, PuzzleResponse } from '@/types' import type { SteamCollectionItem, SubmissionFile } from '@/types'
interface Props { interface Props {
puzzles: SteamCollectionItem[] puzzles: SteamCollectionItem[]
@ -65,7 +65,7 @@ interface Props {
} }
interface Emits { interface Emits {
submit: [submission: Submission] submit: [submissionData: { files: SubmissionFile[], notes?: string }]
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@ -108,31 +108,11 @@ const handleSubmit = async () => {
isSubmitting.value = true isSubmitting.value = true
try { try {
const responses: PuzzleResponse[] = [] // Emit the files and notes for the parent to handle API submission
emit('submit', {
// Create responses for each detected puzzle files: submissionFiles.value,
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,
notes: notes.value.trim() || undefined notes: notes.value.trim() || undefined
} })
emit('submit', submission)
// Reset form // Reset form
submissionFiles.value = [] submissionFiles.value = []

View 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
}
}

View File

@ -16,6 +16,7 @@ export interface OCRRegion {
export class OpusMagnumOCRService { export class OpusMagnumOCRService {
private worker: Tesseract.Worker | null = null; private worker: Tesseract.Worker | null = null;
private availablePuzzleNames: string[] = [];
// Regions based on main.py coordinates (adjusted for web usage) // Regions based on main.py coordinates (adjusted for web usage)
private readonly regions: Record<string, OCRRegion> = { 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> { async extractOpusMagnumData(imageFile: File): Promise<OpusMagnumData> {
if (!this.worker) { if (!this.worker) {
await this.initialize(); await this.initialize();
@ -123,8 +131,8 @@ export class OpusMagnumOCRService {
// Ensure only digits remain // Ensure only digits remain
cleanText = cleanText.replace(/[^0-9]/g, ''); cleanText = cleanText.replace(/[^0-9]/g, '');
} else if (key === 'puzzle') { } else if (key === 'puzzle') {
// Post-process puzzle names // Post-process puzzle names with fuzzy matching
cleanText = this.processPuzzleName(cleanText); cleanText = this.findBestPuzzleMatch(cleanText);
} }
results[key as keyof OpusMagnumData] = 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 for (let i = 0; i <= str1.length; i++) matrix[0][i] = i;
if (!processed.includes('-') && /\d/.test(processed)) { for (let j = 0; j <= str2.length; j++) matrix[j][0] = j;
// Common pattern: "P4141" should become "P41-41"
// Look for patterns like P[digits][digits] where the last part might be a separate number for (let j = 1; j <= str2.length; j++) {
const match = processed.match(/^([A-Z]+\d+)(\d{1,3})$/); for (let i = 1; i <= str1.length; i++) {
if (match) { const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
processed = `${match[1]}-${match[2]}`; matrix[j][i] = Math.min(
} matrix[j][i - 1] + 1, // deletion
// Handle cases like "4141" -> "41-41" (missing P prefix) matrix[j - 1][i] + 1, // insertion
else if (/^\d{3,4}$/.test(processed)) { matrix[j - 1][i - 1] + indicator // substitution
const mid = Math.floor(processed.length / 2); );
processed = `P${processed.slice(0, mid)}-${processed.slice(mid)}`;
} }
} }
// Clean up spacing around dashes return matrix[str2.length][str1.length];
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; /**
* 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> { async terminate(): Promise<void> {
if (this.worker) { if (this.worker) {
await this.worker.terminate(); await this.worker.terminate();

View File

@ -42,18 +42,34 @@ export interface SubmissionFile {
export interface PuzzleResponse { export interface PuzzleResponse {
id?: number id?: number
puzzle_id: number puzzle: number | SteamCollectionItem
puzzle_name: string puzzle_name: string
cost?: string cost?: string
cycles?: string cycles?: string
area?: string area?: string
files: SubmissionFile[] needs_manual_validation?: boolean
} ocr_confidence_score?: number
validated_cost?: string
export interface Submission { validated_cycles?: string
id?: number validated_area?: string
responses: PuzzleResponse[] final_cost?: string
notes?: 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 created_at?: string
updated_at?: string updated_at?: string
} }

View File

@ -60,6 +60,8 @@ 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)
try: try:
with transaction.atomic(): with transaction.atomic():
# Create the submission # Create the submission
@ -135,6 +137,7 @@ 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,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),
),
]

View File

@ -14,34 +14,30 @@ class SteamAPIKey(models.Model):
name = models.CharField( name = models.CharField(
max_length=100, max_length=100,
unique=True, 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( api_key = models.CharField(
max_length=64, 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( is_active = models.BooleanField(
default=True, default=True, help_text="Whether this API key should be used"
help_text="Whether this API key should be used"
) )
description = models.TextField( description = models.TextField(
blank=True, blank=True, help_text="Optional description or notes about this API key"
help_text="Optional description or notes about this API key"
) )
# Metadata # Metadata
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
last_used = models.DateTimeField( last_used = models.DateTimeField(
null=True, null=True, blank=True, help_text="When this API key was last used"
blank=True,
help_text="When this API key was last used"
) )
class Meta: class Meta:
verbose_name = "Steam API Key" verbose_name = "Steam API Key"
verbose_name_plural = "Steam API Keys" verbose_name_plural = "Steam API Keys"
ordering = ['-is_active', 'name'] ordering = ["-is_active", "name"]
def __str__(self): def __str__(self):
status = "Active" if self.is_active else "Inactive" status = "Active" if self.is_active else "Inactive"
@ -58,7 +54,9 @@ class SteamAPIKey(models.Model):
try: try:
int(self.api_key, 16) int(self.api_key, 16)
except ValueError: 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): def save(self, *args, **kwargs):
self.full_clean() self.full_clean()
@ -202,7 +200,7 @@ class SteamCollectionItem(models.Model):
def submission_file_upload_path(instance, filename): def submission_file_upload_path(instance, filename):
"""Generate upload path for submission files""" """Generate upload path for submission files"""
# Create path: submissions/{submission_id}/{uuid}_{filename} # 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()) new_filename = f"{uuid.uuid4()}_{filename}" if ext else str(uuid.uuid4())
return f"submissions/{instance.response.submission.id}/{new_filename}" return f"submissions/{instance.response.submission.id}/{new_filename}"
@ -219,32 +217,30 @@ class Submission(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
null=True, null=True,
blank=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 # Submission metadata
notes = models.TextField( notes = models.TextField(
null=True,
blank=True, blank=True,
help_text="Optional notes about the submission" help_text="Optional notes about the submission",
) )
# Status tracking # Status tracking
is_validated = models.BooleanField( is_validated = models.BooleanField(
default=False, default=False, help_text="Whether this submission has been manually validated"
help_text="Whether this submission has been manually validated"
) )
validated_by = models.ForeignKey( validated_by = models.ForeignKey(
User, User,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
null=True, null=True,
blank=True, blank=True,
related_name='validated_submissions', related_name="validated_submissions",
help_text="Admin user who validated this submission" help_text="Admin user who validated this submission",
) )
validated_at = models.DateTimeField( validated_at = models.DateTimeField(
null=True, null=True, blank=True, help_text="When this submission was validated"
blank=True,
help_text="When this submission was validated"
) )
# Timestamps # Timestamps
@ -252,7 +248,7 @@ class Submission(models.Model):
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:
ordering = ['-created_at'] ordering = ["-created_at"]
verbose_name = "Submission" verbose_name = "Submission"
verbose_name_plural = "Submissions" verbose_name_plural = "Submissions"
@ -276,64 +272,42 @@ class PuzzleResponse(models.Model):
# Relationships # Relationships
submission = models.ForeignKey( submission = models.ForeignKey(
Submission, Submission, on_delete=models.CASCADE, related_name="responses"
on_delete=models.CASCADE,
related_name='responses'
) )
puzzle = models.ForeignKey( puzzle = models.ForeignKey(
SteamCollectionItem, SteamCollectionItem,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='responses', related_name="responses",
help_text="The puzzle this response is for" help_text="The puzzle this response is for",
) )
# OCR extracted data # OCR extracted data
puzzle_name = models.CharField( puzzle_name = models.CharField(
max_length=255, max_length=255, help_text="Puzzle name as detected by OCR"
help_text="Puzzle name as detected by OCR"
)
cost = models.CharField(
max_length=20,
blank=True,
help_text="Cost value from OCR"
) )
cost = models.CharField(max_length=20, blank=True, help_text="Cost value from OCR")
cycles = models.CharField( cycles = models.CharField(
max_length=20, max_length=20, blank=True, help_text="Cycles value from OCR"
blank=True,
help_text="Cycles value from OCR"
)
area = models.CharField(
max_length=20,
blank=True,
help_text="Area value from OCR"
) )
area = models.CharField(max_length=20, blank=True, help_text="Area value from OCR")
# Validation flags # Validation flags
needs_manual_validation = models.BooleanField( needs_manual_validation = models.BooleanField(
default=False, default=False, help_text="Whether OCR failed and manual validation is needed"
help_text="Whether OCR failed and manual validation is needed"
) )
ocr_confidence_score = models.FloatField( ocr_confidence_score = models.FloatField(
null=True, null=True, blank=True, help_text="OCR confidence score (0.0 to 1.0)"
blank=True,
help_text="OCR confidence score (0.0 to 1.0)"
) )
# Manual validation overrides # Manual validation overrides
validated_cost = models.CharField( validated_cost = models.CharField(
max_length=20, max_length=20, blank=True, help_text="Manually validated cost value"
blank=True,
help_text="Manually validated cost value"
) )
validated_cycles = models.CharField( validated_cycles = models.CharField(
max_length=20, max_length=20, blank=True, help_text="Manually validated cycles value"
blank=True,
help_text="Manually validated cycles value"
) )
validated_area = models.CharField( validated_area = models.CharField(
max_length=20, max_length=20, blank=True, help_text="Manually validated area value"
blank=True,
help_text="Manually validated area value"
) )
# Timestamps # Timestamps
@ -341,8 +315,8 @@ class PuzzleResponse(models.Model):
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:
ordering = ['submission', 'puzzle__order_index'] ordering = ["submission", "puzzle__order_index"]
unique_together = ['submission', 'puzzle'] unique_together = ["submission", "puzzle"]
verbose_name = "Puzzle Response" verbose_name = "Puzzle Response"
verbose_name_plural = "Puzzle Responses" verbose_name_plural = "Puzzle Responses"
@ -367,7 +341,7 @@ class PuzzleResponse(models.Model):
def mark_for_validation(self, reason="OCR failed"): def mark_for_validation(self, reason="OCR failed"):
"""Mark this response as needing manual validation""" """Mark this response as needing manual validation"""
self.needs_manual_validation = True self.needs_manual_validation = True
self.save(update_fields=['needs_manual_validation']) self.save(update_fields=["needs_manual_validation"])
class SubmissionFile(models.Model): class SubmissionFile(models.Model):
@ -375,49 +349,34 @@ class SubmissionFile(models.Model):
# Relationships # Relationships
response = models.ForeignKey( response = models.ForeignKey(
PuzzleResponse, PuzzleResponse, on_delete=models.CASCADE, related_name="files"
on_delete=models.CASCADE,
related_name='files'
) )
# File information # File information
file = models.FileField( file = models.FileField(
upload_to=submission_file_upload_path, upload_to=submission_file_upload_path, help_text="Uploaded file (image/gif)"
help_text="Uploaded file (image/gif)"
) )
original_filename = models.CharField( original_filename = models.CharField(
max_length=255, max_length=255, help_text="Original filename as uploaded by user"
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"
) )
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 metadata
ocr_processed = models.BooleanField( ocr_processed = models.BooleanField(
default=False, default=False, help_text="Whether OCR has been processed for this file"
help_text="Whether OCR has been processed for this file"
) )
ocr_raw_data = models.JSONField( ocr_raw_data = models.JSONField(
null=True, null=True, blank=True, help_text="Raw OCR data as JSON"
blank=True,
help_text="Raw OCR data as JSON"
)
ocr_error = models.TextField(
blank=True,
help_text="OCR processing error message"
) )
ocr_error = models.TextField(blank=True, help_text="OCR processing error message")
# Timestamps # Timestamps
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:
ordering = ['response', 'created_at'] ordering = ["response", "created_at"]
verbose_name = "Submission File" verbose_name = "Submission File"
verbose_name_plural = "Submission Files" verbose_name_plural = "Submission Files"
@ -436,4 +395,3 @@ class SubmissionFile(models.Model):
if self.file and not self.file_size: if self.file and not self.file_size:
self.file_size = self.file.size self.file_size = self.file.size
super().save(*args, **kwargs) super().save(*args, **kwargs)

View File

@ -8,8 +8,6 @@ dependencies = [
"django-vite>=3.1.0", "django-vite>=3.1.0",
"requests>=2.31.0", "requests>=2.31.0",
"django-ninja>=1.3.0", "django-ninja>=1.3.0",
"django-storages>=1.14.0",
"boto3>=1.35.0",
"pillow>=10.0.0", "pillow>=10.0.0",
] ]