|
#!/usr/bin/env python3 |
|
""" |
|
ninja_scaffold: A CLI tool to quickly scaffold Django apps with models and APIs. |
|
|
|
Usage: |
|
python ninja_scaffold.py <app_name> <model_name> [field:type_field]... |
|
python ninja_scaffold.py --generate-from-model <app_name> <model_name> |
|
|
|
Examples: |
|
python ninja_scaffold.py crm Person name:charfield email:charfield age:integerfield data:datefield |
|
python ninja_scaffold.py crm Customer name:charfield email:charfield age:integerfield data:datefield |
|
python ninja_scaffold.py crm Provider name:charfield email:charfield person:foreignkey |
|
|
|
python ninja_scaffold.py product Product title:charfield price:decimalfield |
|
|
|
python ninja_scaffold.py --generate-from-model crm Person |
|
python ninja_scaffold.py --generate-from-model crm Customer |
|
""" |
|
|
|
import sys |
|
import re |
|
from pathlib import Path |
|
|
|
|
|
FIELD_TYPE_MAPPING = { |
|
'charfield': ('CharField', 'max_length=100'), |
|
'textfield': ('TextField', 'null=True, blank=True'), |
|
'integerfield': ('IntegerField', 'null=True, blank=True'), |
|
'booleanfield': ('BooleanField', 'default=False'), |
|
'decimalfield': ('DecimalField', 'decimal_places=2, max_digits=7'), |
|
'datefield': ('DateField', 'null=True, blank=True'), |
|
'datetimefield': ('DateTimeField', 'auto_now_add=True'), |
|
'emailfield': ('EmailField', 'max_length=254'), |
|
'urlfield': ('URLField', 'max_length=200, null=True, blank=True'), |
|
'slugfield': ('SlugField', 'max_length=50'), |
|
'uuidfield': ('UUIDField', 'default=uuid.uuid4, editable=False'), |
|
'filefield': ('FileField', 'upload_to="uploads/", null=True, blank=True'), |
|
'imagefield': ('ImageField', 'upload_to="images/", null=True, blank=True'), |
|
# ForeignKey and other relation fields are handled separately |
|
'jsonfield': ('JSONField', 'default=dict, null=True, blank=True'), |
|
} |
|
|
|
|
|
def validate_app_name(name): |
|
"""Validate the app name follows Django conventions.""" |
|
if not re.match(r'^[a-z][a-z0-9_]*$', name): |
|
print(f"Error: App name '{name}' must start with a lowercase letter and contain only lowercase letters, numbers, and underscores.") |
|
sys.exit(1) |
|
return name |
|
|
|
|
|
def validate_model_name(name): |
|
"""Validate the model name follows Django conventions.""" |
|
if not re.match(r'^[A-Z][a-zA-Z0-9]*$', name): |
|
print(f"Error: Model name '{name}' must start with an uppercase letter and be in CamelCase.") |
|
sys.exit(1) |
|
return name |
|
|
|
|
|
def validate_field_name(name): |
|
"""Validate the field name follows Django conventions.""" |
|
if not re.match(r'^[a-z][a-z0-9_]*$', name): |
|
print(f"Error: Field name '{name}' must start with a lowercase letter and contain only lowercase letters, numbers, and underscores.") |
|
sys.exit(1) |
|
return name |
|
|
|
|
|
def get_relation_model_name(field_name): |
|
"""Convert a field name to the appropriate related model name. |
|
E.g., 'person' -> 'Person', 'user_profile' -> 'UserProfile' |
|
""" |
|
# Convert to CamelCase |
|
parts = field_name.split('_') |
|
return ''.join(part.capitalize() for part in parts) |
|
|
|
|
|
def generate_model_class(model_name, fields): |
|
"""Generate a model class for models.py file.""" |
|
field_imports = set() |
|
|
|
for field_type in [f_type.lower() for _, f_type in fields]: |
|
if field_type == 'uuidfield': |
|
field_imports.add('import uuid') |
|
|
|
field_lines = [] |
|
for field_name, field_type in fields: |
|
field_type = field_type.lower() |
|
|
|
# Special handling for relation fields |
|
if field_type == 'foreignkey': |
|
# Derive related model name from field name |
|
related_model = get_relation_model_name(field_name) |
|
field_lines.append(f" {field_name} = models.ForeignKey('{related_model}', on_delete=models.CASCADE, null=True, blank=True)") |
|
elif field_type == 'manytomanyfield': |
|
related_model = get_relation_model_name(field_name) |
|
field_lines.append(f" {field_name} = models.ManyToManyField('{related_model}')") |
|
elif field_type == 'onetoone': |
|
related_model = get_relation_model_name(field_name) |
|
field_lines.append(f" {field_name} = models.OneToOneField('{related_model}', on_delete=models.CASCADE, null=True, blank=True)") |
|
elif field_type in FIELD_TYPE_MAPPING: |
|
django_type, extra_args = FIELD_TYPE_MAPPING[field_type] |
|
args = f"{extra_args}" |
|
field_lines.append(f" {field_name} = models.{django_type}({args})") |
|
else: |
|
print(f"Warning: Unknown field type '{field_type}'. Using TextField instead.") |
|
field_lines.append(f" {field_name} = models.TextField()") |
|
|
|
# Get first field for __str__ and ordering |
|
first_field = fields[0][0] if fields else "id" |
|
|
|
model_class = '\n'.join([ |
|
f"class {model_name}(models.Model):", |
|
*field_lines, |
|
"", |
|
" class Meta:", |
|
f" ordering = ('{first_field}',)", |
|
f" verbose_name = '{model_name.lower()}'", |
|
f" verbose_name_plural = '{model_name.lower()}s'", |
|
"", |
|
" def __str__(self):", |
|
f" return f'{{self.{first_field}}}'", |
|
"" |
|
]) |
|
|
|
return model_class, field_imports |
|
|
|
|
|
def generate_schema_class(model_name, fields): |
|
"""Generate schema classes for schemas.py file.""" |
|
field_names = [field_name for field_name, _ in fields] |
|
|
|
# Create output schema with ID |
|
output_fields = ['id'] + field_names |
|
output_fields_tuple = repr(tuple(output_fields)) |
|
|
|
# Create input schema without ID |
|
input_fields_tuple = repr(tuple(field_names)) |
|
|
|
schema_class = '\n'.join([ |
|
f"class {model_name}Schema(ModelSchema):", |
|
" class Meta:", |
|
f" model = {model_name}", |
|
f" fields = {output_fields_tuple}", |
|
"", |
|
f"class {model_name}InputSchema(ModelSchema):", |
|
" class Meta:", |
|
f" model = {model_name}", |
|
f" fields = {input_fields_tuple}", |
|
"" |
|
]) |
|
|
|
return schema_class |
|
|
|
|
|
def generate_router_content(model_name, fields): |
|
"""Generate router content for api.py file.""" |
|
model_name_lower = model_name.lower() |
|
models_plural = f"{model_name_lower}s" |
|
|
|
router_content = '\n'.join([ |
|
f"# {model_name} routes", |
|
f"@router.get('/{models_plural}', response=list[{model_name}Schema], tags=['{models_plural}'])", |
|
f"def list_{models_plural}(request):", |
|
f" return {model_name}.objects.all()", |
|
"", |
|
f"@router.get('/{models_plural}/{{pk}}', response={model_name}Schema, tags=['{models_plural}'])", |
|
f"def get_{model_name_lower}(request, pk: int):", |
|
f" return get_object_or_404({model_name}, pk=pk)", |
|
"", |
|
f"@router.post('/{models_plural}', response={{HTTPStatus.CREATED: {model_name}Schema}}, tags=['{models_plural}'])", |
|
f"def create_{model_name_lower}(request, payload: {model_name}InputSchema):", |
|
f" return {model_name}.objects.create(**payload.dict())", |
|
"", |
|
f"@router.patch('/{models_plural}/{{pk}}', response={model_name}Schema, tags=['{models_plural}'])", |
|
f"def update_{model_name_lower}(request, pk: int, payload: {model_name}InputSchema):", |
|
f" instance = get_object_or_404({model_name}, pk=pk)", |
|
" data = payload.dict(exclude_unset=True)", |
|
"", |
|
" for attr, value in data.items():", |
|
" setattr(instance, attr, value)", |
|
"", |
|
" instance.save()", |
|
" return instance", |
|
"", |
|
f"@router.delete('/{models_plural}/{{pk}}', tags=['{models_plural}'])", |
|
f"def delete_{model_name_lower}(request, pk: int):", |
|
f" instance = get_object_or_404({model_name}, pk=pk)", |
|
" instance.delete()", |
|
" return {'success': True}", |
|
"" |
|
]) |
|
|
|
return router_content |
|
|
|
|
|
def generate_admin_class(model_name, fields): |
|
"""Generate admin class for admin.py file.""" |
|
field_names = [field_name for field_name, _ in fields] |
|
|
|
# Get first field for search |
|
first_field = fields[0][0] if fields else "id" |
|
|
|
admin_class = '\n'.join([ |
|
f"@admin.register({model_name})", |
|
f"class {model_name}Admin(admin.ModelAdmin):", |
|
f" list_display = {repr(field_names)}", |
|
f" search_fields = ('{first_field}',)", |
|
" list_filter = ('created_at',)" if 'created_at' in field_names else "", |
|
"" |
|
]) |
|
|
|
# Remove empty line if list_filter isn't included |
|
admin_class = admin_class.replace("\n\n\n", "\n\n") |
|
|
|
return admin_class |
|
|
|
|
|
def update_models_py(app_dir, model_name, fields): |
|
"""Update models.py file with a new model.""" |
|
models_file = app_dir / "models.py" |
|
model_class, field_imports = generate_model_class(model_name, fields) |
|
|
|
if models_file.exists(): |
|
# File exists, check if the model is already defined |
|
content = models_file.read_text() |
|
if f"class {model_name}(models.Model):" in content: |
|
print(f"Model {model_name} already exists in {models_file}") |
|
return False |
|
|
|
# Add imports if needed |
|
for import_stmt in field_imports: |
|
if import_stmt not in content: |
|
content = content.replace("from django.db import models", f"from django.db import models\n{import_stmt}") |
|
|
|
# Add the new model class |
|
content += "\n\n" + model_class |
|
models_file.write_text(content) |
|
else: |
|
# Create new file |
|
content = "from django.db import models\n" |
|
for import_stmt in field_imports: |
|
content += f"{import_stmt}\n" |
|
content += "\n\n" + model_class |
|
models_file.write_text(content) |
|
|
|
return True |
|
|
|
|
|
def update_schemas_py(app_dir, model_name, fields): |
|
"""Update schemas.py file with new schema classes.""" |
|
schemas_file = app_dir / "schemas.py" |
|
schema_class = generate_schema_class(model_name, fields) |
|
|
|
if schemas_file.exists(): |
|
# File exists, check if the schema is already defined |
|
content = schemas_file.read_text() |
|
if f"class {model_name}Schema" in content: |
|
print(f"Schema for {model_name} already exists in {schemas_file}") |
|
return False |
|
|
|
# Add the model to imports if not already there |
|
if f"from .models import {model_name}" not in content: |
|
if "from .models import " in content: |
|
# Update the existing import |
|
content = re.sub(r'from .models import (.+)', f'from .models import \\1, {model_name}', content) |
|
else: |
|
# Add a new import |
|
content += f"\nfrom .models import {model_name}\n" |
|
|
|
# Add the new schema classes |
|
content += "\n" + schema_class |
|
schemas_file.write_text(content) |
|
else: |
|
# Create new file |
|
content = '\n'.join([ |
|
"from ninja import ModelSchema", |
|
f"from .models import {model_name}", |
|
"", |
|
"", |
|
schema_class |
|
]) |
|
schemas_file.write_text(content) |
|
|
|
return True |
|
|
|
|
|
def update_api_py(app_dir, model_name, fields): |
|
"""Update api.py file with new router content.""" |
|
api_file = app_dir / "api.py" |
|
router_content = generate_router_content(model_name, fields) |
|
|
|
if api_file.exists(): |
|
# File exists, check if the routes are already defined |
|
content = api_file.read_text() |
|
if f"def list_{model_name.lower()}s" in content: |
|
print(f"Routes for {model_name} already exist in {api_file}") |
|
return False |
|
|
|
# Add the model and schemas to imports if not already there |
|
model_import = f"from .models import {model_name}" |
|
schema_import = f"from .schemas import {model_name}Schema, {model_name}InputSchema" |
|
|
|
if model_name not in re.findall(r'from .models import (.+)', content): |
|
if "from .models import " in content: |
|
# Update the existing import |
|
content = re.sub(r'from .models import (.+)', f'from .models import \\1, {model_name}', content) |
|
else: |
|
# Add a new import |
|
content = content.replace("from ninja import Router", f"from ninja import Router\n{model_import}") |
|
|
|
if f"{model_name}Schema" not in content: |
|
if "from .schemas import " in content: |
|
# Update the existing import |
|
content = re.sub(r'from .schemas import (.+)', |
|
f'from .schemas import \\1, {model_name}Schema, {model_name}InputSchema', content) |
|
else: |
|
# Add a new import |
|
content = content.replace("from ninja import Router", f"from ninja import Router\n{schema_import}") |
|
|
|
# Add the new router content |
|
content += "\n" + router_content |
|
api_file.write_text(content) |
|
else: |
|
# Create new file |
|
content = '\n'.join([ |
|
"from http import HTTPStatus", |
|
"from django.shortcuts import get_object_or_404", |
|
"from ninja import Router", |
|
f"from .models import {model_name}", |
|
f"from .schemas import {model_name}Schema, {model_name}InputSchema", |
|
"", |
|
"", |
|
"router = Router()", |
|
"", |
|
router_content |
|
]) |
|
api_file.write_text(content) |
|
|
|
return True |
|
|
|
|
|
def update_admin_py(app_dir, model_name, fields): |
|
"""Update admin.py file with new admin class.""" |
|
admin_file = app_dir / "admin.py" |
|
admin_class = generate_admin_class(model_name, fields) |
|
|
|
if admin_file.exists(): |
|
# File exists, check if the admin class is already defined |
|
content = admin_file.read_text() |
|
if f"class {model_name}Admin" in content: |
|
print(f"Admin class for {model_name} already exists in {admin_file}") |
|
return False |
|
|
|
# Add the model to imports if not already there |
|
if f"from .models import {model_name}" not in content: |
|
if "from .models import " in content: |
|
# Update the existing import |
|
content = re.sub(r'from .models import (.+)', f'from .models import \\1, {model_name}', content) |
|
else: |
|
# Add a new import |
|
content += f"\nfrom .models import {model_name}\n" |
|
|
|
# Add the new admin class |
|
content += "\n" + admin_class |
|
admin_file.write_text(content) |
|
else: |
|
# Create new file |
|
content = '\n'.join([ |
|
"from django.contrib import admin", |
|
f"from .models import {model_name}", |
|
"", |
|
"", |
|
admin_class |
|
]) |
|
admin_file.write_text(content) |
|
|
|
return True |
|
|
|
|
|
def extract_model_fields(app_name, model_name): |
|
"""Extract fields from an existing model by parsing the models.py file.""" |
|
models_file = Path(app_name) / "models.py" |
|
|
|
if not models_file.exists(): |
|
print(f"Error: File {models_file} not found.") |
|
sys.exit(1) |
|
|
|
try: |
|
content = models_file.read_text() |
|
|
|
# Find the model class definition |
|
pattern = rf"class\s+{model_name}\s*\([^)]*\):(.+?)(?:class\s+\w+|\Z)" |
|
match = re.search(pattern, content, re.DOTALL) |
|
|
|
if not match: |
|
print(f"Error: Model '{model_name}' not found in {models_file}") |
|
sys.exit(1) |
|
|
|
model_content = match.group(1) |
|
|
|
# Extract field definitions |
|
field_pattern = r"^\s*(\w+)\s*=\s*models\.(\w+)\s*\(" |
|
field_matches = re.finditer(field_pattern, model_content, re.MULTILINE) |
|
|
|
fields = [] |
|
for match in field_matches: |
|
field_name = match.group(1) |
|
field_type = match.group(2).lower() |
|
|
|
if field_name not in ['id', 'objects']: |
|
fields.append((field_name, field_type)) |
|
|
|
if not fields: |
|
print(f"Warning: No fields found in model '{model_name}'.") |
|
else: |
|
print(f"Extracted {len(fields)} fields from {models_file}:") |
|
for name, field_type in fields: |
|
print(f" - {name}: {field_type}") |
|
|
|
return fields |
|
except Exception as e: |
|
print(f"Error: Failed to parse model file: {str(e)}") |
|
import traceback |
|
traceback.print_exc() |
|
sys.exit(1) |
|
|
|
|
|
def generate_from_model(app_name, model_name): |
|
"""Generate schemas, API, and admin for an existing model.""" |
|
print(f"Generating from existing model '{model_name}' in app '{app_name}'...") |
|
|
|
# Extract fields from the model |
|
fields = extract_model_fields(app_name, model_name) |
|
|
|
if not fields: |
|
print(f"Warning: No fields found for model '{model_name}' in app '{app_name}'.") |
|
return |
|
|
|
app_dir = Path(app_name) |
|
|
|
# Update schemas.py |
|
schemas_updated = update_schemas_py(app_dir, model_name, fields) |
|
|
|
# Update api.py |
|
api_updated = update_api_py(app_dir, model_name, fields) |
|
|
|
# Update admin.py |
|
admin_updated = update_admin_py(app_dir, model_name, fields) |
|
|
|
print(f"Successfully generated files for model '{model_name}' in app '{app_name}':") |
|
if schemas_updated: |
|
print(f" - {app_name}/schemas.py (updated)") |
|
if api_updated: |
|
print(f" - {app_name}/api.py (updated)") |
|
if admin_updated: |
|
print(f" - {app_name}/admin.py (updated)") |
|
|
|
|
|
def create_or_update_app_structure(app_name, model_name, fields): |
|
"""Create or update the app directory structure and files.""" |
|
# Create app directory |
|
app_dir = Path(app_name) |
|
app_dir.mkdir(exist_ok=True) |
|
|
|
# Create __init__.py |
|
init_file = app_dir / "__init__.py" |
|
init_file.touch() |
|
|
|
# Create or update models.py |
|
models_updated = update_models_py(app_dir, model_name, fields) |
|
|
|
# Create or update schemas.py |
|
schemas_updated = update_schemas_py(app_dir, model_name, fields) |
|
|
|
# Create or update api.py |
|
api_updated = update_api_py(app_dir, model_name, fields) |
|
|
|
# Create or update admin.py |
|
admin_updated = update_admin_py(app_dir, model_name, fields) |
|
|
|
# Create apps.py if it doesn't exist |
|
apps_file = app_dir / "apps.py" |
|
if not apps_file.exists(): |
|
class_name = ''.join(word.capitalize() for word in app_name.split('_')) |
|
apps_content = '\n'.join([ |
|
"from django.apps import AppConfig", |
|
"", |
|
"", |
|
f"class {class_name}Config(AppConfig):", |
|
f" default_auto_field = 'django.db.models.BigAutoField'", |
|
f" name = '{app_name}'", |
|
"", |
|
" def ready(self):", |
|
" pass # Import signals and other initialization here", |
|
"" |
|
]) |
|
apps_file.write_text(apps_content) |
|
|
|
print(f"Successfully {'updated' if any([models_updated, schemas_updated, api_updated, admin_updated]) else 'added'} model '{model_name}' to app '{app_name}'") |
|
print(f"Files:") |
|
print(f" - {app_name}/__init__.py") |
|
print(f" - {app_name}/models.py") |
|
print(f" - {app_name}/schemas.py") |
|
print(f" - {app_name}/api.py") |
|
print(f" - {app_name}/admin.py") |
|
print(f" - {app_name}/apps.py") |
|
|
|
|
|
def parse_fields(field_args): |
|
"""Parse field arguments in the format field:type.""" |
|
fields = [] |
|
for arg in field_args: |
|
parts = arg.split(':') |
|
if len(parts) != 2: |
|
print(f"Error: Invalid field format '{arg}'. Use 'field:type'.") |
|
sys.exit(1) |
|
|
|
field_name, field_type = parts |
|
validate_field_name(field_name) |
|
fields.append((field_name, field_type)) |
|
|
|
return fields |
|
|
|
|
|
def main(): |
|
"""Main entry point for the CLI.""" |
|
# Check if using the --generate-from-model flag |
|
if len(sys.argv) > 1 and sys.argv[1] == "--generate-from-model": |
|
if len(sys.argv) < 4: |
|
print("Error: Not enough arguments for --generate-from-model.") |
|
print(__doc__) |
|
sys.exit(1) |
|
|
|
app_name = validate_app_name(sys.argv[2]) |
|
model_name = validate_model_name(sys.argv[3]) |
|
|
|
generate_from_model(app_name, model_name) |
|
return |
|
|
|
# Standard mode |
|
if len(sys.argv) < 3: |
|
print(__doc__) |
|
sys.exit(1) |
|
|
|
app_name = validate_app_name(sys.argv[1]) |
|
model_name = validate_model_name(sys.argv[2]) |
|
fields = parse_fields(sys.argv[3:]) |
|
|
|
# Check that we have at least one field |
|
if not fields: |
|
print("Error: At least one field must be provided.") |
|
print(__doc__) |
|
sys.exit(1) |
|
|
|
create_or_update_app_structure(app_name, model_name, fields) |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |