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.
+
+
+
+ Retry OCR on low confidence puzzle
+
@@ -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" },