#!/usr/bin/env node /** * Generates TypeScript file with choice labels from Django TextChoices in Python models * * Usage: node scripts/generate-choice-labels.cjs * * This script reads Django models.py files from backend apps and extracts * TextChoices class definitions (format: VALUE = 'value', 'Czech Label') * and generates a TypeScript file with runtime-accessible label mappings. * * This is more reliable than parsing OpenAPI schema since it reads directly * from the source of truth (Python models). */ const fs = require('fs'); const path = require('path'); // Configuration - Backend Python apps to scan const PYTHON_APPS = [ path.join(__dirname, '../../backend/account'), path.join(__dirname, '../../backend/booking'), path.join(__dirname, '../../backend/commerce'), path.join(__dirname, '../../backend/servicedesk'), path.join(__dirname, '../../backend/product'), path.join(__dirname, '../../backend/configuration'), ]; const OUTPUT_PATH = path.join(__dirname, '../src/constants/choiceLabels.ts'); // Convert camelCase to UPPER_SNAKE_CASE function camelToSnakeCase(str) { return str .replace(/([A-Z])/g, '_$1') .replace(/^_/, '') // Remove leading underscore .toUpperCase(); } // Extract TextChoices from Python models.py file function parsePythonModels(filePath) { const content = fs.readFileSync(filePath, 'utf8'); const results = {}; // First, find all model class definitions to track context // Match: class ModelName(SoftDeleteModel): or class ModelName(models.Model): const modelRegex = /class\s+(\w+)\([^)]*(?:Model|SoftDeleteModel)[^)]*\):/g; const modelPositions = []; let modelMatch; while ((modelMatch = modelRegex.exec(content)) !== null) { modelPositions.push({ name: modelMatch[1], start: modelMatch.index, }); } // Match TextChoices class definitions: // class RoleChoices(models.TextChoices): or class StatusChoices(models.TextChoices): const classRegex = /class\s+(\w+Choices)\s*\([^)]*TextChoices[^)]*\):\s*\n((?:\s+\w+\s*=\s*[^\n]+\n?)+)/g; let classMatch; while ((classMatch = classRegex.exec(content)) !== null) { const [, className, classBody] = classMatch; const choicePosition = classMatch.index; // Find the parent model class (closest model before this choice) let parentModel = null; for (let i = modelPositions.length - 1; i >= 0; i--) { if (modelPositions[i].start < choicePosition) { parentModel = modelPositions[i].name; break; } } // Generate enum name based on parent model + choice class name // MarketSlot + StatusChoices -> MarketSlotStatusEnum // Reservation + StatusChoices -> ReservationStatusEnum (but spec uses Status9e5Enum) // Order + StatusChoices -> OrderStatusEnum // For top-level choices (no parent), use just the choice name let enumName; if (parentModel && className === 'StatusChoices') { // Use parent model name for nested StatusChoices enumName = `${parentModel}${className.replace(/Choices$/, 'Enum')}`; } else { // Top-level choices like RoleChoices, AccountTypeChoices enumName = className.replace(/Choices$/, 'Enum'); } // Parse individual choice lines: ADMIN = 'admin', 'Administrátor' const choiceRegex = /\w+\s*=\s*['"]([^'"]+)['"],\s*['"]([^'"]+)['"]/g; const labels = {}; let choiceMatch; while ((choiceMatch = choiceRegex.exec(classBody)) !== null) { const [, value, label] = choiceMatch; labels[value] = label; } if (Object.keys(labels).length > 0) { results[enumName] = labels; } } return results; } // Main function function generateLabels() { console.log('🔍 Scanning Python models for TextChoices...'); const enums = {}; let totalFiles = 0; // Scan each backend app for models.py for (const appDir of PYTHON_APPS) { const appName = path.basename(appDir); const modelsFile = path.join(appDir, 'models.py'); if (!fs.existsSync(modelsFile)) { continue; } console.log(`📁 Scanning ${appName}/models.py...`); totalFiles++; // Parse the models file const results = parsePythonModels(modelsFile); for (const [enumName, labels] of Object.entries(results)) { if (enums[enumName]) { // Check for conflicts const existing = JSON.stringify(enums[enumName]); const current = JSON.stringify(labels); if (existing !== current) { console.warn(` ⚠️ ${enumName}: Conflict in ${appName}, keeping first version`); } } else { enums[enumName] = labels; console.log(` ✓ ${enumName}: ${Object.keys(labels).length} labels`); } } } if (totalFiles === 0) { console.error('❌ No models.py files found in backend apps'); process.exit(1); } if (Object.keys(enums).length === 0) { console.error('❌ No TextChoices found in Python models'); process.exit(1); } // Generate TypeScript file console.log('📝 Generating TypeScript file...'); const tsContent = `/** * Auto-generated choice labels from Django TextChoices in Python models * Generated by: scripts/generate-choice-labels.cjs * * DO NOT EDIT MANUALLY - Run \`npm run api:gen\` to regenerate * * Source: backend (TextChoices classes) */ ${Object.entries(enums).map(([enumName, labels]) => { const enumBaseName = enumName.replace(/Enum$/, ''); const constantName = camelToSnakeCase(enumBaseName) + '_LABELS'; return `/** * Labels for ${enumName} * ${Object.entries(labels).map(([value, label]) => `* ${value}: ${label}`).join('\n * ')} */ export const ${constantName} = ${JSON.stringify(labels, null, 2)} as const; `; }).join('\n')} /** * Type-safe helper to get choice label */ export function getChoiceLabel( labels: Record, value: T | undefined | null ): string { if (!value) return ''; return labels[value] || value; } /** * Get options array for select dropdowns */ export function getChoiceOptions( labels: Record ): Array<{ value: T; label: string }> { return Object.entries(labels).map(([value, label]) => ({ value: value as T, label: label as string, })); } // Auto-generate all OPTIONS exports ${Object.entries(enums).map(([enumName]) => { const enumBaseName = enumName.replace(/Enum$/, ''); const labelsConstName = camelToSnakeCase(enumBaseName) + '_LABELS'; const optionsConstName = camelToSnakeCase(enumBaseName) + '_OPTIONS'; return `export const ${optionsConstName} = getChoiceOptions(${labelsConstName});`; }).join('\n')} `; // Ensure output directory exists const outputDir = path.dirname(OUTPUT_PATH); if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } // Write file fs.writeFileSync(OUTPUT_PATH, tsContent, 'utf8'); console.log(`✅ Generated ${OUTPUT_PATH}`); console.log(` Found ${Object.keys(enums).length} enum types with labels`); } // Run the script try { generateLabels(); } catch (error) { console.error('❌ Error generating choice labels:', error.message); process.exit(1); }