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:
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¶
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:
See Also¶
- Layered Architecture --- Layer rules
- Code Conventions --- Coding standards
- Adding a Provider --- Provider plugin pattern