In the Django admin, regional managers must see and edit only their own region's stores; how do you scope it, and where does get_queryset() alone leak?
answer
- filter the admin's base queryset
- object views reuse it
- foreign-key choices come from elsewhere
- filters, raw ids, inlines
basics
~20 sOverride ModelAdmin.get_queryset() to filter by the manager's region; the change list, change and delete views and actions inherit it. It does not scope foreign-key choices on other forms, raw-id inputs, related list filters or inlines, so limit those too.
solid answer
~40 sStart with `get_queryset(request)`: call `super()`, return it whole for superusers and filter by `request.user`'s region otherwise. The change list, actions and "Select all" use it, and `get_object()` uses it too, so opening another region's store by URL redirects to the index with "doesn't exist". What it does not cover: foreign-key fields on other models' forms get their choices from the related model's default manager, so an `EmployeeAdmin` still offers every store unless `formfield_for_foreignkey()` narrows the queryset; `raw_id_fields` accept any typed id for the same reason; `list_filter` on a relation lists every related object unless you use `RelatedOnlyFieldListFilter`; inlines have their own `get_queryset()`; and the add form lets a manager create a store in another region unless you force the region on save. Override `has_change_permission(request, obj)` as a second check.
code
python · 45 linesfrom django.contrib import admin
from .models import Employee, Store
def stores_for(user):
qs = Store.objects.all()
return qs if user.is_superuser else qs.filter(region_id=user.region_id)
@admin.register(Store)
class StoreAdmin(admin.ModelAdmin):
list_display = ["name", "region"]
def get_queryset(self, request):
qs = super().get_queryset(request)
if request.user.is_superuser:
return qs
return qs.filter(region_id=request.user.region_id)
def has_change_permission(self, request, obj=None):
allowed = super().has_change_permission(request, obj)
if obj is None or request.user.is_superuser:
return allowed
return allowed and obj.region_id == request.user.region_id
def save_model(self, request, obj, form, change):
if not request.user.is_superuser:
obj.region_id = request.user.region_id
super().save_model(request, obj, form, change)
@admin.register(Employee)
class EmployeeAdmin(admin.ModelAdmin):
list_filter = [("store", admin.RelatedOnlyFieldListFilter)]
raw_id_fields = ["store"]
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.filter(store__in=stores_for(request.user))
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "store":
kwargs["queryset"] = stores_for(request.user)
return super().formfield_for_foreignkey(db_field, request, **kwargs)go deeper
Recall that get_queryset on a ModelAdmin decides which rows the admin lists and lets users open.
Explain which admin views reuse get_queryset and how formfield_for_foreignkey and RelatedOnlyFieldListFilter narrow related choices.
Enumerate every path that bypasses the base queryset — FK choices, raw ids, filters, inlines, the add form — and test each one, backed by object-level hooks.
Judge when row scoping in the admin is enough and when a tenancy boundary belongs in the data layer, enforced for every caller.
## The scenario A retail chain's `Store` model has a `region` foreign key, and each regional manager is a staff user whose account records their region. Managers should list, open and edit only their own region's stores, and when they edit an `Employee` they should only be able to assign one of those stores. Superusers see everything. ## Layer 1: `get_queryset()` `ModelAdmin.get_queryset(request)` returns the base queryset for the admin — by default the model's default manager, ordered by `get_ordering()`. Override it and every consumer inherits the filter: - the **change list**, including search, filters and "Select all"; - **actions**, which receive a subset of the change list's queryset; - **`get_object()`**, used by the change, delete and history views — an out-of-scope id makes the admin redirect to the index with "Store with ID … doesn't exist. Perhaps it was deleted?"; - **autocomplete** searches that target this model, because the autocomplete view starts from the target `ModelAdmin`'s `get_queryset()`. ## Layer 2: what `get_queryset()` does not reach | Leak | Why | Fix | |---|---|---| | Store dropdown on `EmployeeAdmin`'s form | Choices come from `Store._default_manager`, not `StoreAdmin.get_queryset()` | Override `formfield_for_foreignkey()` and set `kwargs["queryset"]` | | `raw_id_fields` on a store FK | The popup is scoped, but the form field accepts any existing id | Same `formfield_for_foreignkey()` override, which also validates | | Autocomplete on a store FK | The search is scoped, but the submitted value is validated against the field's queryset | Same override | | `list_filter = ["store"]` on employees | `RelatedFieldListFilter` lists every related object | Use `("store", admin.RelatedOnlyFieldListFilter)` | | Inlines under another model | `InlineModelAdmin` has its own `get_queryset()` | Override it on the inline | | Add form | A new row is not in any queryset yet | Force `obj.region` in `save_model()` or limit the region field | The pattern: **`get_queryset()` controls which rows the admin reads for this model; every form field that points at the model builds its own choices.** ## Layer 3: object-level hooks `has_view_permission`, `has_change_permission` and `has_delete_permission` receive the object on detail views. The defaults ignore it; overriding them to compare `obj.region_id` with the user's region is **defence in depth** — if a later change widens `get_queryset()`, the object check still refuses. Remember that these hooks are also called with `obj=None` for list-level decisions, so return the model-level answer then. ## Writing it 1. Put the scope rule in one place — a helper or a queryset method such as `Store.objects.for_user(user)` — and use it in `get_queryset()`, `formfield_for_foreignkey()` and your own views. 2. Branch on `request.user.is_superuser` explicitly instead of relying on an empty filter. 3. Use `request.user`'s stored region, never a value from the request's query string. 4. Test the leaks, not just the list: an out-of-scope id on the change URL, an out-of-scope id POSTed through a raw-id field, and the filter sidebar. ## Common mistakes - Filtering only the change list with a default `list_filter` or a custom `ChangeList`, leaving detail URLs open. - Overriding `get_queryset()` without calling `super()`, which drops the admin's ordering. - Scoping `StoreAdmin` and forgetting that `EmployeeAdmin`, `SaleAdmin` and every inline also point at stores. - Assuming `has_change_permission(request, obj)` hides rows: it does not filter the change list. Designing the underlying role and region schema is a general authorisation subject; the admin part is where each hook applies. ## Testing the scope Scoping bugs are silent, so pin them with tests that play the manager: 1. Log in as a North-region manager and GET the Store change list: only North stores appear. 2. GET the change URL of a South store: the response redirects to the admin index. 3. POST the Employee add form with a South store's id in the `store` field: the form is invalid and nothing is saved. 4. GET the Employee change list and check that the store filter lists only North stores. 5. Log in as a superuser and confirm every store is visible, so the scope did not break the unscoped path. Each test corresponds to one row of the leak table; a new foreign key pointing at `Store` deserves a new test of the same shape. ## When the admin is the wrong place for the rule If the same region rule must hold in views, APIs and exports, put it in a queryset method or manager and call it from the admin hooks. The admin then applies the rule rather than owning it.
- In the Django admin, what happens when a scoped manager opens the change URL of another region's store?`get_object()` looks the id up in `get_queryset(request)`, finds nothing, and the change view redirects to the admin index with a warning that the object doesn't exist and may have been deleted. The response does not reveal whether the row exists elsewhere, which is the behaviour you want.
- Why does a Django admin raw_id_fields input let a scoped manager assign another region's store?The magnifier popup opens the Store change list, which is scoped, but the form field behind the text box is a `ModelChoiceField` built on the related model's default manager. Any existing id typed in passes validation. Narrowing `kwargs["queryset"]` in `formfield_for_foreignkey()` makes the out-of-scope id a validation error.
- Does overriding has_change_permission(request, obj) on a Django ModelAdmin hide rows from the change list?No. The change list is built from `get_queryset()`, and the hook is called there with `obj=None`. The object-level override only refuses editing once a specific object is opened, so it complements row scoping rather than replacing it.
saying these in an interview costs you the question
- Overriding get_queryset also limits foreign-key dropdowns on other admins
- has_change_permission with obj filters rows out of the change list
- A raw-id field only accepts ids shown in its popup
- Out-of-scope change URLs return the object read-only
- Hiding the admin menu entry is enough to scope rows