Adding a Report
Every report is gated by its own permission node under REPORTS.*. Once the node exists, everything administrative is UI-driven: granting the report to a role, choosing its row-visibility scope (the scope dropdown in the role-permission editor), menu placement (the sidebar renders SCREEN nodes with a route_path automatically), and revocation. The code below is only the report itself.
Checklist
Permission node — a
SCREENnode under theREPORTSparent withroute_path(e.g./reports/retention) and an icon. Two ways:- Create it in Admin → Permission Nodes (the validator accepts
SCREENsince the scoped-reports change), then capture it in the next migration so rebuilt environments have it, or - Write the migration first (
INSERT ... WHERE NOT EXISTS, seedatabase/migrations/114_backfill_permission_nodes.sqlfor the idiom).
- Create it in Admin → Permission Nodes (the validator accepts
KnownNodes — add the code to
internal/shared/authz/nodes.go.internal/router/nodes_test.gofails the build if a route names a node missing from that list.Handler — in
internal/domain/reporting/handler.go, register withreg.Secure("GET "+p+"/<report>/...", "REPORTS.<NAME>", h.YourHandler)and start every handler with the scoping convention:govis, ok := h.resolveReportVisibility(w, r) if !ok { return } // empty report already written ... if !vis.Unrestricted { sqlFrag, args := vis.studentInUnits("b.student_id") // or courseCodeInUnits / majorInUnits / gradeRowPredicate rq.Where("AND "+sqlFrag, args...) } ... response.OK(w, map[string]interface{}{ "scope": vis.ScopeLabel(), ... })The shared SQL fragments live in
internal/shared/visibility/report_predicates.go; use them (never inline copies) so reporting and any other domain stay in lockstep. Grade-row reports usevis.gradeRowPredicate(studentCol, courseCodeExpr), which applies the either/or lens (?unit_view=courses|students, defaultcourses) — a UNIT viewer sees their unit's courses or their unit's students, never both together. Institution-only reports (IPEDS style) wrap their handlers inh.requireUnrestricted(...)instead.Frontend — a view in
src/views/reports/, an API object inreporting.api.ts(includescope?/unit_view?on the response type), and a router entry withmeta.permissionNode: 'REPORTS.<NAME>'. If the report shows grade rows, render the unit-viewSelectButtonwhen the response saysscope === 'UNIT'(copy the pattern inAllGradesReportView.vue) and always show thet('reports.scopedToUnits')tag.Grants — none in code. Seed a migration grant only when a role must have the report by default (see
118_report_scope_grants.sql); otherwise admins grant it per-role in the UI with whatever scope fits.Drill-downs — if a number in the report opens a list, the list endpoint must filter with the same predicate the summary counted with, over the same period window. Share the fragment; do not write the list filter separately. All Students had a summary that counted "a major my units own, held during the reported year" and a list that asked "a student currently holding a major or minor in my units", so a dean clicked a total of 331 and got a list of 274 — differing in both directions, with 36 of 53 individual cells wrong too. The list endpoint therefore also needs whatever knob the summary pivoted on (All Students passes
row_type), and a live test should assert every cell, row total, column total and grand total equals the length of the list it opens (internal/domain/reporting/unitscope_live_test.go).
Scope semantics recap
| Grant scope | Report content |
|---|---|
ALL | Everything (unchanged behavior) |
UNIT | Rows attributable to the administered org-unit subtree; grade rows under the chosen lens |
ADVISEES | Midterm Grades only: the caller's current advisees (StudentAcademicPredicate) |
OWN_SECTIONS | Midterm Grades only: sections the caller teaches (SectionPredicate) |
| anything else | Empty report (fail closed — advisee/self scopes have no meaning for institution reports) |
ADVISEES and OWN_SECTIONS are opt-in per report, through resolvePersonalReportVisibility instead of resolveReportVisibility. Only the Midterm Grades report (REPORTS.MIDTERM_GRADES, migration 292) opts in, because it is meant for advisors and teachers; every other report still returns an empty result at those scopes. The predicates are the same ones the student record and grade roster use, so the report can never show a student those screens would not.
Rows that cannot be attributed to a unit (a major with no department, a course prefix with no unit_course_codes owner, the "No Declared Major" bucket) are excluded for UNIT viewers — the same fail-closed rule used everywhere else in the visibility layer.