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
+34 -16
View File
@@ -1,5 +1,5 @@
<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 { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core";
@@ -47,7 +47,8 @@ const userSubmissions = ref<SubmissionOut[]>([]);
const isLoadingSubmissions = ref(false);
const selectedSubmissionFile = ref<any>(null);
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 cropStartX = ref(0);
const cropStartY = ref(0);
@@ -141,23 +142,23 @@ const closeSubmissionFileModal = () => {
selectedSubmissionFile.value = null;
};
const copyOCRText = async () => {
if (selectedSubmissionFile.value?.ocr_raw_data) {
const copyOCRText = async (text: string | undefined, key: string) => {
if (!text) return;
try {
await navigator.clipboard.writeText(selectedSubmissionFile.value.ocr_raw_data);
copyFeedback.value = t("common.copied");
setTimeout(() => {
copyFeedback.value = "";
}, 2000);
await navigator.clipboard.writeText(text);
copyFeedback.value = { key, message: t("common.copied") };
} catch (err) {
copyFeedback.value = t("submissions.copyFailed");
copyFeedback.value = { key, message: t("submissions.copyFailed") };
}
setTimeout(() => {
copyFeedback.value = "";
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 rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
@@ -246,6 +247,12 @@ const redrawCropCanvas = () => {
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 () => {
if (!currentCrop.value || !selectedSubmissionFile.value) return;
@@ -262,11 +269,13 @@ 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) {
selectedSubmissionFile.value.crops = selectedSubmissionFile.value.crops || [];
if (!selectedSubmissionFile.value.crops.some((c: any) => c.id === newCrop.id)) {
selectedSubmissionFile.value.crops.push(newCrop);
}
}
currentCrop.value = null;
cropLabel.value = "";
@@ -480,6 +489,7 @@ const loadUserSubmissions = async () => {
// the next match when there's only one, so players follow along without a refresh.
useSubmissionsWebSocket({
userSubmissions,
scopeId: props.scopeId,
onScopeUpdate: (scope) => {
if (scope.id !== props.scopeId || (scope.is_active && scope.assigned)) return;
@@ -909,9 +919,10 @@ onUnmounted(() => {
<div>
<div class="flex items-center justify-between mb-2">
<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>
{{ copyFeedback || t("common.copy") }}
{{ copyLabel("file") }}
</button>
</div>
<div
@@ -948,8 +959,15 @@ onUnmounted(() => {
<div v-for="crop in selectedSubmissionFile.crops" :key="crop.id" class="space-y-2">
<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>
<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 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">
{{ crop.ocr_raw_data }}
+4
View File
@@ -1080,6 +1080,10 @@ export type ImageCropOut = {
* Id
*/
id: string;
/**
* Submission File Id
*/
submission_file_id: string;
/**
* X1
*/
@@ -5,6 +5,7 @@ import { useLiveBadge } from '@/composables/useLiveBadge'
interface SubmissionUpdate {
id: string
scope_id?: string
username?: string | null
notes?: string
files?: SubmissionFileUpdate[]
created_at: string
@@ -24,6 +25,7 @@ interface SubmissionFileUpdate {
interface ImageCropUpdate {
id: string
submission_file_id: string
x1: number
y1: number
x2: number
@@ -42,7 +44,14 @@ export interface ScopeUpdate {
assigned: boolean
}
// See broadcast_image_crop_delete in signals.py
interface DeletedCrop {
id: string
submission_file_id: string
}
type WebSocketData =
| { deleted_crop: DeletedCrop }
| SubmissionUpdate
| SubmissionFileUpdate
| ImageCropUpdate
@@ -50,10 +59,12 @@ type WebSocketData =
interface Options {
userSubmissions?: any
// Only submissions of this scope get added to the list (staff get them all)
scopeId?: string
onScopeUpdate?: (scope: ScopeUpdate) => void
}
export function useSubmissionsWebSocket({ userSubmissions, onScopeUpdate }: Options) {
export function useSubmissionsWebSocket({ userSubmissions, scopeId, onScopeUpdate }: Options) {
const isConnected = ref(false)
const wsUrl = computed(() => {
@@ -86,6 +97,8 @@ export function useSubmissionsWebSocket({ userSubmissions, onScopeUpdate }: Opti
onScopeUpdate?.(data.scope)
} else if (!userSubmissions) {
return
} else if ('deleted_crop' in data) {
handleCropDelete(data.deleted_crop)
} else if ('files' in data) {
handleSubmissionUpdate(data as SubmissionUpdate)
} else if ('original_filename' in data) {
@@ -111,6 +124,8 @@ export function useSubmissionsWebSocket({ userSubmissions, onScopeUpdate }: Opti
useLiveBadge('submissions', isConnected)
const handleSubmissionUpdate = (submission: SubmissionUpdate) => {
if (scopeId && submission.scope_id !== scopeId) return
const existingIndex = userSubmissions.value.findIndex((s: any) => s.id === submission.id)
if (existingIndex >= 0) {
@@ -137,6 +152,8 @@ export function useSubmissionsWebSocket({ userSubmissions, onScopeUpdate }: Opti
}
const handleCropUpdate = (crop: ImageCropUpdate) => {
let found = false
userSubmissions.value.forEach((submission: any) => {
if (!submission.files) return
@@ -147,10 +164,37 @@ export function useSubmissionsWebSocket({ userSubmissions, onScopeUpdate }: Opti
if (cropIndex >= 0) {
// Merge into existing object to preserve references
Object.assign(file.crops[cropIndex], crop)
found = true
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 {
+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)