feat(websocket): frontend websocket
This commit is contained in:
@@ -22,8 +22,6 @@ from submissions.routing import websocket_urlpatterns
|
|||||||
application = ProtocolTypeRouter(
|
application = ProtocolTypeRouter(
|
||||||
{
|
{
|
||||||
"http": django_asgi_app,
|
"http": django_asgi_app,
|
||||||
"websocket": AuthMiddlewareStack(
|
"websocket": AuthMiddlewareStack(URLRouter(websocket_urlpatterns)),
|
||||||
URLRouter(websocket_urlpatterns)
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ CHANNEL_LAYERS = {
|
|||||||
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
||||||
"CONFIG": {
|
"CONFIG": {
|
||||||
"hosts": [("127.0.0.1", 6379)],
|
"hosts": [("127.0.0.1", 6379)],
|
||||||
|
"serializer_format": "cjson",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted, onUnmounted } from "vue";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
import { useUserStore } from "@/stores/user";
|
import { useUserStore } from "@/stores/user";
|
||||||
import { submissionsApiCreateSubmission, submissionsApiListUserSubmissions, submissionsApiCreateImageCrop, submissionsApiDeleteImageCrop, submissionsApiProcessFileOcr, submissionsApiProcessCropOcr } from "@/api";
|
import { submissionsApiCreateSubmission, submissionsApiListUserSubmissions, submissionsApiCreateImageCrop, submissionsApiDeleteImageCrop, submissionsApiProcessFileOcr, submissionsApiProcessCropOcr } from "@/api";
|
||||||
|
import { useSubmissionsWebSocket } from "@/composables/useSubmissionsWebSocket";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
gamePath?: string;
|
gamePath?: string;
|
||||||
@@ -39,6 +40,8 @@ const cropStartY = ref(0);
|
|||||||
const currentCrop = ref<{ x1: number; y1: number; x2: number; y2: number } | null>(null);
|
const currentCrop = ref<{ x1: number; y1: number; x2: number; y2: number } | null>(null);
|
||||||
const cropLabel = ref("");
|
const cropLabel = ref("");
|
||||||
const isCreatingCrop = ref(false);
|
const isCreatingCrop = ref(false);
|
||||||
|
const uploadLimitExceeded = ref<string>("");
|
||||||
|
const MAX_FILES = 5;
|
||||||
|
|
||||||
const openFilePreview = async (file: File) => {
|
const openFilePreview = async (file: File) => {
|
||||||
selectedFile.value = file;
|
selectedFile.value = file;
|
||||||
@@ -337,10 +340,28 @@ const getFileIcon = (file: File | null | { type?: string }) => {
|
|||||||
return "mdi-file";
|
return "mdi-file";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addFiles = (filesToAdd: File[]) => {
|
||||||
|
const totalFiles = uploadedFiles.value.length + filesToAdd.length;
|
||||||
|
|
||||||
|
if (totalFiles > MAX_FILES) {
|
||||||
|
const canAdd = MAX_FILES - uploadedFiles.value.length;
|
||||||
|
uploadLimitExceeded.value = `Maximum ${MAX_FILES} files allowed. You can add ${canAdd} more file${canAdd !== 1 ? 's' : ''}.`;
|
||||||
|
setTimeout(() => {
|
||||||
|
uploadLimitExceeded.value = "";
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// Add only the files that fit
|
||||||
|
uploadedFiles.value.push(...filesToAdd.slice(0, canAdd));
|
||||||
|
} else {
|
||||||
|
uploadedFiles.value.push(...filesToAdd);
|
||||||
|
uploadLimitExceeded.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleFileUpload = (event: Event) => {
|
const handleFileUpload = (event: Event) => {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
if (input.files) {
|
if (input.files) {
|
||||||
uploadedFiles.value = Array.from(input.files);
|
addFiles(Array.from(input.files));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -362,10 +383,11 @@ const handleDrop = (event: DragEvent) => {
|
|||||||
isDragover.value = false;
|
isDragover.value = false;
|
||||||
|
|
||||||
if (event.dataTransfer?.files) {
|
if (event.dataTransfer?.files) {
|
||||||
uploadedFiles.value = Array.from(event.dataTransfer.files);
|
addFiles(Array.from(event.dataTransfer.files));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const removeFile = (index: number) => {
|
const removeFile = (index: number) => {
|
||||||
uploadedFiles.value.splice(index, 1);
|
uploadedFiles.value.splice(index, 1);
|
||||||
};
|
};
|
||||||
@@ -444,9 +466,44 @@ const loadUserSubmissions = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { isConnected: wsConnected } = useSubmissionsWebSocket(userSubmissions);
|
||||||
|
const isUploadAreaHovered = ref(false);
|
||||||
|
|
||||||
|
const handlePasteOnUploadArea = (event: ClipboardEvent) => {
|
||||||
|
if (!isUploadAreaHovered.value) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
const clipboardItems = event.clipboardData?.items;
|
||||||
|
|
||||||
|
if (!clipboardItems) return;
|
||||||
|
|
||||||
|
const filesToAdd: File[] = [];
|
||||||
|
for (let i = 0; i < clipboardItems.length; i++) {
|
||||||
|
const item = clipboardItems[i];
|
||||||
|
|
||||||
|
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) {
|
||||||
|
filesToAdd.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filesToAdd.length > 0) {
|
||||||
|
addFiles(filesToAdd);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
userStore.loadUserData();
|
userStore.loadUserData();
|
||||||
loadUserSubmissions();
|
loadUserSubmissions();
|
||||||
|
|
||||||
|
// Add paste listener (only processes if hovering upload area)
|
||||||
|
window.addEventListener('paste', handlePasteOnUploadArea);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('paste', handlePasteOnUploadArea);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -461,6 +518,15 @@ onMounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
<h1 class="text-xl font-bold">{{ gameName }} Submissions</h1>
|
<h1 class="text-xl font-bold">{{ gameName }} Submissions</h1>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
|
<!-- WebSocket Status Indicator -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div :class="['badge', wsConnected ? 'badge-success' : 'badge-warning']">
|
||||||
|
<i :class="['mdi mr-1', wsConnected ? 'mdi-wifi' : 'mdi-wifi-off']"></i>
|
||||||
|
{{ wsConnected ? 'Live' : 'Offline' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<a href="/api/docs" class="btn btn-xs">API docs</a>
|
<a href="/api/docs" class="btn btn-xs">API docs</a>
|
||||||
<a v-if="userInfo.isStaff" href="/admin" class="btn btn-xs btn-warning">Admin panel</a>
|
<a v-if="userInfo.isStaff" href="/admin" class="btn btn-xs btn-warning">Admin panel</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -496,7 +562,7 @@ onMounted(() => {
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<!-- Upload Area -->
|
<!-- Upload Area -->
|
||||||
<div @dragover="handleDragover" @dragleave="handleDragleave" @drop="handleDrop" :class="[
|
<div @dragover="handleDragover" @dragleave="handleDragleave" @drop="handleDrop" @mouseenter="isUploadAreaHovered = true" @mouseleave="isUploadAreaHovered = false" :class="[
|
||||||
'border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer bg-base-200/50 mb-6',
|
'border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer bg-base-200/50 mb-6',
|
||||||
isDragover ? 'border-primary bg-primary/10' : 'border-base-300 hover:border-primary'
|
isDragover ? 'border-primary bg-primary/10' : 'border-base-300 hover:border-primary'
|
||||||
]">
|
]">
|
||||||
@@ -507,12 +573,23 @@ onMounted(() => {
|
|||||||
isDragover ? 'mdi-cloud-check text-primary' : 'mdi-file-upload text-base-content/50'
|
isDragover ? 'mdi-cloud-check text-primary' : 'mdi-file-upload text-base-content/50'
|
||||||
]"></i>
|
]"></i>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-semibold">Click to upload or drag and drop</p>
|
<p class="font-semibold">Click to upload, drag and drop, or paste</p>
|
||||||
<p class="text-sm text-base-content/70">Any file type supported</p>
|
<p class="text-sm text-base-content/70">Any file type supported • Images from clipboard welcome</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- File Limit Warning -->
|
||||||
|
<div v-if="uploadLimitExceeded" class="alert alert-warning mb-4">
|
||||||
|
<i class="mdi mdi-alert-circle"></i>
|
||||||
|
<span>{{ uploadLimitExceeded }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File Count -->
|
||||||
|
<div v-if="uploadedFiles.length > 0" class="mb-3 text-sm text-base-content/70">
|
||||||
|
<span class="font-medium">{{ uploadedFiles.length }}/{{ MAX_FILES }}</span> files selected
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Uploaded Files List -->
|
<!-- Uploaded Files List -->
|
||||||
<div v-if="uploadedFiles.length > 0" class="mb-6">
|
<div v-if="uploadedFiles.length > 0" class="mb-6">
|
||||||
<p class="font-semibold mb-3">Selected Files:</p>
|
<p class="font-semibold mb-3">Selected Files:</p>
|
||||||
@@ -809,43 +886,75 @@ onMounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 overflow-auto border border-base-300 rounded-lg p-4 bg-base-200">
|
<div class="flex-1 overflow-auto border border-base-300 rounded-lg p-4 bg-base-200">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- File OCR Data -->
|
||||||
<div v-if="selectedSubmissionFile?.ocr_status === 'processed' && selectedSubmissionFile.ocr_raw_data">
|
<div v-if="selectedSubmissionFile?.ocr_status === 'processed' && selectedSubmissionFile.ocr_raw_data">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<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">Extracted Text:</p>
|
<p class="text-xs font-semibold text-base-content/70">File - Extracted Text:</p>
|
||||||
<button @click="copyOCRText" class="btn btn-xs btn-ghost gap-1">
|
<button @click="copyOCRText" class="btn btn-xs btn-ghost gap-1">
|
||||||
<i class="mdi mdi-content-copy"></i>
|
<i class="mdi mdi-content-copy"></i>
|
||||||
{{ copyFeedback || "Copy" }}
|
{{ copyFeedback || "Copy" }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="bg-base-100 p-3 rounded text-xs whitespace-pre-wrap break-words font-mono overflow-y-auto h-full">
|
class="bg-base-100 p-3 rounded text-xs whitespace-pre-wrap break-words font-mono overflow-y-auto">
|
||||||
{{ selectedSubmissionFile.ocr_raw_data }}
|
{{ selectedSubmissionFile.ocr_raw_data }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="selectedSubmissionFile?.ocr_status === 'processing'"
|
<div v-else-if="selectedSubmissionFile?.ocr_status === 'processing'"
|
||||||
class="flex items-center justify-center h-full">
|
class="flex items-center justify-center py-8">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<span class="loading loading-spinner loading-lg"></span>
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
<p class="text-base-content/70 mt-4">OCR is being processed...</p>
|
<p class="text-base-content/70 mt-4">OCR is being processed...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="selectedSubmissionFile?.ocr_status === 'error'"
|
<div v-else-if="selectedSubmissionFile?.ocr_status === 'error'"
|
||||||
class="flex items-center justify-center h-full">
|
class="flex items-center justify-center py-8">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<i class="mdi mdi-alert-circle text-4xl text-error mb-2"></i>
|
<i class="mdi mdi-alert-circle text-4xl text-error mb-2"></i>
|
||||||
<p class="text-base-content/70">OCR processing failed</p>
|
<p class="text-base-content/70">OCR processing failed</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex items-center justify-center h-full">
|
<div v-else-if="!selectedSubmissionFile?.ocr_raw_data"
|
||||||
|
class="flex items-center justify-center py-8">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<i class="mdi mdi-clock-outline text-4xl text-base-content/30 mb-2"></i>
|
<i class="mdi mdi-clock-outline text-4xl text-base-content/30 mb-2"></i>
|
||||||
<p class="text-base-content/70">Waiting for OCR processing...</p>
|
<p class="text-base-content/70">Waiting for OCR processing...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Crops OCR Data -->
|
||||||
|
<div v-if="selectedSubmissionFile?.crops?.length"
|
||||||
|
class="space-y-3 pt-4 border-t border-base-300">
|
||||||
|
<p class="text-xs font-semibold text-base-content/70">Crop OCR Results ({{ selectedSubmissionFile.crops.length }}):</p>
|
||||||
|
<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 || `Crop (${crop.x1}, ${crop.y1})` }}</p>
|
||||||
|
<span :class="getOCRStatusColor(crop.ocr_status)">{{ crop.ocr_status }}</span>
|
||||||
|
</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 }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="crop.ocr_status === 'processing'" class="text-xs text-base-content/70 text-center py-2">
|
||||||
|
<span class="loading loading-spinner loading-sm inline-block"></span>
|
||||||
|
Processing...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="crop.ocr_status === 'error'" class="text-xs text-error text-center py-2">
|
||||||
|
<i class="mdi mdi-alert-circle mr-1"></i>
|
||||||
|
Processing failed
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-xs text-base-content/50 text-center py-2">
|
||||||
|
<i class="mdi mdi-clock-outline mr-1"></i>
|
||||||
|
Pending
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { ref, computed, onBeforeUnmount } from 'vue'
|
||||||
|
import { useWebSocket } from '@vueuse/core'
|
||||||
|
|
||||||
|
interface SubmissionUpdate {
|
||||||
|
id: string
|
||||||
|
game_id?: number
|
||||||
|
notes?: string
|
||||||
|
files?: SubmissionFileUpdate[]
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubmissionFileUpdate {
|
||||||
|
id: string
|
||||||
|
original_filename: string
|
||||||
|
content_type: string
|
||||||
|
file_size: number
|
||||||
|
file_url?: string
|
||||||
|
ocr_status: string
|
||||||
|
ocr_raw_data: string
|
||||||
|
crops?: ImageCropUpdate[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImageCropUpdate {
|
||||||
|
id: string
|
||||||
|
x1: number
|
||||||
|
y1: number
|
||||||
|
x2: number
|
||||||
|
y2: number
|
||||||
|
label: string
|
||||||
|
ocr_status: string
|
||||||
|
ocr_raw_data: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebSocketData = SubmissionUpdate | SubmissionFileUpdate | ImageCropUpdate
|
||||||
|
|
||||||
|
export function useSubmissionsWebSocket(userSubmissions: any) {
|
||||||
|
const isConnected = ref(false)
|
||||||
|
const reconnectAttempts = ref(0)
|
||||||
|
const maxReconnectAttempts = 10
|
||||||
|
const reconnectDelay = ref(3000)
|
||||||
|
|
||||||
|
const wsUrl = computed(() => {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
return `${protocol}//${window.location.host}/ws/submissions/`
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
status,
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
} = useWebSocket(wsUrl.value, {
|
||||||
|
onConnected: () => {
|
||||||
|
isConnected.value = true
|
||||||
|
reconnectAttempts.value = 0
|
||||||
|
reconnectDelay.value = 3000
|
||||||
|
console.log('✓ Connected to submissions WebSocket')
|
||||||
|
},
|
||||||
|
onDisconnected: () => {
|
||||||
|
isConnected.value = false
|
||||||
|
console.log('✗ Disconnected from submissions WebSocket, attempting to reconnect...')
|
||||||
|
attemptReconnect()
|
||||||
|
},
|
||||||
|
onError: (event) => {
|
||||||
|
console.error('WebSocket error:', event)
|
||||||
|
},
|
||||||
|
onMessage: (_ws, event) => {
|
||||||
|
try {
|
||||||
|
const messageData = event.data as string
|
||||||
|
const data = JSON.parse(messageData) as WebSocketData
|
||||||
|
|
||||||
|
if ('files' in data) {
|
||||||
|
handleSubmissionUpdate(data as SubmissionUpdate)
|
||||||
|
} else if ('original_filename' in data) {
|
||||||
|
handleFileUpdate(data as SubmissionFileUpdate)
|
||||||
|
} else if ('x1' in data) {
|
||||||
|
handleCropUpdate(data as ImageCropUpdate)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse WebSocket message:', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
immediate: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const attemptReconnect = () => {
|
||||||
|
if (reconnectAttempts.value >= maxReconnectAttempts) {
|
||||||
|
console.error('Max reconnection attempts reached')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectAttempts.value++
|
||||||
|
const delay = reconnectDelay.value * Math.pow(1.5, reconnectAttempts.value - 1)
|
||||||
|
|
||||||
|
console.log(`Reconnecting (attempt ${reconnectAttempts.value}/${maxReconnectAttempts}) in ${Math.round(delay / 1000)}s...`)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
open()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmissionUpdate = (submission: SubmissionUpdate) => {
|
||||||
|
const existingIndex = userSubmissions.value.findIndex((s: any) => s.id === submission.id)
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
// Merge into existing object to preserve references
|
||||||
|
Object.assign(userSubmissions.value[existingIndex], submission)
|
||||||
|
} else {
|
||||||
|
userSubmissions.value.unshift(submission)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Updated submission:', submission.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileUpdate = (file: SubmissionFileUpdate) => {
|
||||||
|
userSubmissions.value.forEach((submission: any) => {
|
||||||
|
if (!submission.files) return
|
||||||
|
|
||||||
|
const fileIndex = submission.files.findIndex((f: any) => f.id === file.id)
|
||||||
|
if (fileIndex >= 0) {
|
||||||
|
// Merge into existing object to preserve references
|
||||||
|
Object.assign(submission.files[fileIndex], file)
|
||||||
|
console.log('Updated file:', file.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCropUpdate = (crop: ImageCropUpdate) => {
|
||||||
|
userSubmissions.value.forEach((submission: any) => {
|
||||||
|
if (!submission.files) return
|
||||||
|
|
||||||
|
submission.files.forEach((file: any) => {
|
||||||
|
if (!file.crops) return
|
||||||
|
|
||||||
|
const cropIndex = file.crops.findIndex((c: any) => c.id === crop.id)
|
||||||
|
if (cropIndex >= 0) {
|
||||||
|
// Merge into existing object to preserve references
|
||||||
|
Object.assign(file.crops[cropIndex], crop)
|
||||||
|
console.log('Updated crop:', crop.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
close()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
isConnected,
|
||||||
|
status,
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,17 @@
|
|||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
|
|
||||||
|
from channels_redis.serializers import JSONSerializer, registry
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
class CJSONSerializer(JSONSerializer):
|
||||||
|
"""Override `JSONSerializer` to use `DjangoJSONEncoder`."""
|
||||||
|
|
||||||
|
def as_bytes(self, message, *args, **kwargs): # noqa: ANN001 ANN002 ANN003 ANN201
|
||||||
|
message = json.dumps(message, *args, **kwargs, cls=DjangoJSONEncoder)
|
||||||
|
return message.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
class SubmissionsConfig(AppConfig):
|
class SubmissionsConfig(AppConfig):
|
||||||
@@ -6,4 +19,6 @@ class SubmissionsConfig(AppConfig):
|
|||||||
name = "submissions"
|
name = "submissions"
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
|
registry.register_serializer("cjson", CJSONSerializer)
|
||||||
|
|
||||||
import submissions.signals # noqa
|
import submissions.signals # noqa
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||||
from channels.db import database_sync_to_async
|
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
|
||||||
from submissions.models import Submission, SubmissionFile, ImageCrop
|
|
||||||
from submissions.schemas import SubmissionOut, SubmissionFileOut, ImageCropOut
|
|
||||||
|
|
||||||
|
|
||||||
class SubmissionsConsumer(AsyncWebsocketConsumer):
|
class SubmissionsConsumer(AsyncWebsocketConsumer):
|
||||||
async def connect(self):
|
async def connect(self):
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from ninja import Schema
|
from ninja import Schema
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
class ImageCropIn(Schema):
|
class ImageCropIn(Schema):
|
||||||
@@ -12,9 +11,7 @@ class ImageCropIn(Schema):
|
|||||||
label: str | None = None
|
label: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ImageCropOut(BaseModel):
|
class ImageCropOut(Schema):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
id: UUID
|
id: UUID
|
||||||
x1: int
|
x1: int
|
||||||
y1: int
|
y1: int
|
||||||
@@ -26,9 +23,7 @@ class ImageCropOut(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class SubmissionFileOut(BaseModel):
|
class SubmissionFileOut(Schema):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
id: UUID
|
id: UUID
|
||||||
original_filename: str
|
original_filename: str
|
||||||
content_type: str
|
content_type: str
|
||||||
@@ -39,9 +34,7 @@ class SubmissionFileOut(BaseModel):
|
|||||||
crops: list[ImageCropOut] = []
|
crops: list[ImageCropOut] = []
|
||||||
|
|
||||||
|
|
||||||
class SubmissionOut(BaseModel):
|
class SubmissionOut(Schema):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
id: UUID
|
id: UUID
|
||||||
game_id: int | None = None
|
game_id: int | None = None
|
||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import json
|
|
||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import 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
|
||||||
@@ -17,15 +16,14 @@ def broadcast_submission_update(sender, instance, created, **kwargs):
|
|||||||
return
|
return
|
||||||
|
|
||||||
user_group_name = f"submissions_user_{instance.user.id}"
|
user_group_name = f"submissions_user_{instance.user.id}"
|
||||||
schema = SubmissionOut.model_validate(instance)
|
submission_data = SubmissionOut.from_orm(instance).dict()
|
||||||
submission_data = schema.model_dump(mode="json")
|
|
||||||
|
|
||||||
async_to_sync(channel_layer.group_send)(
|
async_to_sync(channel_layer.group_send)(
|
||||||
user_group_name,
|
user_group_name,
|
||||||
{
|
{
|
||||||
"type": "submission.updated",
|
"type": "submission.updated",
|
||||||
"submission": submission_data,
|
"submission": submission_data,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -36,15 +34,14 @@ def broadcast_submission_file_update(sender, instance, created, **kwargs):
|
|||||||
return
|
return
|
||||||
|
|
||||||
user_group_name = f"submissions_user_{submission.user.id}"
|
user_group_name = f"submissions_user_{submission.user.id}"
|
||||||
schema = SubmissionFileOut.model_validate(instance)
|
file_data = SubmissionFileOut.from_orm(instance).dict()
|
||||||
file_data = schema.model_dump(mode="json")
|
|
||||||
|
|
||||||
async_to_sync(channel_layer.group_send)(
|
async_to_sync(channel_layer.group_send)(
|
||||||
user_group_name,
|
user_group_name,
|
||||||
{
|
{
|
||||||
"type": "submission.file.updated",
|
"type": "submission.file.updated",
|
||||||
"file": file_data,
|
"file": file_data,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -55,13 +52,12 @@ def broadcast_image_crop_update(sender, instance, created, **kwargs):
|
|||||||
return
|
return
|
||||||
|
|
||||||
user_group_name = f"submissions_user_{submission.user.id}"
|
user_group_name = f"submissions_user_{submission.user.id}"
|
||||||
schema = ImageCropOut.model_validate(instance)
|
crop_data = ImageCropOut.from_orm(instance).dict()
|
||||||
crop_data = schema.model_dump(mode="json")
|
|
||||||
|
|
||||||
async_to_sync(channel_layer.group_send)(
|
async_to_sync(channel_layer.group_send)(
|
||||||
user_group_name,
|
user_group_name,
|
||||||
{
|
{
|
||||||
"type": "submission.crop.updated",
|
"type": "submission.crop.updated",
|
||||||
"crop": crop_data,
|
"crop": crop_data,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,25 +5,28 @@ from PIL import Image
|
|||||||
import pytesseract
|
import pytesseract
|
||||||
|
|
||||||
|
|
||||||
def _process_ocr_file(file) -> bool:
|
def _process_ocr_file(obj, file, crop: ImageCrop | None = None) -> bool:
|
||||||
if file.ocr_status != OCRStatus.TO_PROCESS:
|
if obj.ocr_status != OCRStatus.TO_PROCESS:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
file.ocr_status = OCRStatus.PROCESSING
|
obj.ocr_status = OCRStatus.PROCESSING
|
||||||
file.save(update_fields=["ocr_status"])
|
obj.save(update_fields=["ocr_status"])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
file.ocr_raw_data = pytesseract.image_to_string(Image.open(file.file))
|
image = Image.open(file)
|
||||||
|
|
||||||
except Exception:
|
if crop:
|
||||||
file.ocr_status = OCRStatus.ERROR
|
image = image.crop((crop.x1, crop.y1, crop.x2, crop.y2))
|
||||||
file.save(update_fields=["ocr_status"])
|
|
||||||
|
|
||||||
return False
|
obj.ocr_raw_data = pytesseract.image_to_string(image)
|
||||||
|
obj.ocr_status = OCRStatus.PROCESSED
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
obj.ocr_status = OCRStatus.ERROR
|
||||||
|
raise e
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
file.ocr_status = OCRStatus.PROCESSED
|
obj.save(update_fields=["ocr_status", "ocr_raw_data"])
|
||||||
file.save(update_fields=["ocr_status", "ocr_raw_data"])
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -42,7 +45,7 @@ def process_submission_file(file_id: str):
|
|||||||
if not file:
|
if not file:
|
||||||
return
|
return
|
||||||
|
|
||||||
_process_ocr_file(file)
|
_process_ocr_file(file, file.file)
|
||||||
|
|
||||||
process_crops.enqueue(file_id)
|
process_crops.enqueue(file_id)
|
||||||
|
|
||||||
@@ -65,4 +68,4 @@ def process_crop(crop_id: str):
|
|||||||
if not crop:
|
if not crop:
|
||||||
return
|
return
|
||||||
|
|
||||||
_process_ocr_file(crop)
|
_process_ocr_file(crop, crop.submission_file.file, crop=crop)
|
||||||
|
|||||||
Reference in New Issue
Block a user