From d7bdb85fb0c356e5f5e4f9578fcc56a5bf16087d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Gremaud?= Date: Mon, 25 May 2026 13:01:22 +0200 Subject: [PATCH] feat(websocket): frontend websocket --- polylan_submitter/polylan_submitter/asgi.py | 4 +- .../polylan_submitter/settings.py | 1 + polylan_submitter/src/Submissions.vue | 181 ++++++++++++++---- .../composables/useSubmissionsWebSocket.ts | 156 +++++++++++++++ polylan_submitter/submissions/apps.py | 15 ++ polylan_submitter/submissions/consumers.py | 4 - polylan_submitter/submissions/schemas.py | 13 +- polylan_submitter/submissions/signals.py | 16 +- polylan_submitter/submissions/tasks.py | 29 +-- 9 files changed, 343 insertions(+), 76 deletions(-) create mode 100644 polylan_submitter/src/composables/useSubmissionsWebSocket.ts diff --git a/polylan_submitter/polylan_submitter/asgi.py b/polylan_submitter/polylan_submitter/asgi.py index 129d240..0211751 100644 --- a/polylan_submitter/polylan_submitter/asgi.py +++ b/polylan_submitter/polylan_submitter/asgi.py @@ -22,8 +22,6 @@ from submissions.routing import websocket_urlpatterns application = ProtocolTypeRouter( { "http": django_asgi_app, - "websocket": AuthMiddlewareStack( - URLRouter(websocket_urlpatterns) - ), + "websocket": AuthMiddlewareStack(URLRouter(websocket_urlpatterns)), } ) diff --git a/polylan_submitter/polylan_submitter/settings.py b/polylan_submitter/polylan_submitter/settings.py index 55b996d..14a62f6 100644 --- a/polylan_submitter/polylan_submitter/settings.py +++ b/polylan_submitter/polylan_submitter/settings.py @@ -173,6 +173,7 @@ CHANNEL_LAYERS = { "BACKEND": "channels_redis.core.RedisChannelLayer", "CONFIG": { "hosts": [("127.0.0.1", 6379)], + "serializer_format": "cjson", }, }, } diff --git a/polylan_submitter/src/Submissions.vue b/polylan_submitter/src/Submissions.vue index 227ef8a..1b215ec 100644 --- a/polylan_submitter/src/Submissions.vue +++ b/polylan_submitter/src/Submissions.vue @@ -1,8 +1,9 @@ @@ -461,6 +518,15 @@ onMounted(() => {

{{ gameName }} Submissions

+ + +
+
+ + {{ wsConnected ? 'Live' : 'Offline' }} +
+
+ API docs Admin panel @@ -496,7 +562,7 @@ onMounted(() => { -
@@ -507,12 +573,23 @@ onMounted(() => { isDragover ? 'mdi-cloud-check text-primary' : 'mdi-file-upload text-base-content/50' ]">
-

Click to upload or drag and drop

-

Any file type supported

+

Click to upload, drag and drop, or paste

+

Any file type supported • Images from clipboard welcome

+ +
+ + {{ uploadLimitExceeded }} +
+ + +
+ {{ uploadedFiles.length }}/{{ MAX_FILES }} files selected +
+

Selected Files:

@@ -809,41 +886,73 @@ onMounted(() => {
-
-
-
-
-

Extracted Text:

- -
-
- {{ selectedSubmissionFile.ocr_raw_data }} +
+ +
+
+
+
+

File - Extracted Text:

+ +
+
+ {{ selectedSubmissionFile.ocr_raw_data }} +
-
-
-
- -

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)