Skip to content

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 DashboardWidget and 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/:

  1. Registry (registry.py) - defines MetricDefinition/MetricScope and the plain Python dict, METRIC_REGISTRY, mapping a metric code to a MetricDefinition: what model/field to query, how to aggregate, which visualizations it supports, and which org verticals/departments it applies to. The actual register() calls live in client_registry.py (MetricScope.CLIENT, organization-scoped) and internal_registry.py (MetricScope.INTERNAL, platform-wide) - registry.py imports both at the bottom of the module so registration happens as a side effect of importing it.
  2. Resolver (resolver.py) - turns a Period (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 any MetricDefinition.
  3. 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.
  4. 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.

  1. Confirm a real model + field exists with the data you need:
  2. Customer-facing metrics need a model with an organization FK (or set organization_field on the MetricDefinition to the correct FK name) and a date field to filter/bucket by.
  3. Internal metrics can use any model; they're queried without an organization filter (via unscoped_objects when the model provides it, otherwise its default manager).
  4. Add one register(MetricDefinition(...)) call in prismio/metrics/client_registry.py (client-scoped) or prismio/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,),
    )
)
  1. Set applicable_verticals/applicable_departments if the metric shouldn't apply to every org (None means "all"). These are checked against Organization.vertical/Organization.department in registry.metrics_for_organization().
  2. Add a test to prismio/metrics/tests/test_registry.py asserting the new code is unique (the registry already raises ValueError on a duplicate register() call) and that every entry in supported_visualizations exists in VISUALIZATION_REGISTRY.
  3. 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

  1. Add a template partial under prismio/metrics/templates/metrics/partials/ that renders a fixed, documented context shape (see the table in visualizations.py's module docstring and the existing partials for examples: stat_card.html, stat_card_with_delta.html, line_graph.html, donut.html).
  2. Register it in VISUALIZATION_REGISTRY (and add a human-readable entry to VISUALIZATION_LABELS for the widget picker's dropdown), both in prismio/metrics/visualizations.py.
  3. Add a build_<name>_context(...) function in visualizations.py if the context needs computation (see _build_line_graph_points for the SVG path math), and call it from services.build_widgets_with_data().
  4. Reference the new visualization code from a metric's supported_visualizations tuple.
  5. 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 from dashboard.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 in METRIC_REGISTRY (clean() validates this)
  • visualization_type - must be in that metric's supported_visualizations (clean() validates this too)
  • position - display order
  • default_period - one of the Period enum 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 to OWNER and ADMIN only in DEFAULT_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; every supported_visualizations entry exists in VISUALIZATION_REGISTRY.
  • prismio/metrics/tests/test_resolver.py - period boundary math; prior-period delta calculation, including the zero-prior-value edge case (delta_percent must be None, 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 - DashboardWidget uniqueness constraint (organization, metric_code); clean() rejects an unknown metric_code or a visualization_type unsupported 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 in prismio/organizations/tests/test_multitenancy_security.py); permission gating (organization.dashboard view vs organization.dashboard.manage edit); internal dashboard/picker reachable only by internal staff.

Run with:

python manage.py test prismio.metrics prismio.organizations prismio.internal

After any migration or permission-codename change, also run:

python manage.py makemigrations --check
python manage.py check
python manage.py sync_role_permissions