diff --git a/opus_submitter/src/App.vue b/opus_submitter/src/App.vue index c464620..7806048 100644 --- a/opus_submitter/src/App.vue +++ b/opus_submitter/src/App.vue @@ -1,5 +1,5 @@ - - diff --git a/opus_submitter/src/components/SubmissionForm.vue b/opus_submitter/src/components/SubmissionForm.vue index 5aaecfb..6aa76a7 100644 --- a/opus_submitter/src/components/SubmissionForm.vue +++ b/opus_submitter/src/components/SubmissionForm.vue @@ -33,21 +33,29 @@ - +
Manual Puzzle Selection Required
- {{ filesNeedingManualSelection.length }} file(s) have low OCR - confidence for puzzle names. Please select the correct puzzle for - each file before submitting. + {{ submissionFilesNeedingManualSelection.length }} file(s) have + low OCR confidence for puzzle names. Please select the correct + puzzle for each file before submitting.
+ +
@@ -74,6 +82,7 @@ type="checkbox" v-model="manualValidationRequested" class="checkbox checkbox-primary" + :disabled="hasLowConfidence" />
Submitting... - - Select Puzzles ({{ filesNeedingManualSelection.length }} + + Select Puzzles ({{ submissionFilesNeedingManualSelection.length }} remaining) Submit Solution @@ -116,26 +125,26 @@ import { ref, computed, watch } from "vue"; import FileUpload from "@/components/FileUpload.vue"; import type { SteamCollectionItem, SubmissionFile } from "@/types"; +import { useUploadsStore } from "@/stores/uploads"; +import { useSubmissionsStore } from "@/stores/submissions"; +import { storeToRefs } from "pinia"; interface Props { puzzles: SteamCollectionItem[]; findPuzzleByName: (name: string) => SteamCollectionItem | null; } -interface Emits { - submit: [ - submissionData: { - files: SubmissionFile[]; - notes?: string; - manualValidationRequested?: boolean; - }, - ]; -} - const props = defineProps(); -const emit = defineEmits(); -const submissionFiles = ref([]); +const uploadsStore = useUploadsStore(); +const { + submissionFiles, + hasLowConfidence, + submissionFilesNeedingManualSelection, +} = storeToRefs(uploadsStore); +const { clearFiles, processLowConfidenceOCRFiles } = uploadsStore; +const { handleSubmission } = useSubmissionsStore(); + const notes = ref(""); const manualValidationRequested = ref(false); const isSubmitting = ref(false); @@ -151,6 +160,12 @@ const canSubmit = computed(() => { return hasFiles && !isSubmitting.value && noManualSelectionNeeded; }); +watch(hasLowConfidence, (newValue) => { + if (newValue) { + manualValidationRequested.value = true; + } +}); + // Group files by detected puzzle const responsesByPuzzle = computed(() => { const grouped: Record< @@ -176,52 +191,22 @@ const responsesByPuzzle = computed(() => { return grouped; }); -// Count files that need manual puzzle selection -const filesNeedingManualSelection = computed(() => { - return submissionFiles.value.filter( - (file) => file.needsManualPuzzleSelection, - ); -}); - -// Check if any OCR confidence is below 50% -const hasLowConfidence = computed(() => { - return submissionFiles.value.some((file) => { - if (!file.ocrData?.confidence) return false; - return ( - file.ocrData.confidence.cost < 0.5 || - file.ocrData.confidence.cycles < 0.5 || - file.ocrData.confidence.area < 0.5 - ); - }); -}); - -// Auto-check manual validation when confidence is low -watch( - hasLowConfidence, - (newValue) => { - console.log(hasLowConfidence.value, newValue); - if (newValue && !manualValidationRequested.value) { - manualValidationRequested.value = true; - } - }, - { immediate: true }, -); - const handleSubmit = async () => { if (!canSubmit.value) return; isSubmitting.value = true; try { - // Emit the files and notes for the parent to handle API submission - emit("submit", { + // Emit the files and notes for the store to handle API submission + handleSubmission({ files: submissionFiles.value, notes: notes.value.trim() || undefined, - manualValidationRequested: manualValidationRequested.value, + manualValidationRequested: + hasLowConfidence.value || manualValidationRequested.value, }); // Reset form - submissionFiles.value = []; + clearFiles(); notes.value = ""; manualValidationRequested.value = false; } catch (error) { diff --git a/opus_submitter/src/services/ocrService.ts b/opus_submitter/src/services/ocrService.ts index f7ef638..79eecbd 100644 --- a/opus_submitter/src/services/ocrService.ts +++ b/opus_submitter/src/services/ocrService.ts @@ -35,7 +35,7 @@ export class OpusMagnumOCRService { async initialize(): Promise { if (this.worker) return; - + this.worker = await createWorker('eng'); await this.worker.setParameters({ tessedit_ocr_engine_mode: '3', @@ -62,29 +62,29 @@ export class OpusMagnumOCRService { await this.worker.setParameters({ // Disable all system dictionaries to prevent interference load_system_dawg: '0', - load_freq_dawg: '0', + load_freq_dawg: '0', load_punc_dawg: '0', load_number_dawg: '0', load_unambig_dawg: '0', load_bigram_dawg: '0', load_fixed_length_dawgs: '0', - + // Use only characters from our puzzle names tessedit_char_whitelist: this.getPuzzleCharacterSet(), - + // Optimize for single words/short phrases tessedit_pageseg_mode: 8 as any, // Single word - + // Increase penalties for non-dictionary words segment_penalty_dict_nonword: '2.0', segment_penalty_dict_frequent_word: '0.001', segment_penalty_dict_case_ok: '0.001', segment_penalty_dict_case_bad: '0.1', - + // Make OCR more conservative about character recognition classify_enable_learning: '0', classify_enable_adaptive_matcher: '1', - + // Preserve word boundaries preserve_interword_spaces: '1' }); @@ -120,13 +120,13 @@ export class OpusMagnumOCRService { // Convert file to image element for canvas processing const imageUrl = URL.createObjectURL(imageFile); const img = new Image(); - + return new Promise((resolve, reject) => { img.onload = async () => { try { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d')!; - + canvas.width = img.width; canvas.height = img.height; ctx.drawImage(img, 0, 0); @@ -138,10 +138,10 @@ export class OpusMagnumOCRService { for (const [key, region] of Object.entries(this.regions)) { const regionCanvas = document.createElement('canvas'); const regionCtx = regionCanvas.getContext('2d')!; - + regionCanvas.width = region.width; regionCanvas.height = region.height; - + // Extract region from main image regionCtx.drawImage( canvas, @@ -178,7 +178,7 @@ export class OpusMagnumOCRService { // Perform OCR on the region const { data: { text, confidence } } = await this.worker!.recognize(regionCanvas); let cleanText = text.trim(); - + // Store the confidence score for this field confidenceScores[key] = confidence / 100; // Tesseract returns 0-100, we want 0-1 @@ -203,7 +203,7 @@ export class OpusMagnumOCRService { } else if (key === 'puzzle') { // Post-process puzzle names with aggressive matching to force selection from available puzzles cleanText = this.findBestPuzzleMatch(cleanText); - + // If we still don't have a match and we have available puzzles, force the best match if (this.availablePuzzleNames.length > 0 && !this.availablePuzzleNames.includes(cleanText)) { const forcedMatch = this.findBestPuzzleMatchForced(cleanText); @@ -218,13 +218,13 @@ export class OpusMagnumOCRService { } URL.revokeObjectURL(imageUrl); - + // Calculate overall confidence as the average of all field confidences const confidenceValues = Object.values(confidenceScores); - const overallConfidence = confidenceValues.length > 0 - ? confidenceValues.reduce((sum, conf) => sum + conf, 0) / confidenceValues.length + const overallConfidence = confidenceValues.length > 0 + ? confidenceValues.reduce((sum, conf) => sum + conf, 0) / confidenceValues.length : 0; - + resolve({ puzzle: results.puzzle || '', cost: results.cost || '', @@ -235,7 +235,7 @@ export class OpusMagnumOCRService { cost: confidenceScores.cost || 0, cycles: confidenceScores.cycles || 0, area: confidenceScores.area || 0, - overall: overallConfidence + overall: overallConfidence, } }); } catch (error) { @@ -256,14 +256,14 @@ export class OpusMagnumOCRService { private preprocessImage(imageData: ImageData): void { // Convert to grayscale and invert (similar to cv2.bitwise_not in main.py) const data = imageData.data; - + for (let i = 0; i < data.length; i += 4) { // Convert to grayscale const gray = Math.round(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]); - + // Invert the grayscale value const inverted = 255 - gray; - + data[i] = inverted; // Red data[i + 1] = inverted; // Green data[i + 2] = inverted; // Blue @@ -276,10 +276,10 @@ export class OpusMagnumOCRService { */ private levenshteinDistance(str1: string, str2: string): number { const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null)); - + for (let i = 0; i <= str1.length; i++) matrix[0][i] = i; for (let j = 0; j <= str2.length; j++) matrix[j][0] = j; - + for (let j = 1; j <= str2.length; j++) { for (let i = 1; i <= str1.length; i++) { const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1; @@ -290,7 +290,7 @@ export class OpusMagnumOCRService { ); } } - + return matrix[str2.length][str1.length]; } @@ -304,7 +304,7 @@ export class OpusMagnumOCRService { const cleanedOcr = ocrText.trim(); if (!cleanedOcr) return ''; - + // Strategy 1: Exact match (case insensitive) const exactMatch = this.availablePuzzleNames.find( name => name.toLowerCase() === cleanedOcr.toLowerCase() @@ -314,31 +314,31 @@ export class OpusMagnumOCRService { // Strategy 2: Substring match (either direction) const substringMatch = this.availablePuzzleNames.find( name => name.toLowerCase().includes(cleanedOcr.toLowerCase()) || - cleanedOcr.toLowerCase().includes(name.toLowerCase()) + cleanedOcr.toLowerCase().includes(name.toLowerCase()) ); if (substringMatch) return substringMatch; // Strategy 3: Multiple fuzzy matching approaches let bestMatch = cleanedOcr; let bestScore = 0; - + for (const puzzleName of this.availablePuzzleNames) { const scores = [ this.calculateLevenshteinSimilarity(cleanedOcr, puzzleName), this.calculateJaroWinklerSimilarity(cleanedOcr, puzzleName), this.calculateNGramSimilarity(cleanedOcr, puzzleName, 2) ]; - + // Use the maximum score from all algorithms const maxScore = Math.max(...scores); - + // Lower threshold for better matching - force selection even with moderate confidence if (maxScore > bestScore && maxScore > 0.4) { bestScore = maxScore; bestMatch = puzzleName; } } - + // Strategy 4: If no good match found, try character-based matching if (bestScore < 0.6) { const charMatch = this.findBestCharacterMatch(cleanedOcr); @@ -346,7 +346,7 @@ export class OpusMagnumOCRService { bestMatch = charMatch; } } - + return bestMatch; } @@ -365,23 +365,23 @@ export class OpusMagnumOCRService { private calculateJaroWinklerSimilarity(str1: string, str2: string): number { const s1 = str1.toLowerCase(); const s2 = str2.toLowerCase(); - + if (s1 === s2) return 1; - + const matchWindow = Math.floor(Math.max(s1.length, s2.length) / 2) - 1; if (matchWindow < 0) return 0; - + const s1Matches = new Array(s1.length).fill(false); const s2Matches = new Array(s2.length).fill(false); - + let matches = 0; let transpositions = 0; - + // Find matches for (let i = 0; i < s1.length; i++) { const start = Math.max(0, i - matchWindow); const end = Math.min(i + matchWindow + 1, s2.length); - + for (let j = start; j < end; j++) { if (s2Matches[j] || s1[i] !== s2[j]) continue; s1Matches[i] = true; @@ -390,9 +390,9 @@ export class OpusMagnumOCRService { break; } } - + if (matches === 0) return 0; - + // Count transpositions let k = 0; for (let i = 0; i < s1.length; i++) { @@ -401,16 +401,16 @@ export class OpusMagnumOCRService { if (s1[i] !== s2[k]) transpositions++; k++; } - + const jaro = (matches / s1.length + matches / s2.length + (matches - transpositions / 2) / matches) / 3; - + // Jaro-Winkler bonus for common prefix let prefix = 0; for (let i = 0; i < Math.min(s1.length, s2.length, 4); i++) { if (s1[i] === s2[i]) prefix++; else break; } - + return jaro + (0.1 * prefix * (1 - jaro)); } @@ -420,24 +420,24 @@ export class OpusMagnumOCRService { private calculateNGramSimilarity(str1: string, str2: string, n: number): number { const s1 = str1.toLowerCase(); const s2 = str2.toLowerCase(); - + if (s1 === s2) return 1; if (s1.length < n || s2.length < n) return 0; - + const ngrams1 = new Set(); const ngrams2 = new Set(); - + for (let i = 0; i <= s1.length - n; i++) { ngrams1.add(s1.substr(i, n)); } - + for (let i = 0; i <= s2.length - n; i++) { ngrams2.add(s2.substr(i, n)); } - + const intersection = new Set([...ngrams1].filter(x => ngrams2.has(x))); const union = new Set([...ngrams1, ...ngrams2]); - + return intersection.size / union.size; } @@ -447,7 +447,7 @@ export class OpusMagnumOCRService { private findBestCharacterMatch(ocrText: string): string | null { let bestMatch = null; let bestScore = 0; - + for (const puzzleName of this.availablePuzzleNames) { const score = this.calculateCharacterFrequencyScore(ocrText.toLowerCase(), puzzleName.toLowerCase()); if (score > bestScore && score > 0.3) { @@ -455,7 +455,7 @@ export class OpusMagnumOCRService { bestMatch = puzzleName; } } - + return bestMatch; } @@ -465,26 +465,26 @@ export class OpusMagnumOCRService { private calculateCharacterFrequencyScore(str1: string, str2: string): number { const freq1 = new Map(); const freq2 = new Map(); - + for (const char of str1) { freq1.set(char, (freq1.get(char) || 0) + 1); } - + for (const char of str2) { freq2.set(char, (freq2.get(char) || 0) + 1); } - + const allChars = new Set([...freq1.keys(), ...freq2.keys()]); let similarity = 0; let totalChars = 0; - + for (const char of allChars) { const count1 = freq1.get(char) || 0; const count2 = freq2.get(char) || 0; similarity += Math.min(count1, count2); totalChars += Math.max(count1, count2); } - + return totalChars === 0 ? 0 : similarity / totalChars; } @@ -539,7 +539,7 @@ export class OpusMagnumOCRService { const len2 = str2.length; const maxLen = Math.max(len1, len2); const minLen = Math.min(len1, len2); - + return maxLen === 0 ? 1 : minLen / maxLen; } @@ -563,11 +563,11 @@ export class OpusMagnumOCRService { return new Promise((resolve, reject) => { const imageUrl = URL.createObjectURL(imageFile); const img = new Image(); - + img.onload = () => { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d')!; - + canvas.width = img.width; canvas.height = img.height; ctx.drawImage(img, 0, 0); @@ -575,7 +575,7 @@ export class OpusMagnumOCRService { // Draw debug rectangles ctx.strokeStyle = '#00ff00'; ctx.lineWidth = 2; - + const service = new OpusMagnumOCRService(); Object.values(service.regions).forEach(region => { ctx.strokeRect(region.x, region.y, region.width, region.height); diff --git a/opus_submitter/src/stores/submissions.ts b/opus_submitter/src/stores/submissions.ts index 5496dd9..a2c924d 100644 --- a/opus_submitter/src/stores/submissions.ts +++ b/opus_submitter/src/stores/submissions.ts @@ -3,6 +3,7 @@ import { ref } from 'vue' import type { Submission, SubmissionFile } from '@/types' import { submissionHelpers } from '@/services/apiService' import { usePuzzlesStore } from '@/stores/puzzles' +import { errorHelpers } from "@/services/apiService"; export const useSubmissionsStore = defineStore('submissions', () => { // State @@ -83,6 +84,49 @@ export const useSubmissionsStore = defineStore('submissions', () => { await loadSubmissions() } + const handleSubmission = async (submissionData: { + files: any[]; + notes?: string; + manualValidationRequested?: boolean; + }) => { + try { + isLoading.value = true; + error.value = ""; + + // Create submission via store + const submission = await createSubmission( + submissionData.files, + submissionData.notes, + submissionData.manualValidationRequested, + ); + + // Show success message + if (submission) { + const puzzleNames = submission.responses + .map((r) => r.puzzle_name) + .join(", "); + + alert(`Solutions submitted successfully for puzzles: ${puzzleNames}`); + + } else { + alert("Submission created successfully!"); + } + + // Close modal + closeSubmissionModal(); + + } catch (err) { + + const errorMessage = errorHelpers.getErrorMessage(err); + error.value = errorMessage; + alert(`Submission failed: ${errorMessage}`); + + console.error("Submission error:", err); + } finally { + isLoading.value = false; + } + } + return { // State submissions, @@ -95,6 +139,7 @@ export const useSubmissionsStore = defineStore('submissions', () => { createSubmission, openSubmissionModal, closeSubmissionModal, - refreshSubmissions + refreshSubmissions, + handleSubmission } }) diff --git a/opus_submitter/src/stores/uploads.ts b/opus_submitter/src/stores/uploads.ts new file mode 100644 index 0000000..41c3315 --- /dev/null +++ b/opus_submitter/src/stores/uploads.ts @@ -0,0 +1,103 @@ +import { SubmissionFile } from '@/types' +import { defineStore } from 'pinia' +import { ref, nextTick, computed } from "vue"; +import { ocrService } from "@/services/ocrService"; + +const CONFIDENCE_VALUE = 0.8; + +export const useUploadsStore = defineStore('uploads', () => { + const submissionFiles = ref([]) + + const isProcessingOCR = computed(() => + submissionFiles.value.some(item => item.ocrProcessing) + ); + + const hasLowConfidence = computed(() => + submissionFiles.value.some(file => { + return isLowConfidence(file) + }) + ) + + const submissionFilesNeedingManualSelection = computed(() => { + return submissionFiles.value.filter(file => file.needsManualPuzzleSelection) + }) + + const isLowConfidence = (file: SubmissionFile) => { + if (!file.ocrData?.confidence) return false; + return ( + file.ocrData.confidence.cost < CONFIDENCE_VALUE || + file.ocrData.confidence.cycles < CONFIDENCE_VALUE || + file.ocrData.confidence.area < CONFIDENCE_VALUE + ) + } + + const processOCR = async (submissionFile: SubmissionFile) => { + while (isProcessingOCR.value) { + const waitingTimeMs = Math.floor(Math.random() * 400) + 100; + console.log(`OCR is already processing, waiting ${waitingTimeMs}ms...`); + await new Promise((res) => setTimeout(res, waitingTimeMs)); + } + + const index = submissionFiles.value.indexOf(submissionFile) + + // Update the reactive array directly + submissionFiles.value[index].ocrProcessing = true; + submissionFiles.value[index].ocrError = undefined; + submissionFiles.value[index].ocrData = undefined; + + try { + console.log("Starting OCR processing for:", submissionFile.file.name); + await ocrService.initialize(); + const ocrData = await ocrService.extractOpusMagnumData(submissionFile.file); + console.log("OCR completed:", ocrData); + + // Force reactivity update + await nextTick(); + submissionFiles.value[index].ocrData = ocrData; + + // Check if puzzle confidence is below CONFIDENCE_VALUE and needs manual selection + if (ocrData.confidence.puzzle < CONFIDENCE_VALUE) { + submissionFiles.value[index].needsManualPuzzleSelection = true; + console.log( + `Low puzzle confidence (${Math.round(ocrData.confidence.puzzle * 100)}%) for ${submissionFile.file.name}, requiring manual selection`, + ); + } else { + submissionFiles.value[index].needsManualPuzzleSelection = false; + } + + await nextTick(); + } catch (error) { + console.error("OCR processing failed:", error); + submissionFiles.value[index].ocrError = "Failed to extract puzzle data"; + + } finally { + submissionFiles.value[index].ocrProcessing = false; + } + }; + + const processLowConfidenceOCRFiles = async () => { + const files = submissionFiles.value.filter(file => isLowConfidence(file)) + + for (const file of files) { + processOCR(file) + } + } + + const clearFiles = () => { + submissionFiles.value = [] + } + + return { + submissionFiles, + submissionFilesNeedingManualSelection, + processOCR, + processLowConfidenceOCRFiles, + clearFiles, + + // computed + isProcessingOCR, + hasLowConfidence, + + CONFIDENCE_VALUE + } +}) diff --git a/opus_submitter/submissions/api.py b/opus_submitter/submissions/api.py index f7aca8b..9a94cf5 100644 --- a/opus_submitter/submissions/api.py +++ b/opus_submitter/submissions/api.py @@ -68,7 +68,7 @@ def create_submission( try: with transaction.atomic(): - # Check if any confidence score is below 50% to auto-request validation + # Check if any confidence score is below 80% to auto-request validation auto_request_validation = any( ( response_data.ocr_confidence_cost is not None diff --git a/opus_submitter/submissions/utils.py b/opus_submitter/submissions/utils.py index 683f306..2d4ce40 100644 --- a/opus_submitter/submissions/utils.py +++ b/opus_submitter/submissions/utils.py @@ -497,20 +497,15 @@ def verify_and_validate_ocr_date_for_submission(file: SubmissionFile): ) valid_count = 0 - if r.cost == ocr_data[1]: - r.validated_cost = r.cost - valid_count += 1 + for index, field in enumerate(["cost", "cycles", "area"]): + value = getattr(r, field, -1) - if r.cycles == ocr_data[2]: - r.validated_cycles = r.cycles - valid_count += 1 + if value == ocr_data[index + 1]: + setattr(r, f"validated_{field}", value) + valid_count += 1 - if r.area == ocr_data[3]: - r.validated_area = r.area - valid_count += 1 + else: + setattr(r, field, ocr_data[index + 1]) - if valid_count == 3: - r.needs_manual_validation = False - - if valid_count: - r.save() + r.needs_manual_validation = valid_count != 3 + r.save() diff --git a/pyproject.toml b/pyproject.toml index f130ed1..e9505a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ build-backend = "setuptools.build_meta" [dependency-groups] dev = [ + "django-extensions>=4.1", "django-stubs>=5.2.7", "django-stubs-ext>=5.2.7", "django-types>=0.22.0", diff --git a/uv.lock b/uv.lock index c05e64a..e1f23c0 100644 --- a/uv.lock +++ b/uv.lock @@ -185,6 +185,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/ef/81f3372b5dd35d8d354321155d1a38894b2b766f576d0abffac4d8ae78d9/django-5.2.7-py3-none-any.whl", hash = "sha256:59a13a6515f787dec9d97a0438cd2efac78c8aca1c80025244b0fe507fe0754b", size = 8307145, upload-time = "2025-10-01T14:22:49.476Z" }, ] +[[package]] +name = "django-extensions" +version = "4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/b3/ed0f54ed706ec0b54fd251cc0364a249c6cd6c6ec97f04dc34be5e929eac/django_extensions-4.1.tar.gz", hash = "sha256:7b70a4d28e9b840f44694e3f7feb54f55d495f8b3fa6c5c0e5e12bcb2aa3cdeb", size = 283078, upload-time = "2025-04-11T01:15:39.617Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980, upload-time = "2025-04-11T01:15:37.701Z" }, +] + [[package]] name = "django-ninja" version = "1.4.5" @@ -504,6 +516,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "django-extensions" }, { name = "django-stubs" }, { name = "django-stubs-ext" }, { name = "django-types" }, @@ -530,6 +543,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "django-extensions", specifier = ">=4.1" }, { name = "django-stubs", specifier = ">=5.2.7" }, { name = "django-stubs-ext", specifier = ">=5.2.7" }, { name = "django-types", specifier = ">=0.22.0" },