Skip to content

Adding an App

Step-by-step guide for creating a new Django app following project conventions.

Directory Structure

Create the following files in backend/apps/{app_name}/:

apps/{app_name}/
├── __init__.py
├── apps.py               # AppConfig
├── models.py             # Data models
├── services.py           # Business logic (async, writes)
├── selectors.py          # Read queries (async, read-only)
├── schemas.py            # Pydantic schemas (API input/output)
├── api.py                # django-ninja-extra controllers
├── admin.py              # Django admin registration
├── baker_recipes.py      # model-bakery recipes (optional)
├── choices.py            # TextChoices enums (optional)
├── permissions.py        # Custom permissions (optional)
├── signals.py            # Signal handlers (optional)
├── tasks.py              # Celery tasks (optional)
├── providers/            # Provider protocol + implementations (optional)
├── migrations/
│   └── __init__.py
└── tests/
    ├── __init__.py
    ├── test_services.py
    ├── test_selectors.py
    └── test_api.py

Step 1: AppConfig

# apps/{app_name}/apps.py
from django.apps import AppConfig

class ThingsConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "apps.things"
    label = "things"

Step 2: Models

Inherit from BaseModel (provides UUID PK, timestamps, external_id, archivable):

# apps/{app_name}/models.py
import pghistory
from django.db import models
from django.utils.translation import gettext_lazy as _
from common.models import BaseModel

@pghistory.track()
class Thing(BaseModel):
    name = models.CharField(max_length=255, help_text=_("Thing name."))
    description = models.TextField(blank=True, default="", help_text=_("Description."))
    status = models.CharField(
        max_length=20,
        choices=ThingStatus.choices,
        default=ThingStatus.DRAFT,
        help_text=_("Current status."),
    )

    class Meta:
        verbose_name = _("thing")
        verbose_name_plural = _("things")
        ordering: ClassVar[list[str]] = ["-created_at"]

For models that attach to multiple parents, use GenericForeignKey:

from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType

class Attachment(BaseModel):
    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
    object_id = models.UUIDField()
    content_object = GenericForeignKey("content_type", "object_id")

Step 3: Services

Async business logic with transactions:

# apps/{app_name}/services.py
from uuid import UUID
from django.db import transaction
from django.utils.translation import gettext as _
from .models import Thing

async def create_thing(*, name: str, description: str = "") -> Thing:
    with transaction.atomic():
        thing = await Thing.objects.acreate(
            name=name,
            description=description,
        )
        return thing

async def update_thing(*, thing_id: UUID, **kwargs: Any) -> Thing:
    thing = await Thing.objects.aget(id=thing_id)
    for field, value in kwargs.items():
        setattr(thing, field, value)
    await thing.asave(update_fields=list(kwargs.keys()) + ["updated_at"])
    return thing

Step 4: Selectors

Async read-only queries:

# apps/{app_name}/selectors.py
from django.db.models import QuerySet
from .models import Thing

async def thing_list(
    *,
    filters: str | None = None,
    sort: str | None = None,
) -> QuerySet[Thing]:
    qs = Thing.objects.filter(is_archived=False)
    # Apply filters and sorting...
    return qs

async def get_thing(*, thing_id: UUID) -> Thing:
    return await Thing.objects.aget(id=thing_id)

Step 5: Pydantic Schemas

# apps/{app_name}/schemas.py
from ninja import Field, Schema
from common.schemas import ArchivableModelOut

class ThingIn(Schema):
    name: str = Field(description="Thing name")
    description: str = Field(default="", description="Optional description")

class ThingOut(ArchivableModelOut):
    name: str = Field(description="Thing name")
    description: str = Field(description="Description")
    status: str = Field(description="Current status")

Step 6: API Controller

# apps/{app_name}/api.py
from ninja_extra import api_controller, http_get, http_post, ControllerBase
from common.controllers import ReactAdminControllerMixin
from common.permissions import IsViewerOrAbove, IsLoanOfficerOrAbove
from . import selectors, services
from .schemas import ThingIn, ThingOut

@api_controller("/things", tags=["Things"], permissions=[IsViewerOrAbove])
class ThingController(ControllerBase, ReactAdminControllerMixin):
    @http_get(
        "",
        response=list[ThingOut],
        operation_id="list_things",
        summary="List things",
    )
    async def list_things(self, sort: str = "", range: str = "", filter: str = ""):
        qs = await selectors.thing_list(filters=filter or None, sort=sort or None)
        return await self.paginated_list(qs, "things", range or None)

    @http_post(
        "",
        response={201: ThingOut},
        permissions=[IsLoanOfficerOrAbove],
        operation_id="create_thing",
        summary="Create a thing",
    )
    async def create_thing(self, payload: ThingIn):
        thing = await services.create_thing(**payload.dict())
        return 201, thing

Step 7: Register in Settings

Add to INSTALLED_APPS in backend/config/settings/base.py:

LMS_APPS = [
    "apps.things",
    # ...
]

If this is a tenant-scoped app, it goes in the TENANT_APPS list. If shared, in PUBLIC_APPS or MAIN_APPS.

Step 8: Create Migrations

cd backend
uv run python manage.py makemigrations things
uv run python manage.py migrateschema -as

Step 9: Write Tests

# apps/{app_name}/tests/test_services.py
import pytest
from model_bakery import baker
from .. import services

pytestmark = pytest.mark.django_db(transaction=True)

class TestCreateThing:
    async def test_creates_thing(self):
        thing = await services.create_thing(name="Widget")
        assert thing.name == "Widget"
        assert thing.status == "draft"

    async def test_requires_name(self):
        with pytest.raises(Exception):
            await services.create_thing(name="")
# apps/{app_name}/tests/test_api.py
import pytest
from model_bakery import baker
from ninja_extra.testing import TestAsyncClient
from ..api import ThingController

pytestmark = pytest.mark.django_db(transaction=True)

class TestThingController:
    @pytest.fixture()
    def client(self):
        return TestAsyncClient(ThingController)

    async def test_list_things(self, client, api_user, test_tenant):
        baker.make("things.Thing", _quantity=3)
        response = await client.get("", user=api_user, tenant=test_tenant)
        assert response.status_code == 200

Step 10: Add Frontend Resource

Create screens in frontend/admin/src/admin/{things}/:

things/
├── index.ts              # Barrel export + icon
├── ThingList.tsx          # List view
├── ThingShow.tsx          # Show view
├── ThingCreate.tsx        # Create form
└── ThingEdit.tsx          # Edit form

Register the resource in App.tsx:

import things from "@/admin/things";

<Resource name="things" {...things} />

See Also