In a Django clinic app, how do you seed Nurse, Doctor and Admin groups with permissions in every environment, and why does a data migration often fail on a fresh database?
answer
- roles are rows, not code
- created after the last migration
- a signal sent per app
- idempotent and loud on typos
basics
~20 sDeclare the roles in code and write them with an idempotent post_migrate receiver, a migration that creates its own rows, or a deploy command. A plain data migration fails on a fresh database because default permissions are created by post_migrate, after every migration has run.
solid answer
~40 sGroups and their grants are rows, so each environment needs them written from a declaration in code. A data migration that looks up `clinic.add_prescription` works on an old database but fails on a fresh one: `contrib.auth` creates default permissions in a `post_migrate` handler, after all migrations in the run. Reliable options are a `post_migrate` receiver connected in `AppConfig.ready()` with `sender=self` that uses `get_or_create` and `group.permissions.set()` and raises on unknown codenames; a data migration that creates the `ContentType` and `Permission` rows it needs itself; or a seeding command run after `migrate`. The receiver runs after every migrate and flush, so it must be idempotent and default `apps` for flush.
code
python · 33 linesfrom django.apps import AppConfig
from django.apps import apps as global_apps
from django.db import DEFAULT_DB_ALIAS
from django.db.models.signals import post_migrate
ROLE_PERMISSIONS = {
"Nurses": {"view_patient", "add_vitalsign", "change_vitalsign"},
"Doctors": {"view_patient", "change_patient", "add_prescription", "view_vitalsign"},
"Clinic admins": {"view_patient", "add_appointment", "change_appointment"},
}
def seed_roles(sender, using=DEFAULT_DB_ALIAS, apps=global_apps, **kwargs):
Group = apps.get_model("auth", "Group")
Permission = apps.get_model("auth", "Permission")
for name, codenames in ROLE_PERMISSIONS.items():
perms = list(
Permission.objects.using(using).filter(
content_type__app_label=sender.label, codename__in=codenames
)
)
missing = codenames - {p.codename for p in perms}
if missing:
raise RuntimeError(f"{name}: unknown permissions {sorted(missing)}")
group, _ = Group.objects.using(using).get_or_create(name=name)
group.permissions.set(perms)
class ClinicConfig(AppConfig):
name = "clinic"
def ready(self):
post_migrate.connect(seed_roles, sender=self)go deeper
Know that groups created in the admin exist only in that one database, so roles must be written by code.
Explain that default permissions come from a post_migrate handler and therefore do not exist during the migrations of the same run.
Pick a seeding mechanism, make it idempotent and loud on typos, and handle flush, fresh databases and INSTALLED_APPS order.
Decide whether role definitions are owned by code or by administrators, and make the seeding behaviour match that ownership.
## Why roles need seeding at all A `Group` and its permission set are **rows**, not code. A developer who clicks "Nurses" together in the admin has configured one database; the test database, a colleague's laptop and a new staging environment all start empty. Roles therefore need to be **declared in the codebase** and written into every database automatically. ## The trap: permission rows do not exist yet The obvious place is a data migration that creates the groups and attaches permissions. On an existing database it works. On a **fresh** database — CI, a new environment, the test runner — it fails with `Permission.DoesNotExist`, or quietly attaches nothing when it uses `filter()`. The reason is the order of events in `migrate`: 1. All migrations run, including `CreateModel` for `Patient`, `VitalSign` and the rest, and then your data migration. 2. Only **after** the whole run does `migrate` send `post_migrate`. 3. `django.contrib.contenttypes` and `django.contrib.auth` react to `post_migrate` by creating the missing `ContentType` rows and the default `add`/`change`/`delete`/`view` permissions. So a data migration that runs in the same `migrate` as the models it refers to looks for permission rows that will only be created a moment later. ## Option 1 — a `post_migrate` receiver Connect an idempotent seeding function in your app's `AppConfig.ready()` with `sender=self`. `post_migrate` is sent once per app, and `contrib.auth`'s handler is connected in its own `ready()`, so with `django.contrib.auth` listed before the clinic app in `INSTALLED_APPS` the clinic's permissions already exist when your receiver runs. Properties to design for: - It runs after **every** `migrate`, even when no migration was applied, and after `flush` unless that flush inhibits the signal — so it must be **idempotent** (`get_or_create`, `set()`). - `flush` sends the signal without the `apps` and `plan` arguments, so default them the way Django's own handlers do. - A missing codename should **fail loudly**; `filter(codename__in=...)` silently drops typos. - `group.permissions.set(...)` makes the database match the declaration; it also undoes any change someone made to that group in the admin. ## Option 2 — create what you need inside the migration A data migration can `get_or_create` the `ContentType` and `Permission` rows it needs before attaching them, using the historical models from `apps.get_model()`. This keeps role history in migrations, at the cost of duplicating what `post_migrate` would create; later runs of the default handler find the rows present and skip them. Some projects instead call the internal `create_permissions()` function from a migration — it works, but it is undocumented and has changed shape across releases. ## Option 3 — a management command in the deploy A `seed_roles` command run after `migrate` in the deploy pipeline is explicit and easy to rerun, but every environment, including test setup, must remember to call it. ## Comparison | Approach | Fresh database | Runs automatically | Main risk | |---|---|---|---| | Data migration using existing rows | Fails or attaches nothing | Yes, once | The `post_migrate` ordering trap | | Data migration creating rows itself | Works | Yes, once | More code to keep in step with the models | | `post_migrate` receiver | Works | After every migrate and flush | Overwrites admin edits if using `set()` | | Management command | Works | Only if called | Forgotten in one environment | ## Verifying the result Whatever the mechanism, a test that runs against the migrated test database proves the roles exist with the intended rights: - load each group by name and compare `group.permissions.values_list("codename", flat=True)` with the declared set; - create a user in the Nurses group and assert `has_perm("clinic.add_vitalsign")` is `True` and `has_perm("clinic.add_prescription")` is `False`. Because the test database is built by running `migrate`, this test fails on exactly the fresh-database path that a data migration gets wrong. ## Details that bite - Filter permissions by **both** `content_type__app_label` and `codename`; codenames are only unique per model. - Model names in codenames are lowercase: `VitalSign` gives `add_vitalsign`. - In tests, a `TransactionTestCase` flushes the database after each test and, unless `available_apps` or serialized rollback is in use, re-sends `post_migrate`; a receiver therefore re-creates the groups, while groups created only by a data migration disappear unless the test case restores serialized data.
- Why does the receiver default the apps argument instead of requiring it?`migrate` sends `post_migrate` with `apps` and `plan`, but `flush` — used by `TransactionTestCase` between tests — sends it with only verbosity, interactivity and the database alias. A receiver that requires `apps` crashes under flush; Django's own `create_permissions()` defaults it to the global registry for the same reason.
- What is the downside of group.permissions.set() in the seeding function?It makes each group match the code exactly, removing anything an administrator added through the admin on the next migrate. That is right when code is the source of truth; if administrators are meant to tune roles, create missing groups and add only missing permissions instead.
saying these in an interview costs you the question
- A data migration can always rely on default permissions existing
- makemigrations creates the permission rows the migration needs
- post_migrate runs only when at least one migration was applied
- Filtering by codename alone is enough to find the right permission