skip to content

In a Django newsroom app, how do you add a custom 'can publish' permission to an Article model, and what does declaring it enforce?

level: middleimportance: should knowfreq 48%

answer

  1. a Meta option on the model
  2. (codename, name) pairs
  3. row appears after migrate
  4. checking is still your job

basics

~10 s

Declare Meta.permissions = [('publish_article', 'Can publish article')] on Article and run migrate, which creates the Permission row. The declaration enforces nothing: views must still check has_perm('newsroom.publish_article') before publishing.

solid answer

~30 s

You add `permissions = [("publish_article", "Can publish article")]` to `Article.Meta`. `makemigrations` records the option change and `migrate`'s `post_migrate` handler creates the `Permission` row, which can then be granted and is checked as `"newsroom.publish_article"`. Declaring it enforces nothing — no field is locked and no button appears — so the publishing view must call `has_perm()` or use a permission decorator or mixin. `Meta.default_permissions` trims the built-in four, but only before the model is first migrated, because `migrate` adds missing rows and never deletes obsolete ones. System checks reject a codename that clashes with a built-in (`auth.E005`) or is duplicated (`auth.E006`).

go deeper

for a junior

Remember the shape: a list of (codename, human-readable name) tuples in the model's Meta, checked later as app label dot codename.

for a middle

Explain the chain from Meta option to AlterModelOptions migration to the post_migrate handler, and state plainly that nothing is enforced until code calls has_perm().

for a senior

Show awareness that migrate never deletes permissions, that codenames collide across models in one app, and that default_permissions must be set before first creation.

for a principal

Judge which business actions deserve a named permission versus a status workflow check, and how to retire permissions cleanly across environments.

## Declaring the permission Default permissions cover add, change, delete and view. Anything else — publishing, approving, exporting — is a **custom permission**, declared on the model's `Meta` as a list of `(codename, human-readable name)` pairs: ```python from django.db import models class Article(models.Model): headline = models.CharField(max_length=200) body = models.TextField() published_at = models.DateTimeField(null=True, blank=True) class Meta: permissions = [ ("publish_article", "Can publish article"), ] ``` In an app labelled `newsroom`, the check string becomes `"newsroom.publish_article"`. ## What happens between the declaration and the database 1. **`makemigrations`** notices the changed `Meta.permissions` (it is one of the options tracked by `AlterModelOptions`, alongside `default_permissions`) and writes an operation into a migration file. That keeps the migration state in step with the model; it does not insert a row. 2. **`migrate`** applies the migration and then fires **`post_migrate`**. The `create_permissions` handler in `django.contrib.auth` computes every permission each model should have — the defaults plus `Meta.permissions` — and bulk-creates the ones that are missing. 3. The row now exists and can be granted to users or groups, and it appears in the admin's permission picker. ## What the declaration does not do The Django documentation is blunt: creating the permission row is **the only thing** `Meta.permissions` does. It does not: - stop anyone from setting `published_at` or calling `save()`; - add a "Publish" button, action or form field anywhere; - change what the admin shows, except that the permission can now be assigned. Enforcement is your code's job: a view, form or service function must call `request.user.has_perm("newsroom.publish_article")` (or use a permission decorator or mixin) before it publishes. A newsroom that declares the permission but forgets the check has a label, not a control. ## Trimming the defaults with `default_permissions` `Meta.default_permissions` controls which built-in actions are generated. It defaults to `('add', 'change', 'delete', 'view')`; setting it to `()` produces none, and `('view',)` only the view permission. The documentation adds a timing rule: it must be set **before the model is first created by `migrate`**, because the handler only adds rows. Trimming it later leaves the old rows in the table. ## Rules and traps Django's system checks catch several mistakes before anything runs (`manage.py check`, `runserver`, `migrate` and most other management commands run them): | Mistake | Check | |---|---| | A custom codename equal to a built-in one, such as `change_article` | `auth.E005` | | The same custom codename twice on one model | `auth.E006` | | A custom name longer than 255 characters | `auth.E008` | | A custom codename longer than 100 characters | `auth.E012` | Beyond the checks: - **Removing an entry does not remove the row.** `migrate` never deletes obsolete permissions, and any user or group that held it still holds it. Delete the row deliberately (for example in a data migration) if it should disappear. - **The codename is scoped per content type in the table, but not in the check string.** `ModelBackend` builds strings from the app label and codename only, so two models in the same app that both declare a codename `publish` collapse into one string, `"newsroom.publish"`. Including the model name in the codename (`publish_article`, `publish_video`) avoids that ambiguity. - **Proxy models get their own permissions**, created against the proxy's own content type; they do not inherit the concrete model's custom permissions. ## Checking it where publishing happens The check belongs wherever the state change happens, not only in the template that shows a button. A service function makes the rule explicit and reusable from a view, a management command or an API endpoint: ```python from django.core.exceptions import PermissionDenied from django.utils import timezone def publish(article, user): if not user.has_perm("newsroom.publish_article"): raise PermissionDenied("Only editors with publishing rights can publish.") article.published_at = timezone.now() article.save(update_fields=["published_at"]) ``` Raising `PermissionDenied` inside a view produces a 403 response through Django's standard handler. Hiding the button for users without the right is a courtesy; the function above is the control. ## The programmatic alternative A permission row can also be created directly with `Permission.objects.create(codename=..., name=..., content_type=ContentType.objects.get_for_model(Article))`. That suits permissions generated from data, but the declarative `Meta.permissions` form is preferred for fixed permissions: it lives next to the model, is reviewed in code and is recreated automatically on every fresh database.

  • You remove ('publish_article', ...) from Meta.permissions and run migrate. What happens to editors who held it?
    Nothing changes for them. `migrate` only creates missing permission rows; it never deletes obsolete ones, so the row and every user or group assignment of it remain, and `has_perm("newsroom.publish_article")` still returns `True` for them. Delete the `Permission` row deliberately if the right should truly disappear.
  • Why include the model name in a custom codename rather than just 'publish'?
    The check string is only `app_label.codename`. If `Article` and `Video` in the same app both declare `publish`, the backend sees one string, `"newsroom.publish"`, so granting either satisfies checks meant for the other. `publish_article` and `publish_video` stay distinct.

saying these in an interview costs you the question

  • Meta.permissions stops unprivileged users from saving published articles
  • makemigrations inserts the new permission row into the database
  • Removing a codename from Meta.permissions revokes it from everyone
  • Setting default_permissions later deletes the unwanted built-in rows
  • A custom codename can override a built-in like change_article