Code generation

Django target

What @appling/codegen-django generates from a model, how the generated Django project is laid out, and how to run it.

@appling/codegen-django turns a model into a draft Django 6.1 project managed by uv. Every module becomes a Django app; domain elements become models, behavior becomes plain functions and signals, and REST interfaces become Django REST framework views.

Requirements

ToolNotes
uvMust be on the PATH. appling generate checks it with uv --version before it runs.
Python 3.12 or laterRequired by Django 6. uv installs Python into the project, but this is not checked: if uv picks an older interpreter, uv add django fails.
Networkuv downloads Django and REST framework.

Configuration

{
  "generate": [
    { "extension": "@appling/codegen-django", "out": "build/shop", "options": { "name": "shop" } }
  ]
}
OptionMeaning
nameThe Python project name passed to uv init. Default: the name of the output folder.

The Django settings package is always config.

Project setup

The extension creates the project with uv and Django’s own commands, then patches the files they create:

StepCommand
Python projectuv init --bare --name <name>
Dependenciesuv add django~=6.1.0, plus djangorestframework~=3.16 when the model has projections or interfaces
Django projectuv run django-admin startproject config ., and rest_framework in INSTALLED_APPS when REST framework is installed
Appsuv run python manage.py startapp <app> per module, added to INSTALLED_APPS
Migrationsuv run python manage.py makemigrations, as the very last step

Because the project ends with makemigrations, its migration history starts with the generated models. appling generate --migrate continues that history later.

Model to Django

The app of a module is its qualified name with dots replaced by underscores: the nested module a.b becomes the app a_b. Names of functions and signals are the snake case form of the element name, such as place_order for PlaceOrder.

ModelDjango
modulean app created by startapp, in INSTALLED_APPS
enuma models.TextChoices class in <app>/enums.py; each value becomes an upper case member, such as DRAFT = "draft"
valuea TypedDict in <app>/values.py
entitya models.Model in <app>/models.py
stored fielda model field (see the next table)
derived fielda @property that returns the expression translated to Python
invarianta CheckConstraint in Meta.constraints, named <app>_<entity>_<label>, when the condition compares stored fields with constants or other stored fields; a test class
policya test class; enforcing it is a task
rolea task to create a Django group for the role
eventa Signal in <app>/events.py
errorsexception classes in <app>/errors.py, which also defines InvalidTransition
actiona function in <app>/actions.py (see actions)
machinetransitions of actions become part of the action function; transitions on events become signal receivers in <app>/handlers.py, connected in AppConfig.ready
transitiona test class
scheduled joba django.tasks task in <app>/jobs.py and a management command named after the job
job on an eventa signal receiver in <app>/handlers.py
querya function in <app>/queries.py returning a queryset with filter and ordering, plus a <QUERY>_PAGE_SIZE constant for paginate
projectiona REST framework ModelSerializer in <app>/serializers.py; derived fields and computed fields that follow references, such as customer.name, become read only fields
externala typing.Protocol in <app>/externals.py
usecasea function in <app>/usecases.py that calls its steps in order
interface restviews in <app>/api.py and routes in <app>/api_urls.py, included under api/ in config/urls.py, so GET "/orders" answers at /api/orders
pagea view in <app>/pages.py and a route in <app>/urls.py, included at the site root (see pages)
navigationthe menu of <app>/templates/<app>/base.html
messageslabels in <app>/messages.py, used for the columns and fields of pages
flow, actors, tenantsnot generated; todo entries in the registry

Fields

Appling typeDjango field
StringCharField(max_length=255)
TextTextField()
IntIntegerField()
DecimalDecimalField(max_digits=19, decimal_places=4)
MoneyDecimalField(max_digits=19, decimal_places=2)
BoolBooleanField()
DateDateField()
DateTimeDateTimeField()
DurationDurationField()
FileFileField(upload_to="<app>/<entity>/")
UuidUUIDField()
an enumCharField(max_length=..., choices=<Enum>.choices)
an entityForeignKey("<app>.<Entity>", on_delete=...): CASCADE when the field is owned, otherwise PROTECT
a list of entitiesManyToManyField("<app>.<Entity>")
a value type or any other listJSONField(encoder=DjangoJSONEncoder) with default=dict or default=list
ModifierDjango
?null=True, blank=True
uniqueunique=True
defaultdefault=; now becomes timezone.now

For the Order entity of the example, this gives:

class Order(models.Model):
    reference = models.CharField(max_length=255, unique=True)
    customer = models.ForeignKey("crm.Account", on_delete=models.PROTECT)
    lines = models.JSONField(encoder=DjangoJSONEncoder, default=list)
    status = models.CharField(max_length=32, choices=OrderStatus.choices, default=OrderStatus.DRAFT)
    created_at = models.DateTimeField()
    placed_at = models.DateTimeField(null=True, blank=True)
    paid_at = models.DateTimeField(null=True, blank=True)

    @property
    def total(self):
        return sum([Decimal(l["amount"]) for l in self.lines])

Actions

An action becomes a function that takes the entity instance first, its inputs as keyword arguments and an optional actor. It runs in one transaction (@transaction.atomic) unless the action has consistency eventual. In order, it:

  1. Checks each precondition and raises the declared error, or ValueError without one.
  2. Applies the transition of the state machine: the current state and the guard pick the branch, and any other state raises InvalidTransition.
  3. Applies the effects: set assigns a field, create calls objects.create, emit sends the event’s signal after the transaction commits.
  4. Saves the instance when it changed.
