Metrics and Dashboards¶
This document describes the modular metrics system that powers both the
client-facing dashboard (/dashboard/) and the internal staff dashboard
(/internal/), including:
- How the registry/resolver/visualization pieces fit together
- How to add a new metric
- How to add or change a visualization type
- How
DashboardWidgetand the widget picker work - Permission codenames
- Testing checklist
Not covered here: the much larger, unbuilt predictive-modeling platform
proposed in docs/feature_planning/multi_domain_analytics.md (Domain /
Entity / PredictiveModel / etc). That is a separate future initiative for
per-vertical predictive modeling - this system is a simpler, immediate
reporting layer, computed on-the-fly from models that already exist. There is
no Celery/precomputation and no event-log/fact-table in this system: every
metric is a live ORM query against real data.
Overview¶
Four pieces, in prismio/metrics/:
- Registry (
registry.py) - definesMetricDefinition/MetricScopeand the plain Python dict,METRIC_REGISTRY, mapping a metriccodeto aMetricDefinition: what model/field to query, how to aggregate, which visualizations it supports, and which org verticals/departments it applies to. The actualregister()calls live inclient_registry.py(MetricScope.CLIENT, organization-scoped) andinternal_registry.py(MetricScope.INTERNAL, platform-wide) -registry.pyimports both at the bottom of the module so registration happens as a side effect of importing it. - Resolver (
resolver.py) - turns aPeriod(last 7/30/90 days, this quarter, this year, custom) into a concrete date range, and computes either a single aggregate (with a prior-period delta, for MoM/QoQ/YoY-style comparisons) or a day-bucketed series, for anyMetricDefinition. - Visualizations (
visualizations.py) - a small closed set of hand-rolled SVG/CSS template partials (no JS charting library), each taking a fixed, flat context shape built from a resolver result. DashboardWidget(models.py) - one row per metric an organization has chosen to display, and how (visualization type + default time period). One shared widget set per organization (not per-user).
Request flow for /dashboard/:
OrganizationDetailView.get()
-> services.get_or_seed_widgets(organization, DEFAULT_METRIC_CODES)
(loads the org's DashboardWidget rows, seeding a default set on first visit)
-> services.build_widgets_with_data(widgets, organization, period_override)
for each widget:
-> registry.get_metric(widget.metric_code)
-> resolver.compute_aggregate() or resolver.compute_series()
-> visualizations.build_*_context()
-> dashboard.html renders each widget via {% include widget.viz_template %}
The internal dashboard (prismio/internal/views.py::InternalView) follows the
exact same flow, just with organization=None (querying platform-wide via
each metric's model directly) and internal_metrics() instead of
metrics_for_organization(organization).
Adding a new metric¶
This is the primary "modular, central location" recipe the system is built around. Adding a metric never requires touching the resolver, views, or templates.
- Confirm a real model + field exists with the data you need:
- Customer-facing metrics need a model with an
organizationFK (or setorganization_fieldon theMetricDefinitionto the correct FK name) and a date field to filter/bucket by. - Internal metrics can use any model; they're queried without an
organization filter (via
unscoped_objectswhen the model provides it, otherwise its default manager). - Add one
register(MetricDefinition(...))call inprismio/metrics/client_registry.py(client-scoped) orprismio/metrics/internal_registry.py(internal/platform-wide), inside_register_client_metrics()/_register_internal_metrics()(or a new registration function called at import time):
register(
MetricDefinition(
code="donations_recorded", # stable id, used in DashboardWidget.metric_code
label="Donations Recorded",
description="Count of donation records imported for this organization.",
scope=MetricScope.CLIENT,
model=Donation, # the model to query
date_field="recorded_at", # DateTimeField/DateField to filter/bucket by
aggregation=AggregationType.SUM, # or COUNT
aggregation_field="amount", # required if aggregation is SUM
supported_visualizations=("stat_card", "stat_card_delta", "line_graph"),
applicable_verticals=(OrganizationVertical.NONPROFIT,),
applicable_departments=(OrganizationDepartment.ADVANCEMENT,),
)
)
- Set
applicable_verticals/applicable_departmentsif the metric shouldn't apply to every org (Nonemeans "all"). These are checked againstOrganization.vertical/Organization.departmentinregistry.metrics_for_organization(). - Add a test to
prismio/metrics/tests/test_registry.pyasserting the new code is unique (the registry already raisesValueErroron a duplicateregister()call) and that every entry insupported_visualizationsexists inVISUALIZATION_REGISTRY. - That's it. The metric is now selectable in the widget picker (subject to vertical/department applicability) and computable for any time period - no resolver, view, or template changes are needed.
Metrics with no backing model yet (donations in the example above is
illustrative - as of this writing there's no Donation model; also
workflows, integration-sync-logs, records-unified, support tickets, logins,
subscription renewals) are intentionally not registered. Register them
once their source model exists.
Adding or changing a visualization type¶
- Add a template partial under
prismio/metrics/templates/metrics/partials/that renders a fixed, documented context shape (see the table invisualizations.py's module docstring and the existing partials for examples:stat_card.html,stat_card_with_delta.html,line_graph.html,donut.html). - Register it in
VISUALIZATION_REGISTRY(and add a human-readable entry toVISUALIZATION_LABELSfor the widget picker's dropdown), both inprismio/metrics/visualizations.py. - Add a
build_<name>_context(...)function invisualizations.pyif the context needs computation (see_build_line_graph_pointsfor the SVG path math), and call it fromservices.build_widgets_with_data(). - Reference the new visualization code from a metric's
supported_visualizationstuple. - Keep partials hand-rolled SVG/CSS - no JS charting library. Reuse existing
CSS custom properties from
prismio/static/prismio/css/tokens.css(--chart-line,--chart-fill-strong,--chart-fill-soft) and classes fromdashboard.css(.metric-card,.donut,.legend-list, etc.) rather than inventing new ones.
donut is registered but has no metric using it yet (no phase-1 metric has
categorical segments) - it's there as a template for a future segmented
metric, not because anything renders it today.
DashboardWidget and the widget picker¶
DashboardWidget (prismio/metrics/models.py) is an OrganizationOwnedModel
with:
metric_code- must match a key inMETRIC_REGISTRY(clean()validates this)visualization_type- must be in that metric'ssupported_visualizations(clean()validates this too)position- display orderdefault_period- one of thePeriodenum values (resolver.py)config- unused JSON escape hatch for future per-widget overrides
One shared widget set per organization - there is no per-user layout. The
picker view (DashboardWidgetPickerView for clients,
InternalDashboardWidgetPickerView for internal staff, both reusing
templates/metrics/widget_picker.html) does a replace-all on save: POST
deletes the organization's existing DashboardWidget rows and recreates them
from the submitted selections, in one transaction. This keeps the model
simple (no partial-update reconciliation logic) since a full replace matches
how infrequently this form is expected to be submitted.
Internal widgets reuse the same DashboardWidget table, scoped to the
singleton internal Organization (the one with
type=OrganizationType.INTERNAL) rather than a second model - internal staff
requests already have request.organization set to that org by
OrganizationMiddleware/InternalAccessMiddleware, so this falls out
naturally rather than needing special-casing.
Widget queries use DashboardWidget.unscoped_objects.filter(organization=...)
rather than the ContextVar-scoped default manager, per the "defense in depth"
guidance in docs/multitenancy.md - the organization is always passed
explicitly rather than relied upon implicitly.
Permission codenames¶
organization.dashboard- view the dashboard (pre-existing).organization.dashboard.manage- add/remove/reconfigure widgets. Added toOWNERandADMINonly inDEFAULT_ROLE_PERMISSIONS(prismio/organizations/roles.py).
After adding or changing a permission codename, run
python manage.py sync_role_permissions (or rely on the post_migrate signal
in prismio/organizations/apps.py) to propagate it into RolePermission rows
per the rules in docs/permissions.md.
Testing checklist¶
prismio/metrics/tests/test_registry.py- no duplicate metric codes; everysupported_visualizationsentry exists inVISUALIZATION_REGISTRY.prismio/metrics/tests/test_resolver.py- period boundary math; prior-period delta calculation, including the zero-prior-value edge case (delta_percentmust beNone, not a division error); series bucket gap-filling (days with no activity still appear as zero-value points).prismio/metrics/tests/test_widget_model.py-DashboardWidgetuniqueness constraint (organization,metric_code);clean()rejects an unknownmetric_codeor avisualization_typeunsupported by the metric.prismio/metrics/tests/test_dashboard_views.py- cross-organization isolation (an org cannot see another org's widgets or data, matching the pattern inprismio/organizations/tests/test_multitenancy_security.py); permission gating (organization.dashboardview vsorganization.dashboard.manageedit); internal dashboard/picker reachable only by internal staff.
Run with:
After any migration or permission-codename change, also run: