skip to content

Which ModelAdmin permission hooks does the Django admin call, and how would you make one model read-only for every staff user?

level: middleimportance: should knowfreq 48%

answer

  1. four model hooks plus one for the app
  2. view is implied by change
  3. the obj argument defaults to None
  4. return False, keep view

basics

~20 s

The Django admin calls has_view_permission, has_add_permission, has_change_permission and has_delete_permission on the ModelAdmin, plus has_module_permission for the app on the index. Returning False from add, change and delete leaves a read-only model for users with view rights.

solid answer

~40 s

Every admin view asks the `ModelAdmin` before acting. `has_view_permission(request, obj=None)` is true with the model's view **or** change permission; `has_add_permission(request)`, `has_change_permission(request, obj=None)` and `has_delete_permission(request, obj=None)` map to the add, change and delete codenames. By default they ignore `obj`. `has_module_permission(request)` decides whether the app appears on the admin index, but does not protect the model's views. The index lists a model only if one of its four hooks is true, the change list needs view or change, and a user with view but not change gets a read-only change form. To make an audit model read-only, override `has_add_permission`, `has_change_permission` and `has_delete_permission` to return `False`; staff still need the view permission, and because the overrides never call `has_perm()`, they bind superusers too.

code

python · 19 lines
python
from django.contrib import admin

from .models import AuditRecord


@admin.register(AuditRecord)
class AuditRecordAdmin(admin.ModelAdmin):
    list_display = ["created_at", "actor", "event"]

    def has_add_permission(self, request):
        return False

    def has_change_permission(self, request, obj=None):
        return False

    def has_delete_permission(self, request, obj=None):
        return False

    # has_view_permission is inherited: staff need shops.view_auditrecord.

go deeper

for a junior

Recall the four model hooks and that returning False from them removes the matching buttons and pages.

for a middle

Explain which view calls which hook, how view relates to change, the meaning of obj=None, and why has_module_permission is only for the index.

for a senior

Use overrides deliberately: they bind superusers, inlines have their own hooks, and object-level rules only exist once you implement them.

for a principal

Decide which data staff may ever edit by hand and make those rules explicit in hooks rather than in group conventions nobody reviews.

## The hooks `django.contrib.admin` never checks `user.has_perm()` directly in its views; it asks the `ModelAdmin` (or inline) through overridable **permission hooks**: | Hook | Default answer | Where the admin uses it | |---|---|---| | `has_view_permission(request, obj=None)` | view **or** change codename | change list, read-only change form, autocomplete | | `has_add_permission(request)` | add codename | "Add" button, add view | | `has_change_permission(request, obj=None)` | change codename | editable change form, `list_editable`, change-gated actions | | `has_delete_permission(request, obj=None)` | delete codename | delete view, `delete_selected`, cascade check | | `has_module_permission(request)` | `user.has_module_perms(app_label)` | showing the app on the admin index | The default bodies build a full permission name like `shops.change_store` from the model's `app_label` and `get_permission_codename()`, and call `request.user.has_perm()`. What those codenames are and how groups grant them is the permissions topic; the admin simply asks. ## How the answers shape the UI 1. **Index page.** `has_module_permission()` must be true for the app, and at least one of `get_model_perms()` — the four model hooks with no object — must be true for a model to be listed. 2. **Change list.** Requires view or change. Without add, the "Add" button disappears. 3. **Change form.** With view but not change, the form renders every field read-only and offers no save buttons; a POST is refused with `PermissionDenied`. 4. **Delete.** Without delete, the delete link, the delete view and the `delete_selected` action are all unavailable. ## The `obj` argument `has_view_permission`, `has_change_permission` and `has_delete_permission` accept an optional `obj`. The admin passes `None` when asking "may this user do this to **any** row" — for the index, the change list and action filtering — and the instance when rendering or saving a specific object. The defaults **do not look at `obj`**, so object-level rules only exist if you override the hook, or install an authentication backend that implements object permissions. ## Two traps - **`has_module_permission()` is cosmetic for security.** Its docstring says overriding it "does not restrict access to the add, change or delete views"; a user who knows the URL still gets in if the model hooks allow it. - **Overrides bind superusers.** The defaults pass superusers because `has_perm()` returns `True` for them. An override that returns `False` without calling `has_perm()` applies to everyone — exactly what a read-only audit model wants, and a surprise when it was not intended. ## Making a model read-only For an append-only `AuditRecord`, written by code and never edited by hand: - Return `False` from `has_add_permission`, `has_change_permission` and `has_delete_permission`. - Leave `has_view_permission` alone, and grant the view permission to the staff groups that need it. - The model then appears with a view-only change list and read-only detail pages; `delete_selected` vanishes because it requires the delete hook. Alternative approaches are weaker: `readonly_fields` still allows adding and deleting; simply not granting change permissions leaves superusers able to edit. ## Inlines have the same hooks `InlineModelAdmin` has its own four hooks, and its `has_add_permission(request, obj)` takes the parent object. Inline rows follow the inline's hooks, not the parent's, so a read-only parent with an editable inline is possible and usually a mistake. ## Walking one request through the hooks A support agent in the "support" group, holding only `shops.view_store`, clicks **Stores** on the admin index and opens one store: 1. The index asks `has_module_permission()` for the `shops` app — true, since the user holds a permission in it — and `get_model_perms()` for Store: view is true, so the model is listed. 2. The change list checks `has_view_or_change_permission(request)` — true — and hides the "Add" button because `has_add_permission()` is false. 3. Actions requiring `change` or `delete` are filtered out; the checkbox column disappears if none remain. 4. Opening a store calls `has_view_permission(request, obj)` and `has_change_permission(request, obj)`; with view only, every field is rendered read-only and there is no save or delete button. 5. Any POST to that form is refused with `PermissionDenied`. Every step is a hook you can override, which is why these five methods are where custom admin access rules belong.

  • In the Django admin, if you override has_module_permission() to return False for non-superusers, can staff still edit that app's models?
    Yes, if the model hooks allow it. `has_module_permission()` only hides the app from the admin index; the change list, change form and delete views check the `has_*_permission` model hooks. A staff user with the right permissions who types the URL gets in, so restrict the model hooks as well.
  • What does the Django admin do when a user with view but not change permission POSTs to an object's change form?
    It raises `PermissionDenied`. The GET renders the form read-only because `has_change_permission(request, obj)` is false, and the POST path checks the same hook before binding the form, so a hand-crafted submission gets a 403 instead of saving.

saying these in an interview costs you the question

  • has_module_permission returning False blocks the model's admin views
  • The default has_change_permission checks the object passed in
  • View permission alone hides the change list entirely
  • Superusers always bypass overridden has_*_permission hooks
  • readonly_fields is the complete way to make a model read-only