fix(submissions): broadcast crop delete + fix for crop admin
This commit is contained in:
@@ -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 }}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"]}))
|
||||
|
||||
@@ -47,6 +47,7 @@ class ImageCropIn(Schema):
|
||||
|
||||
class ImageCropOut(Schema):
|
||||
id: UUID
|
||||
submission_file_id: UUID
|
||||
x1: int
|
||||
y1: int
|
||||
x2: int
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user