Which ModelAdmin permission hooks does the Django admin call, and how would you make one model read-only for every staff user?
answer
- four model hooks plus one for the app
- view is implied by change
- the obj argument defaults to None
- return False, keep view
basics
~20 sThe 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 sEvery 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 linesfrom 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
Recall the four model hooks and that returning False from them removes the matching buttons and pages.
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.
Use overrides deliberately: they bind superusers, inlines have their own hooks, and object-level rules only exist once you implement them.
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