Horilla’s CRM dashboard is more flexible than it might seem at first. It isn’t built around a fixed list of models or a separate piece of code for every chart. Instead, the dashboard uses a data-source-driven approach, which means almost any model can be made available for charts, KPIs, or tables.

The interesting part is that you usually don’t need to create a new view, write aggregation logic, or add custom frontend code just to show data from another model. Horilla CRM already has the logic in place. You mainly need to tell the dashboard which models are allowed to be used as data sources.

Let’s look at how this works and how you can add your own model.

How the dashboard is structured

Most of the dashboard functionality revolves around three models in horilla.contrib.dashboard.models.

Dashboard represents the dashboard itself. It belongs to a user and can contain multiple widgets. Dashboards can also be organized into folders, and one dashboard can be marked as the user’s default dashboard.

DashboardComponent represents an individual widget on the dashboard. A component can be a chart, KPI, or table. It also stores the information needed to generate that widget, such as the source model, grouping field, metric, chart type, and table columns.

Then there is ComponentCriteria, which handles filtering. For example, a widget might only display opportunities with a particular status or records created during a specific period.

The important field for our purpose is module inside DashboardComponent.

module = models.ForeignKey(
    HorillaContentType,
    on_delete=models.CASCADE,
    null=True,
    blank=True,
    limit_choices_to=limit_content_types("dashboard_component_models"),
    verbose_name=_("Module"),
)

This field tells Horilla which model the widget should get its data from.

But notice that the choices aren’t hardcoded here. Instead, limit_choices_to uses the dashboard_component_models feature registry.

That’s where the real extension point comes in.

Registering a model as a dashboard data source

Horilla uses a lightweight feature registry for this.

The registry lives in horilla/registry/feature.py, where feature names are mapped to the models that support them. One of those features is:

dashboard_component_models

So, if you want your model to appear in the dashboard’s module dropdown, you need to register it for that feature.

For example:

from horilla.registry.feature import register_model_for_feature


register_model_for_feature(
    app_label="opportunities",
    model_name="OpportunitySplit",
    features=["dashboard_component_models"],
)

That’s enough to tell Horilla that OpportunitySplit can be used as a dashboard data source.

Using all=True

You may also see registrations like this in Horilla:

register_model_for_feature(
    app_label="opportunities",
    model_name="Opportunity",
    all=True,
    features=[
        "approval_models",
        "reviews_models",
        "scoring",
        "workflow_models",
    ],
)

When all=True is used, the model is registered for every available feature.

That’s why a model such as Opportunity can automatically become available to the dashboard without having a separate dashboard_component_models entry.

This is useful when a model is expected to participate in several Horilla features. But if you only want to make a model available to the dashboard, it’s cleaner to register just the feature you need.

register_model_for_feature(
    app_label="opportunities",
    model_name="OpportunitySplit",
    features=["dashboard_component_models"],
)

Making sure the registration actually runs

Adding the registration code isn’t enough by itself. The file containing it needs to be imported when Horilla starts.

Horilla handles this through its AppLauncher system rather than relying only on Django’s normal ready() approach.

For example, an app can have:

class OpportunitiesConfig(AppLauncher):
    auto_import_modules = [
        "registration",
        "signals",
        "menu",
        "dashboard",
    ]

Because registration is included in auto_import_modules, Horilla imports the app’s registration.py during startup.

That means this code:

register_model_for_feature(…)

gets executed automatically when the application starts.

For a normal Horilla app, this is usually all you need to do.

What happens after registration?

Once the model has been registered, Horilla can handle the rest dynamically.

When a user creates a dashboard component, the model appears in the module dropdown. After selecting the model, Horilla can inspect its fields and populate the available fields for grouping, metrics, and table columns.

This is handled through the dashboard’s existing field-choice views and HTMX endpoints.

You don’t have to create something like:

OpportunitySplitDashboardView

or write a separate form specifically for the model.

The dashboard already knows how to inspect the model and work with its fields.

The same idea continues when the widget is actually rendered. Views such as DashboardComponentChartView and DashboardComponentTableDataView build the queryset, apply the filters, perform the required grouping or aggregation, and return the data in a format that the frontend can understand.

That’s what makes the dashboard system reusable.

How Horilla calculates widget data

It’s useful to understand what happens behind the scenes because it explains why the dashboard can work with different models without custom aggregation code.

KPI widgets

For a KPI, Horilla starts by getting a queryset for the selected module.

The queryset is scoped according to the user’s permissions and company settings. Then any criteria attached to the component are applied.

The metric can be something like:

count
sum__amount
average__deal_size
min__amount
max__amount

For example:

sum__amount

