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:

AttributeDefaultPurpose
template_namechart_view.htmlTemplate used for the chart and its controls
bulk_select_optionFalseBulk selection is not needed for charts
table_classFalseA chart does not render a table
paginate_byNoneThe complete filtered queryset is used for aggregation
default_chart_type“column”Chart type used when none is selected
allowed_chart_typesAll available chart typesControls 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_TYPESTuple of supported typesIdentifies 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:

  1. A valid chart_group_by value from the request, provided the field is visible to the user.
  2. A saved KanbanGroupBy preference for that user and model.
  3. The group_by_field configured on the view.
  4. The first available legacy kanban-style group-by field.
  5. 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.

Share this article