New in v0.8.1 - Configure code generation through the Django admin instead of writing Python code!
FormKit-Ninja now supports database-driven code generation configuration, allowing you to override type mappings, field arguments, and other code generation rules through the Django admin interface or settings, without needing to create custom Python classes.
✅ No Code Required - Configure through Django admin
✅ Dynamic Updates - Change configs without redeploying
✅ Priority System - Fine-grained control over which rules apply
✅ Settings Fallback - Support for Django settings configuration
✅ Backward Compatible - Existing custom NodePath classes still work
Code generation configuration follows this priority cascade (highest to lowest):
graph TD
A[FormKit Node] --> B{Match node_name?}
B -->|Yes| C[Use CodeGenerationConfig<br/>with matching node_name]
B -->|No| D{Match options_pattern?}
D -->|Yes| E[Use CodeGenerationConfig<br/>with matching options_pattern]
D -->|No| F{Match formkit_type?}
F -->|Yes| G[Use CodeGenerationConfig<br/>with matching formkit_type]
F -->|No| H{Django Settings?}
H -->|Yes| I[Use FORMKIT_NINJA settings]
H -->|No| J[Use Default Converters]
C --> K[Generate Code]
E --> K
G --> K
I --> K
J --> K
graph LR
A[FormKit Schema] --> B[CodeGenerator]
B --> C[DatabaseNodePath]
C --> D{Config Lookup}
D --> E[(CodeGenerationConfig<br/>Database)]
D --> F[Django Settings<br/>FORMKIT_NINJA]
D --> G[Default<br/>Type Converters]
C --> H[Generate Models]
C --> I[Generate Schemas]
C --> J[Generate Admin]
style E fill:#90EE90
style F fill:#FFD700
style G fill:#87CEEB
How does a single field in your FormKit JSON become a line of code in models.py?
A FormKitSchema record in your database contains a JSON array. For example:
{ "$formkit": "text", "name": "district_name" }The CodeGenerator walks through this JSON. For the "district_name" node, it creates a DatabaseNodePath instance. This instance is responsible for answering questions like "What is your Django type?".
The DatabaseNodePath queries the database for a CodeGenerationConfig that matches:
formkit_type="text"node_name="district_name"(Highest priority)
If it finds an entry, it "loads" the instructions:
- Django Type:
ForeignKey - Django Args:
{"to": "pnds.District", "null": true}
The CodeGenerator passes the DatabaseNodePath to the models.py.jinja2 template. The template contains code like this:
{{ nodepath.name }} = models.{{ nodepath.to_django_type }}({{ nodepath.to_django_args }})The final result written to your disk is:
district_name = models.ForeignKey("pnds.District", null=True)By changing the CodeGenerationConfig in the database, you change the behavior of the DatabaseNodePath, which in turn changes the rendered output of the template without ever touching the generator's source code.
Database-driven generation is enabled by default in v0.8.1+. The GeneratorConfig automatically uses DatabaseNodePath:
from formkit_ninja.parser.generator_config import GeneratorConfig
config = GeneratorConfig(
app_name="myapp",
output_dir=Path("./generated"),
# DatabaseNodePath is now the default!
)Navigate to Django Admin → Code generation configs → Add code generation config
Use Case: Make the district field a ForeignKey instead of TextField
Matching Criteria:
- FormKit type: text
- Node name: district ← Matches the field name
- Priority: 10
Type Overrides:
- Django type: ForeignKey
Field Configuration:
- Django args:
{
"to": "pnds_data.zDistrict",
"on_delete": "models.CASCADE",
"null": true
}
Use Case: All fields with IDA lookup options should be integers
Matching Criteria:
- FormKit type: select
- Options pattern: $ida( ← Matches options starting with "$ida("
- Priority: 5
Type Overrides:
- Pydantic type: int
Use Case: All datepicker fields should use DateField
Matching Criteria:
- FormKit type: datepicker
- Priority: 0
Type Overrides:
- Django type: DateField
| Field | Type | Description | Example |
|---|---|---|---|
formkit_type |
CharField | FormKit type to match | "text", "select", "datepicker" |
node_name |
CharField | Specific field name (highest priority) | "district", "start_date" |
options_pattern |
CharField | Pattern in options field | "$ida(", "$enum(" |
pydantic_type |
CharField | Override Pydantic type | "int", "Decimal", "date" |
django_type |
CharField | Override Django field type | "ForeignKey", "IntegerField" |
django_args |
JSONField | Django field arguments | {"null": true, "max_length": 100} |
extra_imports |
JSONField | Additional imports | ["from decimal import Decimal"] |
validators |
JSONField | Field validators | ["MinValueValidator(0)"] |
priority |
IntegerField | Matching priority (higher = first) | 0, 10, 100 |
is_active |
BooleanField | Enable/disable without deleting | true, false |
You can also configure overrides in settings.py:
FORMKIT_NINJA = {
# Type-level mappings
"TYPE_MAPPINGS": {
"datepicker": {
"django_type": "DateField",
"pydantic_type": "date",
},
"currency": {
"pydantic_type": "Decimal",
"extra_imports": ["from decimal import Decimal"],
},
},
# Field name mappings (higher priority than TYPE_MAPPINGS)
"NAME_MAPPINGS": {
"district": {
"django_type": "ForeignKey",
"django_args": {
"to": "pnds_data.zDistrict",
"on_delete": "models.CASCADE",
"null": True,
},
},
},
# Options pattern mappings
"OPTIONS_MAPPINGS": {
"$ida(": {
"pydantic_type": "int",
},
},
}Problem: Need to reference another model instead of storing text
Solution: Create a node-specific config
# Via Admin:
formkit_type = "text"
node_name = "health_zone"
django_type = "ForeignKey"
django_args = {
"to": "pnds_data.zHealthZone",
"on_delete": "models.PROTECT",
"related_name": "submissions"
}Generated Code:
# models/myform.py
health_zone = models.ForeignKey(
"pnds_data.zHealthZone",
on_delete=models.PROTECT,
related_name="submissions"
)Problem: Currency values need decimal precision
Solution: Type-level config with custom imports
# Via Admin:
formkit_type = "text"
node_name = "amount"
pydantic_type = "Decimal"
extra_imports = ["from decimal import Decimal"]Generated Code:
# schemas/myform.py
from decimal import Decimal
class MyFormSchema(BaseModel):
amount: Decimal | None = NoneProblem: IDA lookups return integers, not strings
Solution: Pattern-based config
# Via Admin:
formkit_type = "select"
options_pattern = "$ida("
pydantic_type = "int"Matches:
<FormKit type="select" name="status" options="$ida(yesno)" /><FormKit type="select" name="category" options="$ida(categories)" />
Problem: Need to add validation constraints
Solution: Add validators in config
# Via Admin:
formkit_type = "number"
node_name = "age"
validators = [
"MinValueValidator(0)",
"MaxValueValidator(150)"
]
extra_imports = [
"from django.core.validators import MinValueValidator, MaxValueValidator"
]The priority field determines which config wins when multiple configs could match a node:
# Priority 100: Most specific - this field only
CodeGenerationConfig(
formkit_type="text",
node_name="special_field",
priority=100,
)
# Priority 50: Medium - pattern-based matching
CodeGenerationConfig(
formkit_type="select",
options_pattern="$ida(",
priority=50,
)
# Priority 0: Lowest - type-level default
CodeGenerationConfig(
formkit_type="text",
priority=0,
)Example Node:
<FormKit type="select" name="status" options="$ida(yesno)" />Potential Matches (checked in order):
- ✅ Priority 100:
formkit_type="select"ANDnode_name="status"(most specific) - ✅ Priority 50:
formkit_type="select"ANDoptions_pattern="$ida("(pattern match) - ✅ Priority 0:
formkit_type="select"(type-level)
The highest priority match wins!
Set is_active=False to temporarily disable a configuration without deleting it:
# Disabled - will be skipped during generation
CodeGenerationConfig(
formkit_type="text",
node_name="legacy_field",
is_active=False, # ← Ignored
)The admin list view shows:
- Summary: Human-readable config description
- FormKit Type: The type being configured
- Node Name: Specific field name (if any)
- Priority: Matching priority
- Active: Whether config is enabled
- Pydantic: ✓ if Pydantic type is overridden
- Django: ✓ if Django type/args are overridden
- Created: When config was created
Filter by:
- Active status (active/inactive)
- FormKit type (text, select, etc.)
- Has node name (yes/no)
- Has options pattern (yes/no)
- Creation date
Search across:
- FormKit type
- Node name
- Options pattern
- Pydantic type
- Django type
Fields are organized into logical groups:
- Matching Criteria - Define what nodes this applies to
- Type Overrides - Override Pydantic/Django types
- Field Configuration - Django field arguments
- Advanced (collapsed) - Extra imports and validators
- Metadata (collapsed) - Creation/update timestamps
Before (Custom NodePath):
from formkit_ninja.parser.type_convert import NodePath
class CustomNodePath(NodePath):
def to_django_type(self) -> str:
if hasattr(self.node, 'name') and self.node.name == 'district':
return 'ForeignKey'
return super().to_django_type()
def to_django_args(self) -> str:
if hasattr(self.node, 'name') and self.node.name == 'district':
return 'to="pnds_data.zDistrict", on_delete=models.CASCADE'
return super().to_django_args()
config = GeneratorConfig(
app_name="myapp",
output_dir=Path("./generated"),
node_path_class=CustomNodePath, # Custom class
)After (Database Config):
# 1. Create config via Django admin (one-time):
CodeGenerationConfig.objects.create(
formkit_type="text",
node_name="district",
django_type="ForeignKey",
django_args={
"to": "pnds_data.zDistrict",
"on_delete": "models.CASCADE",
},
)
# 2. Use default DatabaseNodePath (no custom code needed!):
config = GeneratorConfig(
app_name="myapp",
output_dir=Path("./generated"),
# DatabaseNodePath is automatic!
)Settings still work! Database configs take priority over settings:
# settings.py
FORMKIT_NINJA = {
"TYPE_MAPPINGS": {
"text": {"django_type": "TextField"}, # Default
}
}
# Database config overrides settings:
CodeGenerationConfig.objects.create(
formkit_type="text",
node_name="description",
django_type="CharField", # ← Overrides settings for this field
django_args={"max_length": 500},
)Check:
- ✓ Is
is_active=True? - ✓ Does
formkit_typematch exactly? (case-sensitive) - ✓ For node_name matches, does the name match exactly?
- ✓ Is there a higher-priority config overriding it?
Debug:
from formkit_ninja.code_generation_config import CodeGenerationConfig
# Check what configs exist
configs = CodeGenerationConfig.objects.filter(
is_active=True,
formkit_type="text"
).order_by("-priority")
for cfg in configs:
print(f"Priority {cfg.priority}: {cfg}")Verify DatabaseNodePath is being used:
from formkit_ninja.parser.generator_config import GeneratorConfig
from formkit_ninja.parser.database_node_path import DatabaseNodePath
config = GeneratorConfig(app_name="test", output_dir=Path("./out"))
assert config.node_path_class == DatabaseNodePath # Should be TrueValid JSON format:
{
"null": true,
"blank": true,
"to": "app.Model",
"on_delete": "models.CASCADE"
}Invalid (common mistakes):
- ❌ Single quotes:
{'null': True}(use double quotes) - ❌ Python booleans:
{"null": True}(use lowercase:true) - ❌ Trailing commas:
{"null": true,}(remove trailing comma)
The DatabaseNodePath class implements the priority cascade:
from formkit_ninja.parser.database_node_path import DatabaseNodePath
from formkit_ninja.formkit_schema import TextNode
# Create a node
node = TextNode(name="district")
# Create nodepath (automatically queries database)
nodepath = DatabaseNodePath(node)
# Get type information (uses database config if available)
pydantic_type = nodepath.to_pydantic_type() # e.g., "str"
django_type = nodepath.to_django_type() # e.g., "ForeignKey"
django_args = nodepath.to_django_args() # e.g., "to=..."
validators = nodepath.get_validators() # e.g., ["MinValueValidator(0)"]
imports = nodepath.get_extra_imports() # e.g., ["from decimal import Decimal"]DatabaseNodePath caches configuration lookups for performance:
# First lookup: queries database
nodepath1 = DatabaseNodePath(TextNode(name="field1"))
type1 = nodepath1.to_pydantic_type() # DB query
# Subsequent lookups: uses cache
type2 = nodepath1.to_pydantic_type() # Cached!
# Different node: new query
nodepath2 = DatabaseNodePath(TextNode(name="field2"))
type3 = nodepath2.to_pydantic_type() # DB queryCache is per-instance and includes formkit_type, node_name, and options.
# ✅ Good: Clear priority levels
- Field-specific overrides: priority=100
- Pattern-based rules: priority=50
- Type-level defaults: priority=0
# ❌ Avoid: All same priority
- Multiple configs with priority=0 (unpredictable which wins)Use clear, descriptive summaries in the admin that explain WHY a config exists:
Summary: District field → FK to zDistrict table (PNDS data)
After updating configs, regenerate a test schema to verify:
python manage.py generate_code --schema-id=123 --output=/tmp/test
# Check /tmp/test/models/*.py to verify changesExport configs for version control:
python manage.py dumpdata formkit_ninja.CodeGenerationConfig \
--indent=2 > fixtures/code_gen_configs.jsonLoad on other environments:
python manage.py loaddata fixtures/code_gen_configs.jsonFor project-wide defaults, use settings. For specific overrides or frequently changing rules, use database configs:
# settings.py - Project defaults
FORMKIT_NINJA = {
"TYPE_MAPPINGS": {
"datepicker": {"django_type": "DateField"},
}
}
# Database - Specific overrides that may change
CodeGenerationConfig.objects.create(
formkit_type="datepicker",
node_name="end_date",
django_args={"null": True}, # This field is optional
)- Code Generation Guide - General code generation documentation
- Options Documentation - FormKit options patterns
- Admin Guide - Django admin interfaces