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
+39 -21
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from "vue"; import { ref, onMounted, onUnmounted, computed, watch } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core"; import { useTitle } from "@vueuse/core";
@@ -47,7 +47,8 @@ const userSubmissions = ref<SubmissionOut[]>([]);
const isLoadingSubmissions = ref(false); const isLoadingSubmissions = ref(false);
const selectedSubmissionFile = ref<any>(null); const selectedSubmissionFile = ref<any>(null);
const isSubmissionFileModalOpen = ref(false); const isSubmissionFileModalOpen = ref(false);
const copyFeedback = ref<string>(""); // Feedback shown on the copy button that was clicked, keyed by "file" or crop id
const copyFeedback = ref<{ key: string; message: string } | null>(null);
const isDrawingCrop = ref(false); const isDrawingCrop = ref(false);
const cropStartX = ref(0); const cropStartX = ref(0);
const cropStartY = ref(0); const cropStartY = ref(0);
@@ -141,23 +142,23 @@ const closeSubmissionFileModal = () => {
selectedSubmissionFile.value = null; selectedSubmissionFile.value = null;
}; };
const copyOCRText = async () => { const copyOCRText = async (text: string | undefined, key: string) => {
if (selectedSubmissionFile.value?.ocr_raw_data) { if (!text) return;
try {
await navigator.clipboard.writeText(selectedSubmissionFile.value.ocr_raw_data); try {
copyFeedback.value = t("common.copied"); await navigator.clipboard.writeText(text);
setTimeout(() => { copyFeedback.value = { key, message: t("common.copied") };
copyFeedback.value = ""; } catch (err) {
}, 2000); copyFeedback.value = { key, message: t("submissions.copyFailed") };
} catch (err) {
copyFeedback.value = t("submissions.copyFailed");
setTimeout(() => {
copyFeedback.value = "";
}, 2000);
}
} }
setTimeout(() => {
if (copyFeedback.value?.key === key) copyFeedback.value = null;
}, 2000);
}; };
const copyLabel = (key: string) =>
copyFeedback.value?.key === key ? copyFeedback.value.message : t("common.copy");
const getCanvasCoordinates = (canvas: HTMLCanvasElement, clientX: number, clientY: number) => { const getCanvasCoordinates = (canvas: HTMLCanvasElement, clientX: number, clientY: number) => {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width; const scaleX = canvas.width / rect.width;
@@ -246,6 +247,12 @@ const redrawCropCanvas = () => {
img.src = selectedSubmissionFile.value?.file_url; img.src = selectedSubmissionFile.value?.file_url;
}; };
// Crops can also arrive over the websocket (e.g. drawn by another admin)
watch(
() => selectedSubmissionFile.value?.crops?.length,
() => redrawCropCanvas(),
);
const submitCrop = async () => { const submitCrop = async () => {
if (!currentCrop.value || !selectedSubmissionFile.value) return; if (!currentCrop.value || !selectedSubmissionFile.value) return;
@@ -262,10 +269,12 @@ const submitCrop = async () => {
}, },
}); });
// Add the new crop to the list immediately // Add the new crop to the list immediately, unless the websocket beat us to it
if (newCrop) { if (newCrop) {
selectedSubmissionFile.value.crops = selectedSubmissionFile.value.crops || []; selectedSubmissionFile.value.crops = selectedSubmissionFile.value.crops || [];
selectedSubmissionFile.value.crops.push(newCrop); if (!selectedSubmissionFile.value.crops.some((c: any) => c.id === newCrop.id)) {
selectedSubmissionFile.value.crops.push(newCrop);
}
} }
currentCrop.value = null; currentCrop.value = null;
@@ -480,6 +489,7 @@ const loadUserSubmissions = async () => {
// the next match when there's only one, so players follow along without a refresh. // the next match when there's only one, so players follow along without a refresh.
useSubmissionsWebSocket({ useSubmissionsWebSocket({
userSubmissions, userSubmissions,
scopeId: props.scopeId,
onScopeUpdate: (scope) => { onScopeUpdate: (scope) => {
if (scope.id !== props.scopeId || (scope.is_active && scope.assigned)) return; if (scope.id !== props.scopeId || (scope.is_active && scope.assigned)) return;
@@ -909,9 +919,10 @@ onUnmounted(() => {
<div> <div>
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<p class="text-xs font-semibold text-base-content/70">{{ t("submissions.extractedText") }}</p> <p class="text-xs font-semibold text-base-content/70">{{ t("submissions.extractedText") }}</p>
<button @click="copyOCRText" class="btn btn-xs btn-ghost gap-1"> <button @click="copyOCRText(selectedSubmissionFile.ocr_raw_data, 'file')"
class="btn btn-xs btn-ghost gap-1">
<i class="mdi mdi-content-copy"></i> <i class="mdi mdi-content-copy"></i>
{{ copyFeedback || t("common.copy") }} {{ copyLabel("file") }}
</button> </button>
</div> </div>
<div <div
@@ -948,7 +959,14 @@ onUnmounted(() => {
<div v-for="crop in selectedSubmissionFile.crops" :key="crop.id" class="space-y-2"> <div v-for="crop in selectedSubmissionFile.crops" :key="crop.id" class="space-y-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<p class="text-xs font-medium">{{ crop.label || t("submissions.cropLabelFallback", { x: crop.x1, y: crop.y1 }) }}</p> <p class="text-xs font-medium">{{ crop.label || t("submissions.cropLabelFallback", { x: crop.x1, y: crop.y1 }) }}</p>
<span :class="getOCRStatusColor(crop.ocr_status)">{{ crop.ocr_status }}</span> <div class="flex items-center gap-1">
<button v-if="crop.ocr_status === 'processed' && crop.ocr_raw_data"
@click="copyOCRText(crop.ocr_raw_data, crop.id)" class="btn btn-xs btn-ghost gap-1">
<i class="mdi mdi-content-copy"></i>
{{ copyLabel(crop.id) }}
</button>
<span :class="getOCRStatusColor(crop.ocr_status)">{{ crop.ocr_status }}</span>
</div>
</div> </div>
<div v-if="crop.ocr_status === 'processed' && crop.ocr_raw_data" <div v-if="crop.ocr_status === 'processed' && crop.ocr_raw_data"
class="bg-base-100 p-2 rounded text-xs whitespace-pre-wrap break-words font-mono max-h-32 overflow-y-auto"> class="bg-base-100 p-2 rounded text-xs whitespace-pre-wrap break-words font-mono max-h-32 overflow-y-auto">
+4
View File
@@ -1080,6 +1080,10 @@ export type ImageCropOut = {
* Id * Id
*/ */
id: string; id: string;
/**
* Submission File Id
*/
submission_file_id: string;
/** /**
* X1 * X1
*/ */
@@ -5,6 +5,7 @@ import { useLiveBadge } from '@/composables/useLiveBadge'
interface SubmissionUpdate { interface SubmissionUpdate {
id: string id: string
scope_id?: string scope_id?: string
username?: string | null
notes?: string notes?: string
files?: SubmissionFileUpdate[] files?: SubmissionFileUpdate[]
created_at: string created_at: string
@@ -24,6 +25,7 @@ interface SubmissionFileUpdate {
interface ImageCropUpdate { interface ImageCropUpdate {
id: string id: string
submission_file_id: string
x1: number x1: number
y1: number y1: number
x2: number x2: number
@@ -42,7 +44,14 @@ export interface ScopeUpdate {
assigned: boolean assigned: boolean
} }
// See broadcast_image_crop_delete in signals.py
interface DeletedCrop {
id: string
submission_file_id: string
}
type WebSocketData = type WebSocketData =
| { deleted_crop: DeletedCrop }
| SubmissionUpdate | SubmissionUpdate
| SubmissionFileUpdate | SubmissionFileUpdate
| ImageCropUpdate | ImageCropUpdate
@@ -50,10 +59,12 @@ type WebSocketData =
interface Options { interface Options {
userSubmissions?: any userSubmissions?: any
// Only submissions of this scope get added to the list (staff get them all)
scopeId?: string
onScopeUpdate?: (scope: ScopeUpdate) => void onScopeUpdate?: (scope: ScopeUpdate) => void
} }
export function useSubmissionsWebSocket({ userSubmissions, onScopeUpdate }: Options) { export function useSubmissionsWebSocket({ userSubmissions, scopeId, onScopeUpdate }: Options) {
const isConnected = ref(false) const isConnected = ref(false)
const wsUrl = computed(() => { const wsUrl = computed(() => {
@@ -86,6 +97,8 @@ export function useSubmissionsWebSocket({ userSubmissions, onScopeUpdate }: Opti
onScopeUpdate?.(data.scope) onScopeUpdate?.(data.scope)
} else if (!userSubmissions) { } else if (!userSubmissions) {
return return
} else if ('deleted_crop' in data) {
handleCropDelete(data.deleted_crop)
} else if ('files' in data) { } else if ('files' in data) {
handleSubmissionUpdate(data as SubmissionUpdate) handleSubmissionUpdate(data as SubmissionUpdate)
} else if ('original_filename' in data) { } else if ('original_filename' in data) {
@@ -111,6 +124,8 @@ export function useSubmissionsWebSocket({ userSubmissions, onScopeUpdate }: Opti
useLiveBadge('submissions', isConnected) useLiveBadge('submissions', isConnected)
const handleSubmissionUpdate = (submission: SubmissionUpdate) => { const handleSubmissionUpdate = (submission: SubmissionUpdate) => {
if (scopeId && submission.scope_id !== scopeId) return
const existingIndex = userSubmissions.value.findIndex((s: any) => s.id === submission.id) const existingIndex = userSubmissions.value.findIndex((s: any) => s.id === submission.id)
if (existingIndex >= 0) { if (existingIndex >= 0) {
@@ -137,6 +152,8 @@ export function useSubmissionsWebSocket({ userSubmissions, onScopeUpdate }: Opti
} }
const handleCropUpdate = (crop: ImageCropUpdate) => { const handleCropUpdate = (crop: ImageCropUpdate) => {
let found = false
userSubmissions.value.forEach((submission: any) => { userSubmissions.value.forEach((submission: any) => {
if (!submission.files) return if (!submission.files) return
@@ -147,10 +164,37 @@ export function useSubmissionsWebSocket({ userSubmissions, onScopeUpdate }: Opti
if (cropIndex >= 0) { if (cropIndex >= 0) {
// Merge into existing object to preserve references // Merge into existing object to preserve references
Object.assign(file.crops[cropIndex], crop) Object.assign(file.crops[cropIndex], crop)
found = true
console.log('Updated crop:', crop.id) console.log('Updated crop:', crop.id)
} }
}) })
}) })
if (!found) handleNewCrop(crop)
}
const handleCropDelete = (crop: DeletedCrop) => {
userSubmissions.value.forEach((submission: any) => {
submission.files?.forEach((file: any) => {
if (file.id !== crop.submission_file_id || !file.crops) return
file.crops = file.crops.filter((c: any) => c.id !== crop.id)
console.log('Deleted crop:', crop.id)
})
})
}
// New crop (e.g. drawn by another admin): add it to its file
const handleNewCrop = (crop: ImageCropUpdate) => {
userSubmissions.value.forEach((submission: any) => {
submission.files?.forEach((file: any) => {
if (file.id !== crop.submission_file_id) return
file.crops = file.crops || []
file.crops.push(crop)
console.log('Added crop:', crop.id)
})
})
} }
return { return {
+14 -3
View File
@@ -2,6 +2,8 @@ import json
from channels.generic.websocket import AsyncWebsocketConsumer from channels.generic.websocket import AsyncWebsocketConsumer
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from submissions.signals import STAFF_GROUP_NAME
class SubmissionsConsumer(AsyncWebsocketConsumer): class SubmissionsConsumer(AsyncWebsocketConsumer):
async def connect(self): async def connect(self):
@@ -11,12 +13,18 @@ class SubmissionsConsumer(AsyncWebsocketConsumer):
await self.close() await self.close()
return return
self.user_group_name = f"submissions_user_{self.user.id}" self.group_names = [f"submissions_user_{self.user.id}"]
await self.channel_layer.group_add(self.user_group_name, self.channel_name) 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() await self.accept()
async def disconnect(self, close_code): 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): async def submission_updated(self, event):
submission_data = event["submission"] submission_data = event["submission"]
@@ -30,5 +38,8 @@ class SubmissionsConsumer(AsyncWebsocketConsumer):
crop_data = event["crop"] crop_data = event["crop"]
await self.send(text_data=json.dumps(crop_data)) 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): async def scope_updated(self, event):
await self.send(text_data=json.dumps({"scope": event["scope"]})) await self.send(text_data=json.dumps({"scope": event["scope"]}))
+1
View File
@@ -47,6 +47,7 @@ class ImageCropIn(Schema):
class ImageCropOut(Schema): class ImageCropOut(Schema):
id: UUID id: UUID
submission_file_id: UUID
x1: int x1: int
y1: int y1: int
x2: int x2: int
+38 -30
View File
@@ -1,5 +1,5 @@
from django.db import transaction 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 django.dispatch import receiver
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer 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 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) @receiver(post_save, sender=Submission)
def broadcast_submission_update(sender, instance, created, **kwargs): def broadcast_submission_update(sender, instance, created, **kwargs):
if not instance.user: _broadcast_submission_event(
return instance,
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,
{ {
"type": "submission.updated", "type": "submission.updated",
"submission": submission_data, "submission": SubmissionOut.from_orm(instance).dict(),
}, },
) )
@receiver(post_save, sender=SubmissionFile) @receiver(post_save, sender=SubmissionFile)
def broadcast_submission_file_update(sender, instance, created, **kwargs): def broadcast_submission_file_update(sender, instance, created, **kwargs):
submission = instance.submission _broadcast_submission_event(
if not submission.user: instance.submission,
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,
{ {
"type": "submission.file.updated", "type": "submission.file.updated",
"file": file_data, "file": SubmissionFileOut.from_orm(instance).dict(),
}, },
) )
@receiver(post_save, sender=ImageCrop) @receiver(post_save, sender=ImageCrop)
def broadcast_image_crop_update(sender, instance, created, **kwargs): def broadcast_image_crop_update(sender, instance, created, **kwargs):
submission = instance.submission_file.submission _broadcast_submission_event(
if not submission.user: instance.submission_file.submission,
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,
{ {
"type": "submission.crop.updated", "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 games.models import EventGame, Game
from . import tasks from . import tasks
from .models import Scope, Submission from .models import ImageCrop, Scope, Submission, SubmissionFile
from .services.aandt_client.models import ( from .services.aandt_client.models import (
AandtApiInfosResponse200, AandtApiInfosResponse200,
AandtApiMatchesResponse200, AandtApiMatchesResponse200,
@@ -434,3 +434,55 @@ class ListUserSubmissionsApiTests(TestCase):
self.assertEqual( self.assertEqual(
{s["id"] for s in data}, {str(self.mine.id), str(self.theirs.id)} {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)