Skip to content

Item reads are scoped by the manager

ADR-0006 says every Item read carries two filters, circle=… and deleted_at__isnull=True, and that both belong in one place. This decides the place, and makes forgetting it loud.

The decision

Item.objects raises when asked for an unscoped queryset. There are exactly two ways to read Items:

  • Item.objects.for_member(user): the Items of the Circle user is a Member of, Deleted ones hidden. It filters on circle__memberships__user=user, so a person with no Circle gets no Items rather than an exception. The one way past the lid is for_member(user, include_deleted=True), which undo uses. The Circle filter cannot be lifted from here at all.
  • Item.unscoped: a plain manager, named to be seen in review. Reserved for the expiry sweep (which is global by nature), create_item, the admin, and management commands that deliberately act across Circles.

Reads may stay in views. The boundary is the queryset, not the module, so the house rule "never write an Item from a view" does not need a read-side twin that would have to be remembered.

Why a manager that raises

The options were: a scoped default manager; moving every read into services.py; a middleware-set current Circle that managers read; and passing the Circle into every call.

  • Reads only in services is a house rule, and a house rule is exactly what the next view forgets.
  • A current Circle from middleware is implicit global state: invisible at the call site, awkward under ASGI, and absent in a management command, which has no request.
  • Passing the scope explicitly is right, and is what for_member(user) does. But on its own nothing makes a caller do it: get_object_or_404(Item, pk=pk) still compiles.
  • A default manager that returns nothing is safe but quiet. A forgotten scope shows up as an empty wishlist, which reads as "no data" rather than "bug".

Raising turns every forgotten scope into a 500 on the first test that touches it. The idiom this codebase already uses four times, get_object_or_404(Item, pk=…), reads the default manager, so it fails too. That is the point. Writes are unaffected: Model.save() and cascades go through _base_manager.

Consequences

  • A cross-Circle access is a 404, because the Item is not in the scoped queryset. Ids are sequential, and a 403 would confirm that the Item exists. PermissionDenied stays for "yours, but not allowed", and since ADR-0006 nothing is in that category.
  • Services check Membership too. _require_creator becomes _require_member(item, actor), called by every operation on an existing Item: update, schedule, done, delete, undo. Over HTTP it never fires, because the 404 comes first. It is there for callers holding an Item from Item.unscoped. A 403 from it means someone bypassed the scope.
  • create_item stamps actor.membership.circle and raises PermissionDenied when the actor has no Membership. On a write the queryset cannot help, so the service is the last line.
  • The admin sees every Circle. ItemAdmin reads through Item.unscoped and shows and filters by circle. The operator who approves signups is the operator who reads the admin.
  • Tests prove the boundary rather than one view's memory of it, in three layers: the manager raises and for_member scopes; a two-Circle test walks the URL resolver for every items route with a pk and asserts an outsider gets 404 on GET and POST, so a new route is covered the day it is added; and the other Circle's Item never appears in a list partial, a Kind chip count, a month or week grid, or via ?item=<pk>. The raising manager also makes every existing view test fail if its view reads unscoped.