""" Django Generator Generates Django ORM models from model definitions. """ import dataclasses as dc from enum import Enum from pathlib import Path from typing import Any, List, get_type_hints from ..helpers import format_opts, get_origin_name, get_type_name, unwrap_optional from ..loader.schema import EnumDefinition, ModelDefinition from ..types import DJANGO_SPECIAL, DJANGO_TYPES from .base import BaseGenerator class DjangoGenerator(BaseGenerator): """Generates Django ORM model files.""" def file_extension(self) -> str: return ".py" def generate(self, models, output_path: Path) -> None: """Generate Django models to output_path.""" output_path.parent.mkdir(parents=True, exist_ok=True) # Handle different input types if hasattr(models, "models"): # SchemaLoader or similar content = self._generate_from_definitions( models.models, getattr(models, "enums", []) ) elif isinstance(models, tuple): # (models, enums) tuple content = self._generate_from_definitions(models[0], models[1]) elif isinstance(models, list): # List of dataclasses (MPR style) content = self._generate_from_dataclasses(models) else: raise ValueError(f"Unsupported input type: {type(models)}") output_path.write_text(content) def _generate_from_definitions( self, models: List[ModelDefinition], enums: List[EnumDefinition] ) -> str: """Generate from ModelDefinition objects.""" lines = self._generate_header() # Generate enums as TextChoices for enum_def in enums: lines.extend(self._generate_text_choices(enum_def)) lines.append("") # Generate models for model_def in models: lines.extend(self._generate_model_from_definition(model_def)) lines.extend(["", ""]) return "\n".join(lines) def _generate_from_dataclasses(self, dataclasses: List[type]) -> str: """Generate from Python dataclasses (MPR style).""" lines = self._generate_header() for cls in dataclasses: lines.extend(self._generate_model_from_dataclass(cls)) lines.extend(["", ""]) return "\n".join(lines) def _generate_header(self) -> List[str]: """Generate file header.""" return [ '"""', "Django ORM Models - GENERATED FILE", "", "Do not edit directly. Regenerate using modelgen.", '"""', "", "import uuid", "from django.db import models", "", ] def _generate_text_choices(self, enum_def: EnumDefinition) -> List[str]: """Generate Django TextChoices from EnumDefinition.""" lines = [ f"class {enum_def.name}(models.TextChoices):", ] for name, value in enum_def.values: label = name.replace("_", " ").title() lines.append(f' {name} = "{value}", "{label}"') return lines def _generate_model_from_definition(self, model_def: ModelDefinition) -> List[str]: """Generate Django model from ModelDefinition.""" docstring = model_def.docstring or model_def.name lines = [ f"class {model_def.name}(models.Model):", f' """{docstring.strip().split(chr(10))[0]}"""', "", ] for field in model_def.fields: django_field = self._resolve_field_type( field.name, field.type_hint, field.default, field.optional ) lines.append(f" {field.name} = {django_field}") # Add Meta and __str__ lines.extend( [ "", " class Meta:", ' ordering = ["-created_at"]' if any(f.name == "created_at" for f in model_def.fields) else " pass", "", " def __str__(self):", ] ) # Determine __str__ return field_names = [f.name for f in model_def.fields] if "filename" in field_names: lines.append(" return self.filename") elif "name" in field_names: lines.append(" return self.name") else: lines.append(" return str(self.id)") return lines def _generate_model_from_dataclass(self, cls: type) -> List[str]: """Generate Django model from a dataclass (MPR style).""" docstring = cls.__doc__ or cls.__name__ lines = [ f"class {cls.__name__}(models.Model):", f' """{docstring.strip().split(chr(10))[0]}"""', "", ] hints = get_type_hints(cls) fields = {f.name: f for f in dc.fields(cls)} # Check for enums and add Status inner class if needed for type_hint in hints.values(): base, _ = unwrap_optional(type_hint) if isinstance(base, type) and issubclass(base, Enum): lines.append(" class Status(models.TextChoices):") for member in base: label = member.name.replace("_", " ").title() lines.append(f' {member.name} = "{member.value}", "{label}"') lines.append("") break # Generate fields for name, type_hint in hints.items(): if name.startswith("_"): continue field = fields.get(name) default = dc.MISSING if field and field.default is not dc.MISSING: default = field.default django_field = self._resolve_field_type(name, type_hint, default, False) lines.append(f" {name} = {django_field}") # Add Meta and __str__ lines.extend( [ "", " class Meta:", ' ordering = ["-created_at"]' if "created_at" in hints else " pass", "", " def __str__(self):", ] ) if "filename" in hints: lines.append(" return self.filename") elif "name" in hints: lines.append(" return self.name") else: lines.append(" return str(self.id)") return lines def _resolve_field_type( self, name: str, type_hint: Any, default: Any, optional: bool ) -> str: """Resolve Python type to Django field.""" # Special fields if name in DJANGO_SPECIAL: return DJANGO_SPECIAL[name] base, is_optional = unwrap_optional(type_hint) optional = optional or is_optional origin = get_origin_name(base) type_name = get_type_name(base) opts = format_opts(optional) # Container types if origin == "dict": return DJANGO_TYPES["dict"] if origin == "list": return DJANGO_TYPES["list"] # UUID / datetime if type_name == "UUID": return DJANGO_TYPES["UUID"].format(opts=opts) if type_name == "datetime": return DJANGO_TYPES["datetime"].format(opts=opts) # Enum if isinstance(base, type) and issubclass(base, Enum): extra = [] if optional: extra.append("null=True, blank=True") if default is not dc.MISSING and isinstance(default, Enum): extra.append(f"default=Status.{default.name}") return DJANGO_TYPES["enum"].format( opts=", " + ", ".join(extra) if extra else "" ) # Text fields (based on name heuristics) if base is str and any( x in name for x in ("message", "comments", "description") ): return DJANGO_TYPES["text"] # BigInt fields if base is int and name in ("file_size", "bitrate"): return DJANGO_TYPES["bigint"].format(opts=opts) # String with max_length if base is str: max_length = 1000 if "path" in name else 500 if "filename" in name else 255 return DJANGO_TYPES[str].format( max_length=max_length, opts=", " + opts if opts else "" ) # Integer if base is int: extra = [opts] if opts else [] if default is not dc.MISSING and not callable(default): extra.append(f"default={default}") return DJANGO_TYPES[int].format(opts=", ".join(extra)) # Float if base is float: extra = [opts] if opts else [] if default is not dc.MISSING and not callable(default): extra.append(f"default={default}") return DJANGO_TYPES[float].format(opts=", ".join(extra)) # Boolean if base is bool: default_val = default if default is not dc.MISSING else False return DJANGO_TYPES[bool].format(default=default_val) # Fallback to CharField return DJANGO_TYPES[str].format( max_length=255, opts=", " + opts if opts else "" )