From b64161b371f607bbbd2819aab7553d972847d5a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Gremaud?= Date: Mon, 25 May 2026 02:45:17 +0200 Subject: [PATCH] feat(websocket): submissions backend --- polylan_submitter/polylan_submitter/asgi.py | 8 ++- polylan_submitter/submissions/apps.py | 3 + polylan_submitter/submissions/consumers.py | 35 +++++++++++ polylan_submitter/submissions/routing.py | 6 ++ polylan_submitter/submissions/schemas.py | 13 +++- polylan_submitter/submissions/signals.py | 67 +++++++++++++++++++++ 6 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 polylan_submitter/submissions/consumers.py create mode 100644 polylan_submitter/submissions/routing.py create mode 100644 polylan_submitter/submissions/signals.py diff --git a/polylan_submitter/polylan_submitter/asgi.py b/polylan_submitter/polylan_submitter/asgi.py index 1ce2c5b..129d240 100644 --- a/polylan_submitter/polylan_submitter/asgi.py +++ b/polylan_submitter/polylan_submitter/asgi.py @@ -10,14 +10,20 @@ https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ import os from django.core.asgi import get_asgi_application -from channels.routing import ProtocolTypeRouter +from channels.routing import ProtocolTypeRouter, URLRouter +from channels.auth import AuthMiddlewareStack os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polylan_submitter.settings") django_asgi_app = get_asgi_application() +from submissions.routing import websocket_urlpatterns + application = ProtocolTypeRouter( { "http": django_asgi_app, + "websocket": AuthMiddlewareStack( + URLRouter(websocket_urlpatterns) + ), } ) diff --git a/polylan_submitter/submissions/apps.py b/polylan_submitter/submissions/apps.py index 1ffc7b6..976aa1b 100644 --- a/polylan_submitter/submissions/apps.py +++ b/polylan_submitter/submissions/apps.py @@ -4,3 +4,6 @@ from django.apps import AppConfig class SubmissionsConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "submissions" + + def ready(self): + import submissions.signals # noqa diff --git a/polylan_submitter/submissions/consumers.py b/polylan_submitter/submissions/consumers.py new file mode 100644 index 0000000..99d3914 --- /dev/null +++ b/polylan_submitter/submissions/consumers.py @@ -0,0 +1,35 @@ +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): + self.user = self.scope["user"] + + if isinstance(self.user, AnonymousUser): + await self.close() + return + + self.user_group_name = f"submissions_user_{self.user.id}" + await self.channel_layer.group_add(self.user_group_name, self.channel_name) + await self.accept() + + async def disconnect(self, close_code): + await self.channel_layer.group_discard(self.user_group_name, self.channel_name) + + async def submission_updated(self, event): + submission_data = event["submission"] + await self.send(text_data=json.dumps(submission_data)) + + async def submission_file_updated(self, event): + file_data = event["file"] + await self.send(text_data=json.dumps(file_data)) + + async def submission_crop_updated(self, event): + crop_data = event["crop"] + await self.send(text_data=json.dumps(crop_data)) diff --git a/polylan_submitter/submissions/routing.py b/polylan_submitter/submissions/routing.py new file mode 100644 index 0000000..ad55b1f --- /dev/null +++ b/polylan_submitter/submissions/routing.py @@ -0,0 +1,6 @@ +from django.urls import re_path +from submissions.consumers import SubmissionsConsumer + +websocket_urlpatterns = [ + re_path(r"ws/submissions/$", SubmissionsConsumer.as_asgi()), +] diff --git a/polylan_submitter/submissions/schemas.py b/polylan_submitter/submissions/schemas.py index ba16185..73aa8e4 100644 --- a/polylan_submitter/submissions/schemas.py +++ b/polylan_submitter/submissions/schemas.py @@ -1,6 +1,7 @@ from datetime import datetime from uuid import UUID from ninja import Schema +from pydantic import BaseModel, ConfigDict class ImageCropIn(Schema): @@ -11,7 +12,9 @@ class ImageCropIn(Schema): label: str | None = None -class ImageCropOut(Schema): +class ImageCropOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: UUID x1: int y1: int @@ -23,7 +26,9 @@ class ImageCropOut(Schema): created_at: datetime -class SubmissionFileOut(Schema): +class SubmissionFileOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: UUID original_filename: str content_type: str @@ -34,7 +39,9 @@ class SubmissionFileOut(Schema): crops: list[ImageCropOut] = [] -class SubmissionOut(Schema): +class SubmissionOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + 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 new file mode 100644 index 0000000..5bb7aa7 --- /dev/null +++ b/polylan_submitter/submissions/signals.py @@ -0,0 +1,67 @@ +import json +from django.db.models.signals import post_save +from django.dispatch import receiver +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer + +from submissions.models import Submission, SubmissionFile, ImageCrop +from submissions.schemas import SubmissionOut, SubmissionFileOut, ImageCropOut + + +channel_layer = get_channel_layer() + + +@receiver(post_save, sender=Submission) +def broadcast_submission_update(sender, instance, created, **kwargs): + if not instance.user: + return + + user_group_name = f"submissions_user_{instance.user.id}" + schema = SubmissionOut.model_validate(instance) + submission_data = schema.model_dump(mode="json") + + async_to_sync(channel_layer.group_send)( + user_group_name, + { + "type": "submission.updated", + "submission": submission_data, + } + ) + + +@receiver(post_save, sender=SubmissionFile) +def broadcast_submission_file_update(sender, instance, created, **kwargs): + submission = instance.submission + if not submission.user: + return + + user_group_name = f"submissions_user_{submission.user.id}" + schema = SubmissionFileOut.model_validate(instance) + file_data = schema.model_dump(mode="json") + + async_to_sync(channel_layer.group_send)( + user_group_name, + { + "type": "submission.file.updated", + "file": file_data, + } + ) + + +@receiver(post_save, sender=ImageCrop) +def broadcast_image_crop_update(sender, instance, created, **kwargs): + submission = instance.submission_file.submission + if not submission.user: + return + + user_group_name = f"submissions_user_{submission.user.id}" + schema = ImageCropOut.model_validate(instance) + crop_data = schema.model_dump(mode="json") + + async_to_sync(channel_layer.group_send)( + user_group_name, + { + "type": "submission.crop.updated", + "crop": crop_data, + } + )