Dynamic Dashboards
Dynamic dashboards allow you to build entire frontend dashboards from the backend
INFO
Before you start here, make sure that your API endpoints are configured to use drf. See: # Configuring your API endpoint for Actions and Dynamic Dashboards
Creating a dashboard
Dashboards are created off of a resource (a resource is a model that has a corresponding ModelViewSet in the API)
For example, if we want to create a dashboard called index for the Invoice model, we would need to create the following directory structure:
# mkdir -p {appname}/{resourcename}/dashboards/{actionid}/
mkdir -p project/invoice/dashboards/index/In the folder, create two files:
form.py- Contains the dashboard form definitionhelpers.py- Contains helper functions for building charts (optional but recommended for complex dashboards)
Form Structure
- The name of your form must be:
{ACTION_ID}Form, e.g.:IndexForm,ForecastForm - You must include a Meta class with configuration details
- Your response should include:
- a
metasection (which provides the frontend with useful information like queryset size, date range, etc.) - a
resultssection which should bedashbuilder.dashboard
- a
See example form.py
from django import forms
from rest_framework import serializers
from drf.actionhelpers import get_action_permission_string
from drf.actionmixins import ACTION_TYPE, ACTION_READINESS, ActionFilterMixin
from drf.charts import DashBuilder
from drf.helpers import get_start_and_end_dates, get_end_of_month
from drf.forms import HeirarchicalModelMultipleChoiceFilter
from querytools.tools import group_by_and_annotate, group_by_and_aggregate
from datetime import date
import django_filters
ACTION_ID = "index"
description = "Dashboard providing an overview of invoices"
# Define your FilterSet for the dashboard
class InvoiceDashboardFilterSet(ActionFilterMixin, django_filters.FilterSet):
after = django_filters.DateFilter(field_name="date", lookup_expr="gte")
before = django_filters.DateFilter(field_name="date", lookup_expr="lte")
departments = HeirarchicalModelMultipleChoiceFilter(
queryset=Department.objects.is_active(),
field_name="department_id",
widget=django_filters.widgets.CSVWidget(),
)
customer = django_filters.ModelChoiceFilter(
field_name="customer",
queryset=Customer.objects.all(),
)
status = django_filters.ChoiceFilter(choices=Invoice.STATUS_CHOICES)
# Define a serializer for table display
class InvoiceBasicSerializer(serializers.ModelSerializer):
class Meta:
model = Invoice
fields = ["id", "invoice_number", "customer_name", "sub_total", "date", "status"]
class IndexForm(forms.Form):
# Define initial start and end dates
start_date, end_date = get_start_and_end_dates(months_ago=2, months_ahead=0)
after = forms.DateField(required=False, initial=str(start_date))
before = forms.DateField(required=False, initial=str(end_date))
page = forms.IntegerField(required=False)
departments = forms.ModelMultipleChoiceField(
queryset=Department.objects.is_active(),
widget=django_filters.widgets.CSVWidget,
required=False,
)
class Meta:
title = "Invoice Dashboard"
description = description
action_type = ACTION_TYPE.BULK
release_status = ACTION_READINESS.PRODUCTION.name
auto_dashboard = True
filterset_class = InvoiceDashboardFilterSet
required_permissions = [get_action_permission_string(Invoice, ACTION_ID)]
def clean_before(self):
today = date.today()
before = self.cleaned_data.get("before")
if not before:
return get_end_of_month(today.year, today.month)
return before
def clean_after(self):
today = date.today()
after = self.cleaned_data.get("after")
if not after:
return date(today.year, today.month, 1)
return after
def save(self, qs=None):
dashbuilder = DashBuilder()
page = self.cleaned_data.get("page")
start_date = self.cleaned_data.get("after")
end_date = self.cleaned_data.get("before")
departments = self.cleaned_data.get("departments")
if qs is None:
qs = self.view.get_filtered_queryset(InvoiceDashboardFilterSet)
# Apply additional filters as needed
if start_date and end_date and departments:
qs = qs.in_departments(departments)
# Build your charts using dashbuilder
# ... add charts here ...
return {
"meta": {
"title": self.Meta.title,
"description": self.Meta.description,
"search": False,
"page": page,
"request_page": self.request.GET.get("page"),
"start_date": start_date,
"end_date": end_date,
"qs": qs.count(),
"params": {
"startDateField": "after",
"endDateField": "before",
},
},
"results": dashbuilder.dashboard,
}Helper Functions Pattern
For complex dashboards, separate chart-building logic into a helpers.py file:
See example helpers.py
from datetime import date
from querytools.tools import group_by_and_aggregate, group_by_and_annotate
from drf.helpers import get_months_between, normalize_date
def add_metrics(builder, qs, cols: int = 3):
"""Add metric cards to the dashboard"""
builder.add(
"invoice_sum",
"metric",
group_by_and_aggregate(qs, "sub_total", "Sum"),
title="Total Invoice Value",
cols=4,
chart_options={"formatter": "currency"},
)
builder.add(
"invoice_count",
"metric",
group_by_and_aggregate(qs, "id", "Count"),
title="Number of Invoices",
cols=4,
)
def add_monthly_breakdown(builder, qs, start_date: date, end_date: date):
"""Add monthly breakdown charts"""
series_configs = [
{
"title": "Invoice Total",
"aggregation_field": "sub_total",
"aggregation": "Sum",
"date_field": "date",
"color": "#009688",
},
]
context = {
"series_configs": series_configs,
"start_date": start_date,
"end_date": end_date,
}
builder.add(
"invoice_value_by_month",
"monthly_columns",
qs,
title="Invoice Value by Month",
context=context,
cols=12,
ui_options={"format": "currency", "large_number": True},
)
def add_customer_treemap(builder, qs):
"""Add a treemap showing invoices by customer"""
qs = qs.filter(sub_total__isnull=False)
customer_data = group_by_and_annotate(
qs,
"customer__name",
aggregation="Sum",
aggregation_field="sub_total",
)
customer_series = [
{"data": [{"x": customer, "y": count} for customer, count in customer_data.items()]}
]
data = {
"series": customer_series,
"options": {
"chart": {"height": "600px", "type": "treemap"},
"plotOptions": {
"treemap": {"distributed": True, "enableShades": False},
},
},
}
builder.add(
"invoice_by_customer",
"custom_chart",
data,
title="Invoice Total by Customer",
cols=12,
ui_options={"format": "currency", "large_number": True},
)Then in your form.py, import and use the helpers:
from .helpers import add_metrics, add_monthly_breakdown, add_customer_treemap
def save(self, qs=None):
dashbuilder = DashBuilder()
# ... setup code ...
add_metrics(dashbuilder, qs)
add_monthly_breakdown(dashbuilder, qs, start_date, end_date)
add_customer_treemap(dashbuilder, qs)
return {
"meta": {...},
"results": dashbuilder.dashboard,
}Adding charts to your dashboard
The add() method
All charts are added using dashbuilder.add():
dashbuilder.add(
id="unique_chart_id", # Unique identifier
type="metric", # Chart type
data=value_or_queryset, # Data to display
title="Chart Title", # Display title
cols=4, # Grid columns (1-12)
context={}, # Type-specific options
chart_options={}, # ApexChart options override
ui_options={}, # Frontend UI options
info="Optional tooltip", # Info tooltip text
)UI Options
The ui_options parameter controls frontend formatting:
ui_options={
"format": "currency", # Format values as currency
# or
"format": "percent", # Format values as percentages
# or
"format": "number", # Format as number with separators
"large_number": True, # Use compact notation for large numbers (1.2M)
}Metric
A metric block that highlights a single number
dashbuilder.add(
"invoice_count",
"metric",
group_by_and_aggregate(qs, "id", "Count"),
title="Total Invoices",
cols=4,
)
# With currency formatting
dashbuilder.add(
"invoice_sum",
"metric",
group_by_and_aggregate(qs, "sub_total", "Sum"),
title="Total Invoice Value",
cols=4,
chart_options={"formatter": "currency"},
)
# With percentage formatting
dashbuilder.add(
"achievement_rate",
"metric",
85.5,
title="Achievement Rate",
cols=4,
chart_options={"formatter": "percent"},
)Header
A header displays a Vuetify card for section headings
data = {
"title": "Section Title",
"subtitle": "Optional subtitle text",
"text": "Optional descriptive text",
}
dashbuilder.add("section_header", "header", data=data)Notice
A notice displays a Vuetify alert for important messages
data = {
"title": "Notice Title",
"text": "Notice content goes here",
"type": "info", # info, success, warning, error
"variant": "text",
"border": "start",
"tile": True,
}
dashbuilder.add("notice_id", "notice", data=data)INFO
The values of data are props passed to Vuetify's v-alert. See: https://vuetifyjs.com/en/api/v-alert/#props
Vuetify Component
Render any Vuetify component with custom props
card_data = {
"title": "Custom Card Title",
"subtitle": "Card subtitle",
"variant": "elevated",
"color": "blue-lighten-5",
"class": "elevation-4 rounded-4 py-5",
"style": "border-left: 6px solid #2096F3;",
}
dashbuilder.add("custom_card", "vuetify_component", "v-card", context=card_data)Breakdown Chart
A breakdown chart shows a metric sliced by a single dimension (e.g., Invoices by status)
Features:
- Automatically selects the best chart type based on data size:
- Donut: Less than 10 categories
- Horizontal bar: Less than 20 categories
- Treemap: 20+ categories
- Works seamlessly with
querytools.tools.group_by_and_annotate
from querytools.tools import group_by_and_annotate
# Simple breakdown
dashbuilder.add(
"invoices_by_status",
"breakdown",
group_by_and_annotate(qs, "status"),
title="Invoices by Status",
cols=4,
context={"series_name": "Invoices"},
)
# Breakdown with aggregation
dashbuilder.add(
"revenue_by_customer",
"breakdown",
group_by_and_annotate(qs, "customer__name", aggregation="Sum", aggregation_field="sub_total"),
title="Revenue by Customer",
cols=6,
ui_options={"format": "currency"},
)2D Breakdown
A 2D breakdown shows a metric broken down by a dimension and spread across a second dimension (typically time). e.g., Invoice value by status over time
Display types:
vertical_stacked- Stacked vertical bar charthorizontal_stacked- Stacked horizontal bar chartslope- Line chart showing trends
Options:
stacked_100- Show as 100% stacked chart (default: True)include_sum_annotations- Show sum annotations on barscolors- Custom color array
from drf.helpers import months_between
month_range = months_between(start_date, end_date)
dimensions = [
{"date__year": year, "date__month": month}
for year, month in month_range
]
context = {
"group_by_field": "status",
"aggregation": "Sum",
"aggregation_field": "sub_total",
"dimensions": dimensions,
"display_type": "vertical_stacked", # or "horizontal_stacked" or "slope"
"title": f"Invoice value by status by month",
"labels": [f"{year}-{month}" for year, month in month_range],
"stacked_100": False, # Show actual values, not percentages
"include_sum_annotations": True, # Show totals on top of bars
"colors": ["#028FFB", "#07E396", "#FEB01A"], # Custom colors
}
dashbuilder.add(
"status_over_time",
"twod_breakdown",
qs,
title="Invoice Value by Status Over Time",
cols=12,
context=context,
chart_options={"chart": {"height": "600px"}, "legend": {"show": True, "position": "bottom"}},
ui_options={"format": "currency"},
)Monthly Columns
Display monthly aggregated data with support for multiple series
series_configs = [
{
"title": "Fee Estimation",
"qs": milestones_qs, # Optional: use different queryset
"aggregation_field": "fee_estimation",
"aggregation": "Sum",
"date_field": "end_date",
"color": "#03A9F4",
},
{
"title": "Invoice Total",
"aggregation_field": "sub_total",
"aggregation": "Sum",
"date_field": "date",
"color": "#009688",
},
]
context = {
"series_configs": series_configs,
"start_date": start_date,
"end_date": end_date,
}
dashbuilder.add(
"monthly_comparison",
"monthly_columns",
qs,
title="Monthly Fee vs Invoice Comparison",
context=context,
cols=12,
ui_options={"format": "currency", "large_number": True},
)Column Chart
Standard column/bar chart
# From a dictionary
data = {
"January": 100,
"February": 150,
"March": 200,
}
dashbuilder.add(
"monthly_data",
"column",
data,
title="Monthly Data",
cols=12,
context={
"series_name": "Revenue",
"labels": ["January", "February", "March"],
"colors": ["#009688"],
},
ui_options={"format": "currency"},
)Date Series
Render a line chart with results over time
- Works with
querytools.tools.as_timeseries - Supports multiple series
from querytools.tools import as_timeseries
qs_over_time = as_timeseries(
qs,
from_date=start_date.isoformat(),
to_date=end_date.isoformat(),
search_field="date",
aggregation="Count",
aggregate_field="id",
)
series = [
{"name": "Invoices", "data": qs_over_time},
# Add more series as needed
]
dashbuilder.add(
"invoices_over_time",
"date_series",
series,
title="Invoices Over Time",
cols=12,
)Heatmap (Calendar)
Calendar heatmap showing daily activity
# Single month heatmap
context = {
"dt": date(2024, 1, 1),
"date_field": "date",
"aggregate_field": "sub_total", # Optional: defaults to count
"aggregate_func": "Sum", # Optional: defaults to "Count"
}
dashbuilder.add(
"january_heatmap",
"heatmap",
qs,
title="January Activity",
context=context,
cols=4,
)
# Multiple months (auto-generates one chart per month)
heatmap_context = {
"start_date": start_date,
"end_date": end_date,
"date_field": "date",
}
dashbuilder.add("activity_heatmaps", "heatmaps", qs, context=heatmap_context)List
Display a list of items with metrics
items = [
{
"title": "Invoice #001 - Customer A",
"subtitle": "Date: 2024-01-15",
"to": "/invoices/1",
"description": "Optional notes",
"metrics": [
{"title": "Invoice Total", "value": 1500, "formatter": "currency"},
{"title": "Fee Estimation", "value": 1200, "formatter": "currency"},
{"title": "Difference", "value": 300, "formatter": "currency"},
],
},
# ... more items
]
context = {
"height": 350,
"formatter": "currency",
"count": len(items),
}
dashbuilder.add(
"items_list",
"list",
items,
context=context,
title="Items with Issues",
cols=12,
)Custom Chart
Create any ApexChart with full control over series and options
# Treemap example
series = [
{"data": [{"x": "Category A", "y": 100}, {"x": "Category B", "y": 200}]}
]
data = {
"series": series,
"options": {
"chart": {"height": "600px", "type": "treemap"},
"plotOptions": {
"treemap": {"distributed": True, "enableShades": False},
},
},
}
dashbuilder.add(
"custom_treemap",
"custom_chart",
data,
title="Custom Treemap",
cols=12,
ui_options={"format": "currency"},
)Full example: Dumbbell chart
product_types = qs.values_list("product_type", flat=True).distinct()
series_data = []
for product_type in product_types:
product_qs = qs.filter(product_type=product_type)
male_value = product_qs.filter(gender="Male").aggregate(Sum("amount"))["amount__sum"]
female_value = product_qs.filter(gender="Female").aggregate(Sum("amount"))["amount__sum"]
series_data.append({
"x": product_type,
"y": [male_value, female_value],
})
series = [{"data": series_data}]
options = {
"chart": {"height": 390, "type": "rangeBar"},
"colors": ["#EC7D31", "#36BDCB"],
"plotOptions": {
"bar": {
"horizontal": True,
"isDumbbell": True,
"dumbbellColors": [["#EC7D31", "#36BDCB"]],
}
},
"legend": {
"show": True,
"showForSingleSeries": True,
"customLegendItems": ["Male", "Female"],
},
}
dashbuilder.add(
"gender_comparison",
"custom_chart",
{"series": series, "options": options},
title="Value by Gender",
cols=12,
)Gantt
Render an ApexGantt timeline using pre-shaped task data.
Use gantt for timelines like projects, assignments, milestones, renewals, or any other resource with a start and end date.
Important:
DashBuilder.add_gantt()owns the shared ApexGantt defaults indrf/charts.py- pass pre-shaped ApexGantt task records into
dashbuilder.add(..., "gantt", tasks, ...) - do not pass a raw queryset unless you also transform it into ApexGantt task fields
- use
chart_optionsonly for chart-specific overrides such asresourceLabel, click metadata, or extra ApexGantt options
Task Data Shape
[
{
"id": "123",
"name": "UiPath Developer License Renewal",
"startTime": "2025-12-16",
"endTime": "2026-12-15",
"progress": 0,
"parentId": None,
"barBackgroundColor": "#FFA726",
}
]Typical serializer pattern:
class ProjectGanttSerializer(serializers.ModelSerializer):
id = serializers.SerializerMethodField()
startTime = serializers.DateField(source="start_date")
endTime = serializers.DateField(source="end_date", allow_null=True, required=False)
progress = serializers.SerializerMethodField()
parentId = serializers.SerializerMethodField()
barBackgroundColor = serializers.SerializerMethodField()
class Meta:
model = Project
fields = ["id", "name", "startTime", "endTime", "progress", "parentId", "barBackgroundColor"]
def get_id(self, obj):
return str(obj.id)
def get_progress(self, obj):
return 0Then:
tasks = ProjectGanttSerializer(
qs.order_by("end_date", "start_date", "id"),
many=True,
context={"request": self.request},
).data
dashbuilder.add(
"project_renewals_gantt",
"gantt",
tasks,
title="Renewals Timeline",
cols=12,
chart_options={
"resourceLabel": "Project",
"click": {
"action": "detail",
"resource": "project",
"idField": "id",
},
},
)Shared Defaults
add_gantt() in drf/charts.py provides shared ApexGantt defaults such as:
viewMode: "month"inputDateFormat: "YYYY-MM-DD"- disabled drag / resize / inline edit
- enabled selection / export / tooltip
- alternating row colors and sizing
- a vertical
Todayannotation
Keep those shared defaults in drf/charts.py rather than repeating them in every dashboard.
Renaming Task
Use resourceLabel to rename the built-in tooltip label from Task to the resource name:
chart_options={
"resourceLabel": "Assignment",
}Generic Click Handling
Use shared click metadata so the frontend can open detail views generically:
chart_options={
"click": {
"action": "detail",
"resource": "assignment",
"idField": "id",
},
}When To Use gantt
- Use
ganttwhen you want the shared ApexGantt pipeline and reusable defaults - Use
custom_chartfor normal ApexCharts charts like line, bar, donut, treemap, funnel, and other standard ApexCharts visualizations
Table
Paginated table with customizable columns
from rest_framework import serializers
class ItemSerializer(serializers.ModelSerializer):
class Meta:
model = Item
fields = ["id", "name", "customer_name", "amount", "date", "status"]
table_context = {
"request": self.request,
"serializer": ItemSerializer,
"field_options": {
"id": {"hidden": True},
"name": {
"component": "data-btn",
"props": {
"toPrefix": "/items/",
"idField": "id",
"maxLength": 30,
"quickView": True,
"resource": "item",
"resourceId": "id",
},
},
"amount": {"formatter": "currency"},
"date": {"formatter": "date"},
"status": {"formatter": "boolean"},
},
}
dashbuilder.add(
"items_table",
"table",
qs,
context=table_context,
title="Items",
)Table Field Options
| Option | Description |
|---|---|
hidden | Hide the column |
formatter | Use a predefined formatter: currency, date, boolean, number, percent |
component | Use a custom component for rendering |
props | Props to pass to the custom component |
Custom Components for Tables
data-btn - Renders a link button:
"name": {
"component": "data-btn",
"props": {
"toPrefix": "/items/", # URL prefix
"idField": "id", # Field to use for ID in URL
"maxLength": 30, # Truncate text
"quickView": True, # Enable quick view popup
"resource": "item", # Resource type for quick view
"resourceId": "id", # ID field for quick view
},
}data-btn-list - Renders multiple link buttons from a list field:
"related_items": {
"component": "data-btn-list",
"props": {
"toPrefix": "/related/",
"idField": "related__id",
"labelField": "related__name",
"emptyText": "-",
"quickView": True,
},
}Utility Methods
Combined Breakdown + 2D Crosssection
Add both a breakdown chart and a time-based crosssection in one call
dashbuilder.add_overview_and_crosssection(
qs,
field="status", # Field to break down by
start_date=start_date,
end_date=end_date,
field_title="Status", # Human-readable field name
aggregation="Sum", # Count or Sum
aggregation_field="sub_total", # Field to aggregate
start_date_field="date", # Date field for time dimension
chart_type="vertical_stacked", # slope, vertical_stacked, horizontal_stacked
breakdown_cols=4, # Cols for breakdown chart
two_d_cols=8, # Cols for 2D chart
ui_options={"format": "currency"},
)Top and Bottom Performers
Add two charts showing top and bottom performers
dashbuilder.top_and_bottom_summary(
qs,
field="customer__name",
title="Customers",
num_items=10,
aggregation="Sum",
aggregation_field="sub_total",
ui_options={"format": "currency", "large_number": True},
context={"chart_type": "column"},
)Filters
Filters are automatically generated from the
filterset_classdefined in Meta
FilterSet Definition
from drf.actionmixins import ActionFilterMixin
from drf.forms import HeirarchicalModelMultipleChoiceFilter
import django_filters
class MyDashboardFilterSet(ActionFilterMixin, django_filters.FilterSet):
# Date range filters
after = django_filters.DateFilter(field_name="date", lookup_expr="gte")
before = django_filters.DateFilter(field_name="date", lookup_expr="lte")
# Hierarchical filter (for nested relationships)
departments = HeirarchicalModelMultipleChoiceFilter(
queryset=Department.objects.is_active(),
field_name="department_id",
widget=django_filters.widgets.CSVWidget(),
)
# Model choice filter
customer = django_filters.ModelChoiceFilter(
field_name="customer",
queryset=Customer.objects.all(),
)
# Choice filter
status = django_filters.ChoiceFilter(choices=MyModel.STATUS_CHOICES)
# Boolean filter
is_active = django_filters.BooleanFilter(field_name="is_active", lookup_expr="exact")Supported Filter Types
| Filter Type | Frontend Display |
|---|---|
ChoiceFilter | v-select with choices |
BooleanFilter | Filterable chips (yes/no) |
ModelChoiceFilter | v-select with queryset lookup |
ModelMultipleChoiceFilter | v-select with multiple selection |
HeirarchicalModelMultipleChoiceFilter | Hierarchical multi-select |
Meta Response Structure
The save() method must return a dictionary with meta and results:
return {
"meta": {
"title": "Dashboard Title",
"description": "Dashboard description",
"search": False, # Enable/disable search
"page": page,
"request_page": self.request.GET.get("page"),
"start_date": start_date,
"end_date": end_date,
"qs": qs.count(), # Total items in queryset
"params": {
"startDateField": "after", # URL param for start date
"endDateField": "before", # URL param for end date
},
},
"results": dashbuilder.dashboard,
}Helper Utilities
Date Utilities
from drf.helpers import (
get_start_and_end_dates, # Get default date range
get_end_of_month, # Get last day of month
get_month_start_and_end, # Get first and last day of month
months_between, # Get list of (year, month) tuples
get_months_between, # Alias for months_between
normalize_date, # Ensure date object (not datetime)
)
# Example usage
start_date, end_date = get_start_and_end_dates(months_ago=2, months_ahead=0)
month_range = months_between(start_date, end_date)Query Utilities
from querytools.tools import (
group_by_and_annotate, # Group by field and count/sum
group_by_and_aggregate, # Get single aggregate value
as_timeseries, # Convert queryset to time series
)
# Count by field
status_counts = group_by_and_annotate(qs, "status")
# Returns: {"pending": 10, "paid": 25, "overdue": 5}
# Sum by field
customer_totals = group_by_and_annotate(qs, "customer__name", aggregation="Sum", aggregation_field="amount")
# Returns: {"Customer A": 1500, "Customer B": 2300}
# Single aggregate
total = group_by_and_aggregate(qs, "amount", "Sum")
# Returns: 3800Testing
..