means that Horilla should calculate the sum of the amount field.

Internally, the dashboard maps these metric types to Django ORM aggregation functions such as Sum, Avg, Min, and Max.

So you don’t have to write a custom query for every model.

Chart widgets

Charts follow a similar process.

First, Horilla gets the appropriate queryset and applies the component’s filters.

Then it groups the records using the selected grouping_field.

For certain chart types, a secondary_grouping can also be used. This is useful for charts such as stacked charts, heatmaps, sankey diagrams, and similar visualizations.

The Y-axis metric is then calculated using the selected aggregation.

If no specific metric is provided, Horilla can fall back to counting records.

The resulting data is converted into a common structure, for example:

{
“labels”: […],
“data”: […],
“urls”: […]
}

The frontend chart code can then use that data regardless of which model produced it.

That’s an important part of the design: the model provides the data, while the dashboard handles how that data is displayed.

Table widgets

Tables use the same basic filtering process.

The columns configuration determines which fields should be displayed. The configuration can be stored as JSON or CSV depending on how the component is configured.

If no columns are explicitly selected, Horilla can fall back to a set of non-relation fields from the model.

Again, there is no need to create a separate table implementation for every model.

Adding your model to the default dashboard

There is one other situation you may come across.

Registering a model as a dashboard data source means users can choose it when creating their own widgets. But what if you want your widget to appear automatically when a new user gets their default dashboard?

That’s handled separately through DefaultDashboardGenerator.

For example, an app can add an entry to extra_models:

from horilla.contrib.dashboard.default_dashboard import DefaultDashboardGenerator

DefaultDashboardGenerator.extra_models.append(
    {
        "model": Opportunity,
        "name": _("Opportunities"),
        "icon": "fa-handshake",
        "color": "purple",
        "include_kpi": True,
        "chart_func": create_opportunity_charts,
        "table_func": opportunity_table_func,
        "table_fields_func": opportunity_table_fields,
    }
)

This is different from the feature registry.

The registry answers:

“Can this model be used as a dashboard data source?”

DefaultDashboardGenerator.extra_models answers:

“Should this model automatically be included when generating the default dashboard?”

That distinction is worth remembering.

If you use this approach, the module containing the configuration also needs to be imported during startup:

class OpportunitiesConfig(AppLauncher):
    auto_import_modules = [
        "registration",
        "signals",
        "menu",
        "dashboard",
    ]

The dashboard entry makes sure the dashboard configuration is loaded.

When would you actually need to change the chart code?

In most cases, you won’t.

If your requirement is simply:

“I have a new model and I want to show its data as a KPI, chart, or table.”

then registering the model is generally enough.

The situation changes if you want to introduce an entirely new visualization.

For example, imagine Horilla already supports its existing chart types, but you want to add a gauge chart.

That’s no longer a data-source extension. You’re extending the visualization system itself.

You would need to make changes in several places.

First, add the new chart type to DashboardComponent.CHART_TYPES.

Then add the corresponding chart configuration to EChartsConfig.getChartOption() in:

static/assets/js/horilla_charts.js

You would also need to make sure the preview system accepts the new chart type by updating the valid_chart_types list used by ChartPreviewView.

So the distinction is fairly simple:

New model → use the registry.

New visualization → extend the chart implementation.

That separation is one of the reasons the dashboard is relatively easy to extend.

A simple way to think about the whole system

You can think of the dashboard as having three layers.

The first layer is the data source.

That’s your Django model. The feature registry tells Horilla whether that model is allowed to be used by the dashboard.

The second layer is the data processing.

Horilla takes the selected model, applies permissions and filters, groups the records, and performs the requested aggregation.

The third layer is the visualization.

The processed data is passed to a chart, KPI, or table.

Because these responsibilities are separated, adding a new model doesn’t require rebuilding the entire dashboard.

Horilla’s dashboard is designed around reuse rather than one-off implementations. The dashboard doesn’t need to know how every individual model works. It uses Django’s model and field information to build queries and generate widgets dynamically.

For most cases, adding dashboard support to a model is as simple as registering it with:

register_model_for_feature(
app_label=”your_app”,
model_name=”YourModel”,
features=[“dashboard_component_models”],
)

and making sure the registration module is loaded through auto_import_modules.

If you also want the model to appear automatically on a new user’s default dashboard, you can extend DefaultDashboardGenerator.extra_models.

Only when you’re introducing a completely new visualization do you need to work on the chart model, backend preview handling, and JavaScript configuration.

So, instead of creating a separate dashboard implementation every time a new model needs to be displayed, Horilla lets the existing dashboard infrastructure do most of the work. Register the data source, and let the dashboard handle the rest.

Share this article