In Django's admin, how do you vary a change form per request, such as making price read-only for non-superusers, with get_form() and related hooks?
answer
- request and obj arrive as arguments
- a hook per declarative option
- a factory builds the form class
- never mutate the class attribute
basics
~20 sOverride the get_* hook matching the option: get_readonly_fields(), get_fields() or get_fieldsets() receive request and obj. get_form() returns the ModelForm class, so override it to swap forms. Return new lists; never append to the class attribute.
solid answer
~30 sEvery declarative change-form option has a per-request hook receiving `request` and `obj` (`None` on the add page): `get_fields()`, `get_fieldsets()`, `get_readonly_fields()`, `get_prepopulated_fields()`, `get_autocomplete_fields()`, `get_inlines()`. `get_form(request, obj=None, change=False, **kwargs)` then builds the `ModelForm` class with `modelform_factory()`, using the fieldsets for `fields` and adding the read-only fields and `get_exclude()` to `exclude`; override it to pass a different base form, for example `kwargs["form"] = SuperuserCourseForm`. For "price read-only unless superuser", override `get_readonly_fields()` and return `[*super().get_readonly_fields(request, obj), "price"]`. The trap the docs warn about: these hooks return the class attribute itself, so `readonly.append("price")` mutates it for every later request, including superusers.
code
python · 23 linesfrom django.contrib import admin
from courses.forms import CourseAdminForm, SuperuserCourseAdminForm
from courses.models import Course
@admin.register(Course)
class CourseAdmin(admin.ModelAdmin):
form = CourseAdminForm
readonly_fields = ["created_at"]
def get_readonly_fields(self, request, obj=None):
readonly = [*super().get_readonly_fields(request, obj)] # a copy
if not request.user.is_superuser:
readonly.append("price")
if obj is not None and obj.is_published:
readonly.append("slug")
return readonly
def get_form(self, request, obj=None, change=False, **kwargs):
if request.user.is_superuser:
kwargs["form"] = SuperuserCourseAdminForm
return super().get_form(request, obj, change, **kwargs)go deeper
Know that get_readonly_fields() and get_fields() receive the request and object, so you can vary the form by user.
Explain what get_form() builds with modelform_factory, how read-only fields and exclude feed it, and when obj is None.
Show you avoid mutating class attributes on a shared ModelAdmin, delegate to super(), and rely on server-side exclusion for field-level rules.
Decide where field-level access rules live so the admin, APIs and forms enforce the same policy.
## Declarative options and their hooks `ModelAdmin` attributes such as `readonly_fields` are the same for every request. Real admins often need variation: editors see fewer fields than superusers, the add page differs from the change page, or a field locks once a course is published. Each option therefore has a hook that receives the request and the object being edited (`obj` is `None` on the add page): | Option | Hook | |---|---| | `fields` | `get_fields(request, obj=None)` | | `fieldsets` | `get_fieldsets(request, obj=None)` | | `readonly_fields` | `get_readonly_fields(request, obj=None)` | | `prepopulated_fields` | `get_prepopulated_fields(request, obj=None)` | | `autocomplete_fields` | `get_autocomplete_fields(request)` | | `exclude` | `get_exclude(request, obj=None)` | | `inlines` | `get_inlines(request, obj)` | | `form` | `get_form(request, obj=None, change=False, **kwargs)` | ## What get_form() does `get_form()` returns a form **class**, not an instance. Its default implementation: 1. takes `fields` from the flattened `get_fieldsets()` (unless `fields=` is passed in `kwargs`); 2. builds `exclude` from `get_exclude()` plus `get_readonly_fields()`; 3. excludes every field when this is a change page and the user lacks change permission (the view-only case); 4. calls `modelform_factory(self.model, form=self.form, fields=..., exclude=..., formfield_callback=...)`, where the callback routes through `formfield_for_dbfield()` and the `formfield_for_foreignkey()`/`formfield_for_manytomany()` hooks. So most per-request changes are better made in the narrower hooks, which `get_form()` already consults. Override `get_form()` itself when you need a **different base form class** (extra fields, different validation) or want to pass extra `kwargs` to the factory, and always delegate to `super().get_form(...)` so the read-only and permission handling above still happens. ## The mutation trap The docs carry an explicit warning: hooks that return a `ModelAdmin` property return **the property itself**, not a copy. Given `readonly_fields = ["created_at"]` on the class: - `readonly = super().get_readonly_fields(request, obj)` is the class's list; - `readonly.append("price")` adds `"price"` to that list for the life of the process; - the next superuser request sees `price` read-only too, and the list keeps growing with every non-superuser visit. Return a new list instead: `[*super().get_readonly_fields(request, obj), "price"]`. The same applies to `get_fields()`, `get_fieldsets()`, `get_inlines()` and the rest. A `ModelAdmin` instance is shared across requests, so mutable state on it is shared state. ## Common per-request rules - **Lock after publish:** in `get_readonly_fields()`, add `slug` when `obj` exists and `obj.is_published`. - **Add versus change:** `obj is None` on the add page, so `get_inlines()` can omit the lesson inline until the course exists, and `get_prepopulated_fields()` can apply only when adding. - **Role-based fields:** in `get_fields()`, drop the internal notes field for users without a permission. - **Different forms:** in `get_form()`, pass `kwargs["form"] = SuperuserCourseForm` for superusers. Whether a user may open or change the object at all is decided by the `has_*_permission()` hooks, a separate topic; the hooks here decide what the form looks like once they are allowed in. ## Inlines per request Inlines follow the same pattern. `get_inlines(request, obj)` returns the inline classes for this request, so the lesson inline can be omitted on the add page (`obj is None`) or for users who only review course metadata. For finer control, `get_formsets_with_inlines(request, obj)` yields `(formset, inline)` pairs and can skip one conditionally. Each inline also has its own `get_readonly_fields()`, `get_fields()` and `get_extra()`, so a published course can show its lessons read-only while drafts stay editable. The same rule applies: build new lists, never mutate the inline's class attributes. ## Why read-only through the hook is enforced Because `get_form()` excludes whatever `get_readonly_fields()` returns, a field made read-only per request is also removed from the form for that request. A crafted POST cannot change it, which is the property that makes this a real control rather than a cosmetic one.
- Why do superusers suddenly see price as read-only after a Django get_readonly_fields() override?The override appended to the list returned by `super()`, which is the class attribute itself. The first non-superuser request added `"price"` permanently for the process, so every later request, superusers included, gets it. Build and return a new list, for example `[*super().get_readonly_fields(request, obj), "price"]`.
- In Django's admin, how do you tell the add page from the change page inside get_readonly_fields()?`obj` is `None` on the add page and the instance being edited on the change page. `get_form()` also receives `change`, `True` for the change page. That is how rules such as "slug editable when adding, locked once published" are written.
saying these in an interview costs you the question
- get_form() returns a form instance bound to the request data
- Appending to super().get_readonly_fields() only affects the current request
- Overriding get_form() without super() keeps read-only and permission handling
- Hiding a field with CSS is equivalent to making it read-only in get_readonly_fields()
- obj is always a model instance in get_fields(), even on the add page