diff --git a/CLAUDE.md b/CLAUDE.md index 3f42e88..8f3c049 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -895,15 +895,14 @@ A follow-up used to belong implicitly to whoever performed the original inspecti **The assignee takes over.** Only the owner is notified, and only the owner sees it — the original inspector's list no longer shows a follow-up that was handed to someone else. In the API that means the two arms must be mutually exclusive: ```python -db.or_( - Inspection.follow_up_assigned_to == user.id, - db.and_(Inspection.follow_up_assigned_to.is_(None), - Inspection.inspector_id == user.id), -) +Inspection.follow_up_owned_by(user.id) # models/inspection.py +# → follow_up_assigned_to == uid OR (follow_up_assigned_to IS NULL AND inspector_id == uid) ``` Without the `is_(None)` on the second arm the original inspector keeps seeing it and two people turn up to do the same re-inspection. +**Ownership is stated twice and both live in the model** — `follow_up_owner` for a loaded row, `follow_up_owned_by(user_id)` for a query. Three surfaces scope follow-ups: the mobile list filter, the web dashboard card, and the iPad stats KPI. They each wrote their own version at first and the two dashboards tested AUTHORSHIP, so an assignee saw the work in their list while both cards read 0 — the stats KPI disagreeing with the Follow-up Requests list directly beneath it. All three now call the predicate. + **The generic "inspectors see only their own inspections" filter has to be deferred** when `follow_up_required=true` is requested — an assigned follow-up lives on an inspection somebody *else* performed, so applying authorship first hides exactly the rows the assignee needs. **Read access follows the LIST, not authorship (Aug 2026).** `index()` scopes an inspector by FACILITY (rule 58 — their scope covers all data in their contracted facilities), but `view()` and `export_pdf()` scoped by authorship, so the list offered rows that answered "Access denied" on click. It also blocked the assignee from opening the parent inspection they had just been asked to re-inspect — the button they needed was on a page they could not reach. Both reads now use `_inspector_may_read()` (facility scope). **Writes stay owner-only**: `execute`, `save_draft_ajax`, `upload_photo_ajax` and `flag_issue` keep the authorship check. diff --git a/app/api/inspections.py b/app/api/inspections.py index 7ea6891..fffc5f3 100644 --- a/app/api/inspections.py +++ b/app/api/inspections.py @@ -318,13 +318,7 @@ def list_inspections(): # arm's `is_(None)` an inspector would keep seeing a follow-up that had # been handed to someone else, and two people would turn up to do it. if user.is_inspector: - query = query.filter(db.or_( - Inspection.follow_up_assigned_to == user.id, - db.and_( - Inspection.follow_up_assigned_to.is_(None), - Inspection.inspector_id == user.id, - ), - )) + query = query.filter(Inspection.follow_up_owned_by(user.id)) from_date_str = request.args.get('from_date') if from_date_str: diff --git a/app/api/stats.py b/app/api/stats.py index fc877a3..408b384 100644 --- a/app/api/stats.py +++ b/app/api/stats.py @@ -152,9 +152,14 @@ def dashboard_stats(): if not fids: followup_q = followup_q.filter(False) else: + # OWNERSHIP, not authorship: a follow-up handed to this inspector + # belongs to them even though somebody else performed the original. + # This tile sits directly above the Follow-up Requests list, which + # filters the same way — counting authorship here made the two + # disagree on the same screen. followup_q = followup_q.filter( Inspection.facility_id.in_(fids), - Inspection.inspector_id == user.id, + Inspection.follow_up_owned_by(user.id), ) pending_followups = followup_q.count() diff --git a/app/models/inspection.py b/app/models/inspection.py index 287c0bf..fadb852 100644 --- a/app/models/inspection.py +++ b/app/models/inspection.py @@ -240,6 +240,32 @@ class Inspection(db.Model): follow-up. """ return self.follow_up_assignee or self.inspector + + @staticmethod + def follow_up_owned_by(user_id): + """SQL predicate: *user_id* owns this inspection's follow-up. + + The query-side mirror of `follow_up_owner` above. Ownership has to be + expressed twice — once for a loaded row, once in SQL — so both live + here, together, and every caller uses one of them. + + The two arms are mutually exclusive on purpose. Drop the `is_(None)` + from the second and an inspector keeps matching a follow-up that was + handed to someone else: two people turn up for the same re-inspection. + + Callers: the mobile list filter, the web dashboard card, and the iPad + stats KPI. They previously each wrote their own version, and three of + them tested AUTHORSHIP — so an assignee saw the work in their list but + a 0 on both dashboards. + """ + return db.or_( + Inspection.follow_up_assigned_to == user_id, + db.and_( + Inspection.follow_up_assigned_to.is_(None), + Inspection.inspector_id == user_id, + ), + ) + follow_ups = db.relationship('Inspection', backref=db.backref('parent', remote_side='Inspection.id'), lazy='dynamic', foreign_keys='Inspection.parent_inspection_id') diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index 9b5cebb..b047e1c 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -173,9 +173,13 @@ def index(): if not inspector_facility_ids: followup_q = followup_q.filter(False) else: + # OWNERSHIP, not authorship — see Inspection.follow_up_owned_by(). + # An assigned follow-up lives on an inspection somebody else + # performed, so testing inspector_id made the card read 0 for the + # very person who had been asked to do the work. followup_q = followup_q.filter( Inspection.facility_id.in_(inspector_facility_ids), - Inspection.inspector_id == current_user.id, + Inspection.follow_up_owned_by(current_user.id), ) elif is_customer: if customer_facility_ids: