fix(submissions): broadcast crop delete + fix for crop admin

This commit is contained in:
2026-10-16 02:19:18 +02:00
parent a110575e0a
commit 1daf2757b8
7 changed files with 194 additions and 56 deletions
+14 -3
View File
@@ -2,6 +2,8 @@ import json
from channels.generic.websocket import AsyncWebsocketConsumer
from django.contrib.auth.models import AnonymousUser
from submissions.signals import STAFF_GROUP_NAME
class SubmissionsConsumer(AsyncWebsocketConsumer):
async def connect(self):
@@ -11,12 +13,18 @@ class SubmissionsConsumer(AsyncWebsocketConsumer):
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)
self.group_names = [f"submissions_user_{self.user.id}"]
if self.user.is_staff:
self.group_names.append(STAFF_GROUP_NAME)
for group_name in self.group_names:
await self.channel_layer.group_add(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)
# closed before joining (anonymous)
for group_name in getattr(self, "group_names", []):
await self.channel_layer.group_discard(group_name, self.channel_name)
async def submission_updated(self, event):
submission_data = event["submission"]
@@ -30,5 +38,8 @@ class SubmissionsConsumer(AsyncWebsocketConsumer):
crop_data = event["crop"]
await self.send(text_data=json.dumps(crop_data))
async def submission_crop_deleted(self, event):
await self.send(text_data=json.dumps({"deleted_crop": event["crop"]}))
async def scope_updated(self, event):
await self.send(text_data=json.dumps({"scope": event["scope"]}))
+1
View File
@@ -47,6 +47,7 @@ class ImageCropIn(Schema):
class ImageCropOut(Schema):
id: UUID
submission_file_id: UUID
x1: int
y1: int
x2: int
+38 -30
View File
@@ -1,5 +1,5 @@
from django.db import transaction
from django.db.models.signals import post_save
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
@@ -8,55 +8,63 @@ from submissions.models import Submission, SubmissionFile, ImageCrop, Scope
from submissions.schemas import SubmissionOut, SubmissionFileOut, ImageCropOut
# Staff see (and OCR/crop) every player's submissions, so they get every update
STAFF_GROUP_NAME = "submissions_staff"
def _broadcast_submission_event(submission: Submission, message: dict):
channel_layer = get_channel_layer()
groups = [STAFF_GROUP_NAME]
if submission.user_id:
groups.append(f"submissions_user_{submission.user_id}")
for group_name in groups:
async_to_sync(channel_layer.group_send)(group_name, message)
@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}"
submission_data = SubmissionOut.from_orm(instance).dict()
async_to_sync(get_channel_layer().group_send)(
user_group_name,
_broadcast_submission_event(
instance,
{
"type": "submission.updated",
"submission": submission_data,
"submission": SubmissionOut.from_orm(instance).dict(),
},
)
@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}"
file_data = SubmissionFileOut.from_orm(instance).dict()
async_to_sync(get_channel_layer().group_send)(
user_group_name,
_broadcast_submission_event(
instance.submission,
{
"type": "submission.file.updated",
"file": file_data,
"file": SubmissionFileOut.from_orm(instance).dict(),
},
)
@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}"
crop_data = ImageCropOut.from_orm(instance).dict()
async_to_sync(get_channel_layer().group_send)(
user_group_name,
_broadcast_submission_event(
instance.submission_file.submission,
{
"type": "submission.crop.updated",
"crop": crop_data,
"crop": ImageCropOut.from_orm(instance).dict(),
},
)
@receiver(post_delete, sender=ImageCrop)
def broadcast_image_crop_delete(sender, instance, **kwargs):
_broadcast_submission_event(
instance.submission_file.submission,
{
"type": "submission.crop.deleted",
"crop": {
"id": str(instance.id),
"submission_file_id": str(instance.submission_file_id),
},
},
)
+53 -1
View File
@@ -11,7 +11,7 @@ from events.models import Event
from games.models import EventGame, Game
from . import tasks
from .models import Scope, Submission
from .models import ImageCrop, Scope, Submission, SubmissionFile
from .services.aandt_client.models import (
AandtApiInfosResponse200,
AandtApiMatchesResponse200,
@@ -434,3 +434,55 @@ class ListUserSubmissionsApiTests(TestCase):
self.assertEqual(
{s["id"] for s in data}, {str(self.mine.id), str(self.theirs.id)}
)
class SubmissionBroadcastTests(TestCase):
def setUp(self):
event = Event.objects.create(name="Event", path="event")
game = Game.objects.create(name="Game", path="game")
event_game = EventGame.objects.create(event=event, game=game)
scope = Scope.objects.create(event_game=event_game, name="M1")
self.player = CustomUser.objects.create_user(username="player", password="x")
submission = Submission.objects.create(
event=event, scope=scope, user=self.player
)
# bulk_create: skips the file broadcast, which would sign an S3 url
[submission_file] = SubmissionFile.objects.bulk_create(
[
SubmissionFile(
submission=submission,
file="shot.png",
original_filename="shot.png",
file_size=1,
content_type="image/png",
)
]
)
self.crop = ImageCrop.objects.create(
submission_file=submission_file, x1=0, y1=0, x2=1, y2=1
)
def test_crop_update_reaches_owner_and_staff(self):
with mock.patch("submissions.signals.get_channel_layer") as get_layer:
get_layer.return_value.group_send = mock.AsyncMock()
self.crop.save()
groups = [c.args[0] for c in get_layer.return_value.group_send.call_args_list]
self.assertEqual(
groups, ["submissions_staff", f"submissions_user_{self.player.id}"]
)
def test_crop_delete_reaches_owner_and_staff(self):
crop_id = str(self.crop.id)
with mock.patch("submissions.signals.get_channel_layer") as get_layer:
get_layer.return_value.group_send = mock.AsyncMock()
self.crop.delete()
calls = get_layer.return_value.group_send.call_args_list
self.assertEqual(
[c.args[0] for c in calls],
["submissions_staff", f"submissions_user_{self.player.id}"],
)
self.assertEqual(calls[0].args[1]["type"], "submission.crop.deleted")
self.assertEqual(calls[0].args[1]["crop"]["id"], crop_id)