Skip to content

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 ​

  1. Permission node — a SCREEN node under the REPORTS parent with route_path (e.g. /reports/retention) and an icon. Two ways:

    • Create it in Admin → Permission Nodes (the validator accepts SCREEN since 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, see database/migrations/114_backfill_permission_nodes.sql for the idiom).
  2. KnownNodes — add the code to internal/shared/authz/nodes.go. internal/router/nodes_test.go fails the build if a route names a node missing from that list.

  3. Handler — in internal/domain/reporting/handler.go, register with reg.Secure("GET "+p+"/<report>/...", "REPORTS.<NAME>", h.YourHandler) and start every handler with the scoping convention:

    go
    vis, 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 use vis.gradeRowPredicate(studentCol, courseCodeExpr), which applies the either/or lens (?unit_view=courses|students, default courses) — a UNIT viewer sees their unit's courses or their unit's students, never both together. Institution-only reports (IPEDS style) wrap their handlers in h.requireUnrestricted(...) instead.

  4. Frontend — a view in src/views/reports/, an API object in reporting.api.ts (include scope?/unit_view? on the response type), and a router entry with meta.permissionNode: 'REPORTS.<NAME>'. If the report shows grade rows, render the unit-view SelectButton when the response says scope === 'UNIT' (copy the pattern in AllGradesReportView.vue) and always show the t('reports.scopedToUnits') tag.

  5. 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.

  6. 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 scopeReport content
ALLEverything (unchanged behavior)
UNITRows attributable to the administered org-unit subtree; grade rows under the chosen lens
ADVISEESMidterm Grades only: the caller's current advisees (StudentAcademicPredicate)
OWN_SECTIONSMidterm Grades only: sections the caller teaches (SectionPredicate)
anything elseEmpty 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.

Student Information System Documentation