Lists and kanban boards are useful when you want to look through individual records. But sometimes, looking at rows of data is not enough. You may simply want an overview.
For example:
- How many leads are currently in each stage?
- What is the total revenue for each campaign type?
- How many opportunities did each salesperson close this quarter?
You could answer these questions by filtering a list and manually counting the records, or by exporting the data to a spreadsheet. But that becomes inconvenient very quickly.
A chart view provides a much better experience. Instead of displaying records one by one, it takes the same filtered queryset used by the list view, groups the records, and presents the result visually.
Users can choose the field they want to group by, select the value they want to measure, switch between different chart types, and even click a chart segment to return to the corresponding filtered list.
Building all of this from scratch would require quite a bit of work. You would need to handle aggregation, chart configuration, HTMX interactions, field permissions, filters, and drill-down URLs.
Fortunately, Horilla already provides HorillaChartView for this purpose.
HorillaChartView is available in:
horilla/contrib/generics/views/chart.py
It extends HorillaListView and uses ECharts through horilla_charts.js. Because it builds on the existing list-view functionality, it can reuse filtering, quick filters, sorting, and permission handling.
That means you can add a useful chart view without duplicating the logic you already have in your list view.
1. Creating a Basic Chart View
If you have already worked with HorillaListView or HorillaSplitView, creating a chart view will feel familiar.
At a minimum, you need:
- A model
- A filterset
- A default group-by field
- URLs connecting the chart to the main list view
Here is the LeadChartView used in Horilla CRM:
class LeadChartView(LoginRequiredMixin, HorillaChartView):
"""Lead chart view: counts by group-by field using same filters as list/kanban."""
model = Lead
view_id = "leads-chart"
filterset_class = LeadFilter
search_url = reverse_lazy("leads:leads_list")
main_url = reverse_lazy("leads:leads_view")
group_by_field = "lead_status"
exclude_kanban_fields = "lead_owner"
That is enough to get a working chart page.
In this example, lead_status is used as the default grouping field. The chart counts the filtered leads for each status and displays the result as a column chart.
The user can then change the grouping field, chart type, or Y-axis metric from the chart controls.
2. Chart-Specific Defaults
One of the nice things about HorillaChartView is that it already comes with sensible defaults.
You do not need to configure every option before the chart can work.
Some of the important defaults are:
| Attribute | Default | Purpose |
| template_name | chart_view.html | Template used for the chart and its controls |
| bulk_select_option | False | Bulk selection is not needed for charts |
| table_class | False | A chart does not render a table |
| paginate_by | None | The complete filtered queryset is used for aggregation |
| default_chart_type | “column” | Chart type used when none is selected |
| allowed_chart_types | All available chart types | Controls which chart types can be selected |
| chart_group_by_param | “chart_group_by” | GET parameter used for the main grouping field |
| chart_stack_by_param | “chart_stack_by” | GET parameter used for the second dimension |
| chart_value_field_param | “chart_y_field” | GET parameter used for the Y-axis metric |
| chart_stack_by_single | “__single__” | Special value used by radar charts |
| STACKED_CHART_TYPES | Tuple of supported types | Identifies charts that use two dimensions |
The base class defines these defaults:
class HorillaChartView(HorillaListView):
template_name = "chart_view.html"
bulk_select_option = False
table_class = False
paginate_by = None
default_chart_type = "column"
allowed_chart_types = CHART_TYPE_VALUES
chart_group_by_param = "chart_group_by"
chart_stack_by_param = "chart_stack_by"
chart_value_field_param = "chart_y_field"
chart_stack_by_single = "__single__"
STACKED_CHART_TYPES = (
"stacked_vertical",
"stacked_horizontal",
"heatmap",
"sankey",
"radar",
)
In most cases, you will only need to configure a few things, such as group_by_field, default_chart_type, and the fields you want to exclude or include as chart dimensions.
3. How the Group-By Field Is Selected
A chart needs a dimension to group the records by. This becomes the X-axis or the primary dimension of the chart.
Unlike a list view, where you define columns, HorillaChartView determines which model fields can be used as chart dimensions.
The effective group-by field is selected using the following priority:
- A valid chart_group_by value from the request, provided the field is visible to the user.
- A saved KanbanGroupBy preference for that user and model.
- The group_by_field configured on the view.
- The first available legacy kanban-style group-by field.
- The first visible chart dimension available on the model.
The relevant method is get_group_by_field().
def get_group_by_field(self):
"""
Effective dimension field:
1) chart_group_by GET if valid and visible
2) else KanbanGroupBy group_by preference if in chart dimensions
3) else first allowed chart dimension
"""
choices = self.get_chart_dimension_choices()
allowed_names = {c[0] for c in choices}
requested = self.request.GET.get(self.chart_group_by_param)
if requested and requested in allowed_names:
if self._is_field_visible_for_group_by(requested):
return requested
This approach gives users flexibility without allowing them to select fields they should not see.
Which Fields Can Be Used as Chart Dimensions?
Not every model field makes sense as a chart dimension.
HorillaChartView considers the following fields:
Always allowed:
- ForeignKey
- DateField
- DateTimeField
This also includes non-editable date fields such as an auto_now_add timestamp.
Allowed when editable:
- BooleanField
- Fields with choices
- CharField fields with choices
- IntegerField fields with choices
Plain text fields, numeric fields without choices, many-to-many fields, and non-concrete fields are not treated as chart dimensions.
You can further control the available dimensions with:
exclude_kanban_fields
or:
include_kanban_fields
The same field controls used by kanban/group-by views can therefore be reused for charts.
Hidden-field permissions are also respected automatically.
Date Fields
Date and datetime fields receive special handling.
Instead of creating a separate chart category for every single day, the chart automatically groups these values by month using TruncMonth.
This keeps the chart readable when the queryset contains a large number of records.
4. Choosing the Y-Axis Value
By default, a chart simply counts records.
For example:
New Leads 42
Contacted 31
Qualified 18
Converted 9
But sometimes counting records is not enough.
You may want to see:
- Total revenue
- Average deal value
- Minimum budget
- Maximum budget
This is where the Y-axis field comes in.
get_chart_numeric_choices() collects editable numeric fields such as:
- IntegerField
- BigIntegerField
- PositiveIntegerField
- SmallIntegerField
- DecimalField
- FloatField
get_chart_y_axis_choices() then creates the available aggregation options.
def get_chart_y_axis_choices(self):
"""
Y-axis options in Record count + "Sum of X", "Average of X",
"Minimum of X", "Maximum of X" for each numeric field.
"""
choices = [("", _("Record count"))]
for field_name, verbose_name in self.get_chart_numeric_choices():
for mkey, mlabel in CHART_METRIC_CHOICES:
value = f"{mkey}__{field_name}"
label = _("%(metric)s of %(field)s") % {
"metric": mlabel,
"field": verbose_name,
}
choices.append((value, label))
return choices
The selected value is passed through the chart_y_field query parameter.
For example:
sum__annual_revenue
or:
avg__budget_cost
If the parameter is empty, the chart uses record count.
A plain field name without the metric prefix is also supported and defaults to sum.
5. Reusing the Existing List View Logic
One of the biggest advantages of HorillaChartView is that you do not need to create a completely separate queryset for the chart.
Because it extends HorillaListView, it already has access to the filtering and permission logic used by the list view.
That includes:
- Filtersets
- Quick filters
- Sorting
- Ownership-based filtering
- Permission checks
So if a user is only allowed to see their own opportunities, the chart will also be based only on those opportunities.
When you need custom queryset logic, the recommended approach is to call super() first.
For example:
def get_queryset(self):
queryset = super().get_queryset()
view_type = (
self.request.GET.get("view_type")
or self.get_default_view_type()
)
if view_type == "converted_lead":
queryset = queryset.filter(is_convert=True)
self.actions = None
else:
queryset = queryset.filter(is_convert=False)
return queryset
This is important because the parent implementation gets a chance to apply the normal filters and permission rules before your custom filtering is added.
The result is a chart that behaves consistently with the list and other views.
6. Available Chart Types
The chart type is controlled through the chart_type query parameter.
If no chart type is provided, the default is:
column
Horilla currently provides the following chart types:
CHART_TYPE_CHOICES = [
("column", _("Column Chart")),
("line", _("Line Chart")),
("pie", _("Pie Chart")),
("funnel", _("Funnel")),
("bar", _("Bar Chart")),
("donut", _("Donut")),
("stacked_vertical", _("Stacked Vertical")),
("stacked_horizontal", _("Stacked Horizontal")),
("scatter", _("Scatter")),
("treemap", _("Treemap")),
("area", _("Area Chart")),
("heatmap", _("Heatmap")),
("sankey", _("Sankey")),
("radar", _("Radar Chart")),
]
That gives you fourteen chart types to work with.
You do not necessarily need to expose every chart type in every module.
For example, if a particular module only makes sense with column, bar, and pie charts, you can restrict the available choices:
allowed_chart_types = (
"column",
"bar",
"pie",
)
If a user requests a chart type that is not included in allowed_chart_types, the view falls back to default_chart_type.
7. Stacked and Two-Dimension Charts
Some charts need more than one dimension.
For example, imagine that you want to see campaign status grouped by campaign type.
The first dimension could be:
Status
and the second could be:
Campaign Type
The following chart types support this kind of two-dimensional data:
- Stacked Vertical
- Stacked Horizontal
- Heatmap
- Sankey
- Radar
The second dimension is called the stack-by field.
It is selected using the chart_stack_by query parameter.
If the user does not select one, get_stack_by_field() chooses another available dimension.
Radar charts are treated slightly differently and remain single-dimensional until the user selects another field.
When a stack-by field is active, build_stacked_payload() converts the queryset into the structure expected by ECharts.
Conceptually, the data is transformed into:
- Categories → Primary dimension values
- Series → Secondary dimension values
- Data → Aggregated values for each combination
Each series contains a name and a data array aligned with the primary categories.
If there are not enough valid combinations to build a stacked chart, the view does not leave the user with a broken visualization. It falls back to the normal single-dimension chart payload.
8. Drilling Down From a Chart
A chart should not be just a visual summary. Users often want to know which records make up a particular number.
That is why Horilla supports chart drill-down.
For example, suppose a chart shows:
Qualified Leads: 25
When the user clicks that section of the chart, Horilla can take them back to the lead list with the appropriate filter already applied.
This is handled by _list_drill_url().
The generated URL uses the same filtering parameters understood by HorillaFilterSet.
The important parameters are:
params = {
"layout": "list",
"apply_filter": "true",
"field": filter_field,
"operator": "exact",
"value": value_str,
}
The chart therefore does not need its own filtering system.
It simply passes the selected chart value back to the existing list filtering mechanism.
Drill-Down for Stacked Charts
Stacked charts contain two dimensions, so both values need to be applied.
For example:
Status = Qualified
Campaign Type = Email
_list_drill_url_two() handles this by applying both filters.
HorillaFilterSet supports multiple field, operator, and value combinations, allowing the filters to be combined using AND logic.
9. Customizing the Aggregation Logic
Most chart views can rely on the default aggregation logic.
However, there may be cases where a particular module has business rules that require special handling.
The lead chart in Horilla CRM is a good example.
The lead chart excludes final lead stages so that the chart follows the same behavior as the lead kanban and group-by views.
This is done by overriding build_chart_payload().
def build_chart_payload(
self,
queryset,
group_by,
value_field=None,
value_metric=None,
):
"""
Omit final lead stages from chart (same as LeadGroupByView grouped_items).
Supports optional numeric Y-axis (sum) while preserving this filtering.
"""
if group_by != "lead_status":
return super().build_chart_payload(
queryset,
group_by,
value_field,
)
The important part here is not simply the filtering itself.
It is the fallback to the parent implementation.
If the selected dimension is lead_status, the custom behavior is applied.
For every other dimension, the method calls:
super().build_chart_payload(…)
This means the custom logic only affects the case that actually needs it, while all other dimensions continue to use the standard chart behavior.
The same approach can be used with build_stacked_payload() when a stacked chart needs special business rules.
10. Adding the Chart View to a Module
Creating the chart view is only part of the job.
You also need to connect it to the module so users can access it alongside the other layouts.
Add the URL
For example:
path(
"leads-chart/",
views.LeadChartView.as_view(),
name="leads_chart",
)
Then add the chart URL to the module’s HorillaView and HorillaNavView.
For example:
class LeadView(LoginRequiredMixin, HorillaView):
nav_url = reverse_lazy("leads:leads_nav")
list_url = reverse_lazy("leads:leads_list")
kanban_url = reverse_lazy("leads:leads_kanban")
group_by_url = reverse_lazy("leads:leads_group_by")
card_url = reverse_lazy("leads:leads_card")
split_view_url = reverse_lazy("leads:leads_split_view")
chart_url = reverse_lazy("leads:leads_chart")
timeline_url = reverse_lazy("leads:leads_timeline")
chart_url is treated as a first-class layout by the base view.
When the request contains:
ayout=chart
get_layout_url() maps it to chart_url.
The layout mapping looks like this:
mapping = {
"kanban": self.kanban_url,
"group_by": self.group_by_url,
"card": self.card_url,
"timeline": self.timeline_url,
"split_view": self.split_view_url,
"chart": self.chart_url,
"list": self.list_url,
}
Once the chart_url is configured on the navigation view, the navbar can automatically display the Chart option in the layout dropdown.
When the user selects Chart, HTMX loads the chart layout into the main content area.
11. Adding the Chart to a Dashboard
Horilla also makes it possible to add a chart directly to a dashboard.
You do not have to create a separate dashboard action for this.
If the current user has either:
dashboard.add_dashboard
or:
dashboard.change_dashboard
permission, the chart can expose an Add to Dashboard action.
Superusers can also use the action.
The chart automatically builds a dashboard URL containing information such as:
- The model/content type
- The selected grouping field
- The chart type
- The stack-by field
The relevant logic checks the user’s permissions and then gets the model’s HorillaContentType.
Conceptually, it creates parameters like:
q = {
"module_id": ct.pk,
"grouping_field": group_by,
"chart_type": chart_type,
}
There is therefore no additional configuration required just to enable the dashboard action.
The main requirements are that the model is registered with HorillaContentType and that the user has the required dashboard permission.
12. A Complete Example
Let’s put everything together with a realistic example.
Suppose we want to create a chart for campaigns.
The view can look like this:
from django.contrib.auth.mixins import LoginRequiredMixin
from horilla.urls import reverse_lazy
from horilla.utils.decorators import (
htmx_required,
method_decorator,
permission_required_or_denied,
)
from horilla.contrib.generics.views import HorillaChartView
from .models import Campaign
from .filters import CampaignFilter
@method_decorator(htmx_required, name="dispatch")
@method_decorator(
permission_required_or_denied(
["campaigns.view_campaign", "campaigns.view_own_campaign"]
),
name="dispatch",
)
class CampaignChartView(LoginRequiredMixin, HorillaChartView):
"""Campaign chart view: counts by group-by field using same filters as list/kanban."""
model = Campaign
view_id = "campaign-chart"
filterset_class = CampaignFilter
search_url = reverse_lazy("campaigns:campaign_list_view")
main_url = reverse_lazy("campaigns:campaign_view")
group_by_field = "status"
exclude_kanban_fields = "company"
And the corresponding URL:
path(
"campaigns-chart/",
views.CampaignChartView.as_view(),
name="campaign_chart",
)
There are a few details worth paying attention to here.
search_url and main_url
These URLs connect the chart with the campaign list.
They are used for things such as:
- Drill-down navigation
- Maintaining the correct browser URL
- Returning to the main campaign page after chart interactions
This keeps the chart integrated with the rest of the module rather than treating it as an isolated page.
group_by_field
group_by_field = “status”
This gives the chart a sensible default grouping field.
Without a useful default, the first chart load may not produce the visualization you expect.
exclude_kanban_fields
exclude_kanban_fields = “company”
This prevents company from appearing in the group-by dropdown.
Not every field is necessarily meaningful as a chart axis, so excluding fields that do not make sense can make the chart controls much easier to use.
HTMX and Permissions
The decorators:
@method_decorator(htmx_required, name="dispatch")
and:
@method_decorator(
permission_required_or_denied(
["campaigns.view_campaign", "campaigns.view_own_campaign"]
),
name="dispatch",
)
follow the same approach used by other Horilla views.
This keeps the chart consistent with the module’s existing permission and HTMX behavior.
13. HTMX Behavior and URL State
The chart interface uses HTMX for its controls.
The group-by, stack-by, Y-axis, and chart-type dropdowns send HTMX requests back to the chart view.
The active filters are included using hx-include, so changing a chart option does not accidentally remove the filters the user has already selected.
For these internal chart updates, the requests use:
hx-push-url=”false”
This prevents the browser from changing its address to the internal chart endpoint every time the user changes a dropdown.
Instead, render_to_response() sets the canonical URL using the HX-Push-Url response header.
The implementation is essentially:
def render_to_response(self, context, **response_kwargs):
"""Push canonical main_url (layout=chart) so the bar stays off the chart endpoint."""
response = super().render_to_response(
context,
**response_kwargs,
)
push_url = context.get("chart_push_url")
if push_url and self.request.headers.get("HX-Request"):
response["HX-Push-Url"] = push_url
return response
This gives the user a much cleaner experience.
For example, changing:
Group By
or:
Chart Type
updates the chart without navigating away from the page.
At the same time, the canonical URL still represents the chart layout and its configuration.
This also means that users can refresh the page or share the URL and return to the same chart configuration.
When Should You Use HorillaChartView?
You do not need a large amount of configuration to get started.
For example, a contact chart can be as simple as:
class ContactChartView(LoginRequiredMixin, HorillaChartView):
model = Contact
filterset_class = ContactFilter
group_by_field = “contact_type”
That small amount of code already gives you a chart that can:
- Use the existing filters
- Respect permissions
- Group records by a field
- Change chart types
- Select numeric metrics
- Drill down into the list view
As your module becomes more complex, you can add more customization.
Control available dimensions
Use:
exclude_kanban_fields
or:
include_kanban_fields
when you want to control which fields appear in the group-by dropdown.
Control chart types
Use:
default_chart_type
to choose the initial visualization.
Use:
allowed_chart_types
when you want to restrict the available chart types.
Add custom queryset filtering
Override:
get_queryset()
when the chart needs additional filtering based on the current view or business logic.
Remember to call:
super().get_queryset()
first so the existing filtering and permission logic remains intact.
Customize aggregation
Override:
build_chart_payload()
when the standard aggregation does not match your module’s requirements.
For two-dimensional charts, you can also customize:
build_stacked_payload()
Expose the chart as a module layout
Add:
chart_url
to the relevant HorillaView or HorillaNavView.
This allows the chart to appear alongside layouts such as List, Kanban, Group By, Card, Timeline, and Split View.
Add charts to dashboards
The dashboard integration is already handled by HorillaChartView.
Once the required permissions are available and the model is registered correctly, the Add to Dashboard action can be used without additional chart-specific configuration.
Adding a chart view to an application can easily become a bigger task than it first appears.
You need to think about filtering, aggregation, permissions, field selection, chart configuration, drill-down behavior, HTMX requests, and dashboard integration.
With HorillaChartView, most of that functionality is already available.
Because the chart view extends HorillaListView, it can reuse the filtering, quick-filter, sorting, ownership, and permission logic that your list view already uses.
On top of that, it provides:
- Multiple chart types
- Group-by and stack-by selection
- Numeric Y-axis metrics
- Automatic date grouping
- Permission-aware dimensions
- Click-to-filter drill-down
- HTMX-based chart controls
- Dashboard integration
In many cases, getting started requires little more than a model, a filterset, and a group_by_field.
For example:
class ContactChartView(LoginRequiredMixin, HorillaChartView):
model = Contact
filterset_class = ContactFilter
group_by_field = “contact_type”
From there, you can customize the view only where your module actually needs it.
That is what makes HorillaChartView useful: instead of building a completely separate reporting system for every module, you can build on the existing Horilla view architecture and turn the data you already have into an interactive chart.