@transaction.atomic
def place_order(order: Order, *, actor=None) -> None:
    if not (len(order.lines) > 0):
        raise EmptyOrder()
    if order.status in (OrderStatus.DRAFT,):
        transaction.on_commit(lambda: order_placed.send(sender=None, order=order))
        order.status = OrderStatus.PLACED
    else:
        raise InvalidTransition("place_order", order.status)
    order.placed_at = timezone.now()
    order.save()

A use case calls its actions with actor=actor. Each external system it calls becomes a keyword parameter typed with its protocol, such as payments: Payments, so you pass the client in. Error handlers become try/except blocks.

REST layer

Each endpoint of a REST interface becomes a view:

EndpointView
GET a query, with a projectiona generics.ListAPIView over the query function, using the projection’s serializer; picks creates a serializer subclass with only those fields; paginate adds a PageNumberPagination
POST, PUT, PATCH or DELETE an actionan APIView that loads the instance by {id}, calls the action with the request data and actor=request.user, and answers 204; declared errors and InvalidTransition answer 400 with {"error": "<name>"}

Path parameters become Django converters: {id} becomes <int:id>, others <str:name>. A limit on the interface becomes a UserRateThrottle on every view. An action on an entity needs {id} in its path; endpoints that do not fit these patterns become tasks.

Jobs

A scheduled job becomes a task of the django.tasks framework that runs the action for every row matching the filter, plus a management command that enqueues it:

@task
def expire_drafts() -> None:
    for order in Order.objects.filter((models.Q(status="draft")) & (models.Q(created_at__lt=timezone.now() - timedelta(hours=720)))):
        cancel(order)

Calling it on the cron schedule is a registry task, for example schedule "python manage.py expire_drafts" with cron "0 3 * * *". Without a TASKS setting, Django uses its immediate backend and runs the task inline when the command enqueues it.

A job needs an action on an entity without required inputs; otherwise it becomes a task. A job on an event becomes a signal receiver when the event has a field of the entity’s type.

Tests

Invariants, policies, transitions and actions with preconditions get a TestCase class in <app>/tests.py, with one skipped method per case. The skip reason names the registry entry, and writing the bodies is a task of that entry. See test skeletons.

RuleTest methods
invarianttest_holds_for_valid_rows, test_rejects_violations
policytest_allows_permitted_actors, test_denies_other_actors
transitiontest_moves_from_allowed_states, test_refuses_other_states
action with preconditionstest_precondition_<n>_is_enforced

Pages

A page is rendered on the server. Its view collects the page’s components for the shared template <app>/templates/<app>/page.html, which extends the app’s base.html:

ComponentRenders
list <query> as <projection> { picks }a table of the query’s rows with the picked projection fields, paginated by the query’s paginate, and a button per row for each action
detail <projection>the record with the route’s {id}, its fields, and a button for each action
form <action>a field for each input of the action and a submit button

Buttons and forms post to /_actions/<app>/<action>/<id>/, a runner view that calls the action as the signed in user, shows <Action> done. or <Action> failed: <error> through Django’s messages, and returns to the page. Projection fields that follow references, such as customer_name = customer.name, are shown by following the path. Column and field labels come from the module’s messages (OrderSummary.total = "Total"), in the active language, and default to the field name. A navigation item becomes a link in the menu of base.html.

Project layout

For a model with the modules crm and sales:

build/shop/
  pyproject.toml, uv.lock, manage.py
  config/
    settings.py          INSTALLED_APPS with rest_framework and every app
    urls.py              includes the page and API urls of the apps
  sales/
    models.py            entities
    enums.py             enums
    values.py            value types
    errors.py            InvalidTransition and declared errors
    events.py            signals
    actions.py           actions
    handlers.py          receivers for event transitions and event jobs
    apps.py              ready() connects handlers.py
    jobs.py              scheduled jobs
    management/commands/<job>.py
    queries.py           queries
    serializers.py       projections
    externals.py         protocols for external systems
    usecases.py          use cases
    api.py, api_urls.py  REST interface
    pages.py, urls.py    pages and the action runners they post to
    messages.py          labels per language
    templates/sales/     base.html with the navigation, page.html
    tests.py             skipped test skeletons
    migrations/
  crm/
    ...
  .appling/
    tasks.json           the task registry

An app only gets the files its module needs.

What becomes a task

Whatever cannot be translated is listed in the registry:

  • invariants over derived fields, lists or other entities, and derived fields or defaults that use actor or other context
  • policies and roles
  • preconditions, guards and effects without a Python translation
  • actions without any generated code, and actions with consistency eventual
  • the client of an external system
  • the cron schedule of jobs, and jobs that do not fit the patterns above
  • use cases from their first await on, or from the first call that cannot be translated
  • authentication and CORS of interfaces, and endpoints that do not fit the view patterns
  • computed projection fields that are not a path over entity fields
  • row actions that need inputs, details and forms on pages without {id} in the route, navigation items to such pages, and flows
  • actors, tenants, and interfaces with a protocol other than rest

Run the generated project

cd build/shop
uv run python manage.py migrate
uv run python manage.py runserver
uv run python manage.py test

migrate creates the database (SQLite, as configured by startproject). test is also the command the registry stores, so the AI tools run it after each task. A scheduled job runs with its management command, for example uv run python manage.py expire_drafts.