-
-
OCR is being processed...
+
+
+
+
OCR is being processed...
+
-
-
-
-
OCR processing failed
+
+
+
+
OCR processing failed
+
-
-
-
-
-
Waiting for OCR processing...
+
+
+
+
Waiting for OCR processing...
+
+
+
+
+
+
Crop OCR Results ({{ selectedSubmissionFile.crops.length }}):
+
+
+
{{ crop.label || `Crop (${crop.x1}, ${crop.y1})` }}
+
{{ crop.ocr_status }}
+
+
+ {{ crop.ocr_raw_data }}
+
+
+
+ Processing...
+
+
+
+ Processing failed
+
+
+
+ Pending
+
+
diff --git a/polylan_submitter/src/composables/useSubmissionsWebSocket.ts b/polylan_submitter/src/composables/useSubmissionsWebSocket.ts
new file mode 100644
index 0000000..cfd179b
--- /dev/null
+++ b/polylan_submitter/src/composables/useSubmissionsWebSocket.ts
@@ -0,0 +1,156 @@
+import { ref, computed, onBeforeUnmount } from 'vue'
+import { useWebSocket } from '@vueuse/core'
+
+interface SubmissionUpdate {
+ id: string
+ game_id?: number
+ notes?: string
+ files?: SubmissionFileUpdate[]
+ created_at: string
+ updated_at: string
+}
+
+interface SubmissionFileUpdate {
+ id: string
+ original_filename: string
+ content_type: string
+ file_size: number
+ file_url?: string
+ ocr_status: string
+ ocr_raw_data: string
+ crops?: ImageCropUpdate[]
+}
+
+interface ImageCropUpdate {
+ id: string
+ x1: number
+ y1: number
+ x2: number
+ y2: number
+ label: string
+ ocr_status: string
+ ocr_raw_data: string
+ created_at: string
+}
+
+type WebSocketData = SubmissionUpdate | SubmissionFileUpdate | ImageCropUpdate
+
+export function useSubmissionsWebSocket(userSubmissions: any) {
+ const isConnected = ref(false)
+ const reconnectAttempts = ref(0)
+ const maxReconnectAttempts = 10
+ const reconnectDelay = ref(3000)
+
+ const wsUrl = computed(() => {
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
+ return `${protocol}//${window.location.host}/ws/submissions/`
+ })
+
+ const {
+ status,
+ open,
+ close,
+ } = useWebSocket(wsUrl.value, {
+ onConnected: () => {
+ isConnected.value = true
+ reconnectAttempts.value = 0
+ reconnectDelay.value = 3000
+ console.log('✓ Connected to submissions WebSocket')
+ },
+ onDisconnected: () => {
+ isConnected.value = false
+ console.log('✗ Disconnected from submissions WebSocket, attempting to reconnect...')
+ attemptReconnect()
+ },
+ onError: (event) => {
+ console.error('WebSocket error:', event)
+ },
+ onMessage: (_ws, event) => {
+ try {
+ const messageData = event.data as string
+ const data = JSON.parse(messageData) as WebSocketData
+
+ if ('files' in data) {
+ handleSubmissionUpdate(data as SubmissionUpdate)
+ } else if ('original_filename' in data) {
+ handleFileUpdate(data as SubmissionFileUpdate)
+ } else if ('x1' in data) {
+ handleCropUpdate(data as ImageCropUpdate)
+ }
+ } catch (error) {
+ console.error('Failed to parse WebSocket message:', error)
+ }
+ },
+ immediate: true,
+ })
+
+ const attemptReconnect = () => {
+ if (reconnectAttempts.value >= maxReconnectAttempts) {
+ console.error('Max reconnection attempts reached')
+ return
+ }
+
+ reconnectAttempts.value++
+ const delay = reconnectDelay.value * Math.pow(1.5, reconnectAttempts.value - 1)
+
+ console.log(`Reconnecting (attempt ${reconnectAttempts.value}/${maxReconnectAttempts}) in ${Math.round(delay / 1000)}s...`)
+
+ setTimeout(() => {
+ open()
+ }, delay)
+ }
+
+ const handleSubmissionUpdate = (submission: SubmissionUpdate) => {
+ const existingIndex = userSubmissions.value.findIndex((s: any) => s.id === submission.id)
+
+ if (existingIndex >= 0) {
+ // Merge into existing object to preserve references
+ Object.assign(userSubmissions.value[existingIndex], submission)
+ } else {
+ userSubmissions.value.unshift(submission)
+ }
+
+ console.log('Updated submission:', submission.id)
+ }
+
+ const handleFileUpdate = (file: SubmissionFileUpdate) => {
+ userSubmissions.value.forEach((submission: any) => {
+ if (!submission.files) return
+
+ const fileIndex = submission.files.findIndex((f: any) => f.id === file.id)
+ if (fileIndex >= 0) {
+ // Merge into existing object to preserve references
+ Object.assign(submission.files[fileIndex], file)
+ console.log('Updated file:', file.id)
+ }
+ })
+ }
+
+ const handleCropUpdate = (crop: ImageCropUpdate) => {
+ userSubmissions.value.forEach((submission: any) => {
+ if (!submission.files) return
+
+ submission.files.forEach((file: any) => {
+ if (!file.crops) return
+
+ const cropIndex = file.crops.findIndex((c: any) => c.id === crop.id)
+ if (cropIndex >= 0) {
+ // Merge into existing object to preserve references
+ Object.assign(file.crops[cropIndex], crop)
+ console.log('Updated crop:', crop.id)
+ }
+ })
+ })
+ }
+
+ onBeforeUnmount(() => {
+ close()
+ })
+
+ return {
+ isConnected,
+ status,
+ open,
+ close,
+ }
+}
diff --git a/polylan_submitter/submissions/apps.py b/polylan_submitter/submissions/apps.py
index 976aa1b..a2ec3b1 100644
--- a/polylan_submitter/submissions/apps.py
+++ b/polylan_submitter/submissions/apps.py
@@ -1,4 +1,17 @@
from django.apps import AppConfig
+from django.core.serializers.json import DjangoJSONEncoder
+
+from channels_redis.serializers import JSONSerializer, registry
+
+import json
+
+
+class CJSONSerializer(JSONSerializer):
+ """Override `JSONSerializer` to use `DjangoJSONEncoder`."""
+
+ def as_bytes(self, message, *args, **kwargs): # noqa: ANN001 ANN002 ANN003 ANN201
+ message = json.dumps(message, *args, **kwargs, cls=DjangoJSONEncoder)
+ return message.encode("utf-8")
class SubmissionsConfig(AppConfig):
@@ -6,4 +19,6 @@ class SubmissionsConfig(AppConfig):
name = "submissions"
def ready(self):
+ registry.register_serializer("cjson", CJSONSerializer)
+
import submissions.signals # noqa
diff --git a/polylan_submitter/submissions/consumers.py b/polylan_submitter/submissions/consumers.py
index 99d3914..b42941f 100644
--- a/polylan_submitter/submissions/consumers.py
+++ b/polylan_submitter/submissions/consumers.py
@@ -1,11 +1,7 @@
import json
from channels.generic.websocket import AsyncWebsocketConsumer
-from channels.db import database_sync_to_async
from django.contrib.auth.models import AnonymousUser
-from submissions.models import Submission, SubmissionFile, ImageCrop
-from submissions.schemas import SubmissionOut, SubmissionFileOut, ImageCropOut
-
class SubmissionsConsumer(AsyncWebsocketConsumer):
async def connect(self):
diff --git a/polylan_submitter/submissions/schemas.py b/polylan_submitter/submissions/schemas.py
index 73aa8e4..ba16185 100644
--- a/polylan_submitter/submissions/schemas.py
+++ b/polylan_submitter/submissions/schemas.py
@@ -1,7 +1,6 @@
from datetime import datetime
from uuid import UUID
from ninja import Schema
-from pydantic import BaseModel, ConfigDict
class ImageCropIn(Schema):
@@ -12,9 +11,7 @@ class ImageCropIn(Schema):
label: str | None = None
-class ImageCropOut(BaseModel):
- model_config = ConfigDict(from_attributes=True)
-
+class ImageCropOut(Schema):
id: UUID
x1: int
y1: int
@@ -26,9 +23,7 @@ class ImageCropOut(BaseModel):
created_at: datetime
-class SubmissionFileOut(BaseModel):
- model_config = ConfigDict(from_attributes=True)
-
+class SubmissionFileOut(Schema):
id: UUID
original_filename: str
content_type: str
@@ -39,9 +34,7 @@ class SubmissionFileOut(BaseModel):
crops: list[ImageCropOut] = []
-class SubmissionOut(BaseModel):
- model_config = ConfigDict(from_attributes=True)
-
+class SubmissionOut(Schema):
id: UUID
game_id: int | None = None
notes: str | None = None
diff --git a/polylan_submitter/submissions/signals.py b/polylan_submitter/submissions/signals.py
index 5bb7aa7..7e7f7e9 100644
--- a/polylan_submitter/submissions/signals.py
+++ b/polylan_submitter/submissions/signals.py
@@ -1,4 +1,3 @@
-import json
from django.db.models.signals import post_save
from django.dispatch import receiver
from asgiref.sync import async_to_sync
@@ -17,15 +16,14 @@ def broadcast_submission_update(sender, instance, created, **kwargs):
return
user_group_name = f"submissions_user_{instance.user.id}"
- schema = SubmissionOut.model_validate(instance)
- submission_data = schema.model_dump(mode="json")
+ submission_data = SubmissionOut.from_orm(instance).dict()
async_to_sync(channel_layer.group_send)(
user_group_name,
{
"type": "submission.updated",
"submission": submission_data,
- }
+ },
)
@@ -36,15 +34,14 @@ def broadcast_submission_file_update(sender, instance, created, **kwargs):
return
user_group_name = f"submissions_user_{submission.user.id}"
- schema = SubmissionFileOut.model_validate(instance)
- file_data = schema.model_dump(mode="json")
+ file_data = SubmissionFileOut.from_orm(instance).dict()
async_to_sync(channel_layer.group_send)(
user_group_name,
{
"type": "submission.file.updated",
"file": file_data,
- }
+ },
)
@@ -55,13 +52,12 @@ def broadcast_image_crop_update(sender, instance, created, **kwargs):
return
user_group_name = f"submissions_user_{submission.user.id}"
- schema = ImageCropOut.model_validate(instance)
- crop_data = schema.model_dump(mode="json")
+ crop_data = ImageCropOut.from_orm(instance).dict()
async_to_sync(channel_layer.group_send)(
user_group_name,
{
"type": "submission.crop.updated",
"crop": crop_data,
- }
+ },
)
diff --git a/polylan_submitter/submissions/tasks.py b/polylan_submitter/submissions/tasks.py
index 09b1066..6ac2de9 100644
--- a/polylan_submitter/submissions/tasks.py
+++ b/polylan_submitter/submissions/tasks.py
@@ -5,25 +5,28 @@ from PIL import Image
import pytesseract
-def _process_ocr_file(file) -> bool:
- if file.ocr_status != OCRStatus.TO_PROCESS:
+def _process_ocr_file(obj, file, crop: ImageCrop | None = None) -> bool:
+ if obj.ocr_status != OCRStatus.TO_PROCESS:
return False
- file.ocr_status = OCRStatus.PROCESSING
- file.save(update_fields=["ocr_status"])
+ obj.ocr_status = OCRStatus.PROCESSING
+ obj.save(update_fields=["ocr_status"])
try:
- file.ocr_raw_data = pytesseract.image_to_string(Image.open(file.file))
+ image = Image.open(file)
- except Exception:
- file.ocr_status = OCRStatus.ERROR
- file.save(update_fields=["ocr_status"])
+ if crop:
+ image = image.crop((crop.x1, crop.y1, crop.x2, crop.y2))
- return False
+ obj.ocr_raw_data = pytesseract.image_to_string(image)
+ obj.ocr_status = OCRStatus.PROCESSED
+
+ except Exception as e:
+ obj.ocr_status = OCRStatus.ERROR
+ raise e
finally:
- file.ocr_status = OCRStatus.PROCESSED
- file.save(update_fields=["ocr_status", "ocr_raw_data"])
+ obj.save(update_fields=["ocr_status", "ocr_raw_data"])
return True
@@ -42,7 +45,7 @@ def process_submission_file(file_id: str):
if not file:
return
- _process_ocr_file(file)
+ _process_ocr_file(file, file.file)
process_crops.enqueue(file_id)
@@ -65,4 +68,4 @@ def process_crop(crop_id: str):
if not crop:
return
- _process_ocr_file(crop)
+ _process_ocr_file(crop, crop.submission_file.file, crop=crop)