A Kanban board is a convenient way to work with records that move through different stages. Instead of looking at everything in a table, users can see records as cards arranged in columns. This works especially well for things like leads moving through a sales pipeline, opportunities progressing toward a deal, or activities waiting for review.
Creating a Kanban board from scratch usually involves quite a bit of work. You need to group records by a field, display them as cards, support drag-and-drop, update the record when its stage changes, handle pagination, manage permissions, and deal with empty states.
Horilla already provides this functionality through HorillaKanbanView.
HorillaKanbanView is located in horilla/contrib/generics/views/kanban.py and is built on top of HorillaListView. Because of this, a Kanban view shares many of the same features as a list view, including filtering, actions, permissions, and empty-state handling. The Kanban-specific functionality is mainly added around grouping, cards, and drag-and-drop.
1. Creating a Basic Kanban View
If you have already worked with HorillaListView, creating a Kanban view will feel familiar. The main difference is the group_by_field attribute, which tells Horilla which field should be used to create the board columns.
For example, a lead board can be grouped by lead_status:
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from django.utils.decorators import method_decorator
from horilla.contrib.generics.views import HorillaKanbanView
from horilla.decorators import permission_required_or_denied
from .models import Lead
from .filters import LeadFilter
@method_decorator(
permission_required_or_denied(
["leads.view_lead", "leads.view_own_lead"]
),
name="dispatch",
)
class LeadKanbanView(LoginRequiredMixin, HorillaKanbanView):
model = Lead
view_id = "Lead_Kanban"
filterset_class = LeadFilter
search_url = reverse_lazy("leads:leads_list")
main_url = reverse_lazy("leads:leads_view")
group_by_field = "lead_status"
columns = [
"title",
"first_name",
"email",
"lead_source",
"industry",
"annual_revenue",
]
That is enough to get a working Kanban board. The leads are grouped into columns based on their status, and the cards can be moved between columns to update the status.
Another useful detail is that HorillaKanbanView already applies htmx_required at the class level. Since Kanban requests are expected to come through HTMX, there is no need to add htmx_required yourself. You only need to add the permission checks required by your view.
2. Understanding group_by_field
group_by_field is the main setting that distinguishes a Kanban view from a regular list view.
It specifies the model field that should be used to create the columns. The field must be either a choice field or a ForeignKey. Horilla validates this before using it for the board.
For example:
group_by_field = "stage"
group_by_field = "lead_status"
group_by_field = "department"
group_by_field = "account_type"
These could represent an opportunity stage, lead status, department, or account type respectively.
The value assigned to group_by_field acts as the default. Users can also change the grouping from the Kanban settings modal. Their selected grouping is saved for that user and model, so different users can have different Kanban layouts.
If a user has not selected a preference, Horilla uses the group_by_field defined on the view. If that is not available, it falls back to the first field the user is allowed to use.
You can also control which fields appear in the group-by selector.
exclude_kanban_fields = "lead_owner"
include_kanban_fields = ["lead_status", "priority"]
exclude_kanban_fields can be used when you want to hide specific fields, while include_kanban_fields lets you provide a smaller list of allowed grouping fields.
If neither option is defined, Horilla CRM offers all eligible fields.
There is also an additional behavior when the selected grouping field is a ForeignKey. If the related model contains an order field, Horilla can use it to support column reordering. If the related model has a color field, that color can also be used for the Kanban columns.
3. Options Inherited from HorillaListView
One of the biggest advantages of HorillaKanbanView extending HorillaListView is that you do not have to learn an entirely new configuration system.
Many of the attributes you already use in a list view work with the Kanban view as well.
| Variable | Purpose |
| columns | Defines the fields displayed on each card |
| filterset_class | Adds filtering to the Kanban board |
| actions | Defines actions available from the card menu |
| col_attrs | Adds clickable or permission-aware behavior to fields |
| bulk_update_fields, bulk_delete_enabled | Inherited, but not used by the Kanban markup |
| no_record_section, no_record_msg, no_record_add_button | Controls the empty state |
| search_url, main_url, view_id | Handles view identity and routing |
Because list and Kanban views share this configuration, it is often easier to reuse the actions from the related list view rather than define them again.
For example:
lead_permission = {
"permission": "leads.change_lead",
"own_permission": "leads.change_own_lead",
"owner_field": "lead_owner",
}
actions = [
{
**lead_permission,
"action": "Edit",
"src": "assets/icons/edit.svg",
"img_class": "w-4 h-4",
"attrs": """
hx-get="{get_edit_url}?new=true"
hx-target="#modalBox"
hx-swap="innerHTML"
onclick="openModal()"
""",
},
{
**lead_permission,
"action": "Change Owner",
"src": "assets/icons/a2.svg",
"img_class": "w-4 h-4",
"hidden_if": field_readonly_hidden_if(Lead, "lead_owner"),
"attrs": """
hx-get="{get_change_owner_url}"
hx-target="#modalBox"
hx-swap="innerHTML"
onclick="openModal()"
""",
},
{
**lead_permission,
"action": "Convert",
"src": "assets/icons/a3.svg",
"img_class": "w-4 h-4",
"hidden_if": _convert_action_hidden_if,
"attrs": """
hx-get="{get_lead_convert_url}"
hx-target="#contentModalBox"
hx-swap="innerHTML"
onclick="openContentModal()"
""",
},
{
"action": _("Duplicate"),
"src": "assets/icons/duplicate.svg",
"img_class": "w-4 h-4",
"permission": "leads.add_lead",
"owner_field": "lead_owner",
"own_permission": "leads.add_own_lead",
"attrs": """
hx-get="{get_duplicate_url}?duplicate=true"
hx-target="#modalBox"
hx-swap="innerHTML"
onclick="openModal()"
""",
},
{
"action": "Delete",
"src": "assets/icons/a4.svg",
"img_class": "w-4 h-4",
"permissions": "leads.delete_lead",
"owner_field": "lead_owner",
"own_permission": "leads.delete_own_lead",
"attrs": """
hx-post="{get_delete_url}"
hx-target="#deleteModeBox"
hx-swap="innerHTML"
hx-trigger="click"
hx-vals='{{"check_dependencies": "true"}}'
onclick="openDeleteModeModal()"
""",
},
]
This keeps the behavior consistent between the list and Kanban views.
The columns attribute also supports the same two formats available in list views.
You can provide simple field names:
columns = [
"name",
"amount",
"owner",
]
Or provide labels along with field names:
columns = [
(_("Subject"), "subject"),
(_("Activity Type"), "activity_type"),
(_("Related To"), "related_object"),
]
Some list-view features are inherited by the class but are not rendered by the Kanban templates. For example, bulk_select_option, bulk_update_option, and bulk_export_option do not add bulk-selection controls to the Kanban board.
4. Making the Entire Card Clickable
In a list view, you can use col_attrs to make a particular field clickable. For a Kanban board, you can make the entire card clickable using kanban_attrs.
kanban_attrs is a dictionary and is commonly defined as a cached_property so that URLs and permissions can be calculated for each request.
For example:
from django.utils.functional import cached_property
class LeadKanbanView(LoginRequiredMixin, HorillaKanbanView):
...
@cached_property
def kanban_attrs(self):
return {
"hx-get": "{get_detail_url}",
"hx-target": "#mainContent",
"hx-swap": "outerHTML",
"hx-push-url": "true",
"hx-select": "#mainContent",
"permission": "leads.view_lead",
"own_permission": "leads.view_own_lead",
"owner_field": "lead_owner",
}
The {get_detail_url} placeholder is resolved separately for each card.
The permission-related attributes are also important. Horilla checks permission, own_permission, and owner_field before making the card clickable. This means a user can still see a record without necessarily being allowed to open it.
Card actions work in a similar way. The kebab menu uses the same action structure used by list views, including permission checks, ownership checks, and hidden_if conditions.
5. Controlling Column Height and Card Order
You can control the height of Kanban columns using height_kanban.
For example:
height_kanban = "h-[550px]"
If you do not specify a value, Horilla uses:
h-[calc(100vh_-_190px)]
This allows the columns to fill the available vertical space.
You can also control the order of cards using kanban_order_by.
kanban_order_by = “-updated_at”
By default, Horilla puts recently updated records first. If the model does not have an updated_at field, it automatically falls back to -id.
You can also provide multiple ordering fields by using a tuple or list.
6. Pagination Inside Kanban Columns
Kanban boards handle pagination slightly differently from list views.
You can set the number of cards loaded per column with:
paginate_by = 30
A list view normally paginates the complete table, but a Kanban board paginates each column separately. The default is 30 cards per column.
When there are more records, additional cards are loaded as the user scrolls. Horilla uses an HTMX request triggered when the user reaches the relevant part of the column, so there is no traditional pagination control at the bottom of the board.
7. Customizing Drag-and-Drop Behavior
Drag-and-drop works automatically in the standard case.
When a user moves a card into another column, Horilla sends the update to a shared endpoint. The record’s group-by field is updated and the relevant Kanban content is refreshed.
For most modules, you will not need to customize this behavior.
However, there are situations where the default behavior is not enough. The Activity Kanban view is an example. The same Kanban view is used across different activity tabs, so simply re-rendering one column after a status change would not always give the correct result.
In that situation, the view can override update_kanban_item:
class ActivityKanbanView(LoginRequiredMixin, HorillaKanbanView):
model = Activity
view_id = "activity-kanban"
filterset_class = ActivityFilter
search_url = reverse_lazy("activity:activity_list_view")
main_url = reverse_lazy("activity:activity_view")
group_by_field = "status"
def update_kanban_item(self, request):
"""
After drag-drop, save the status change then reload the active tab's
kanban via reloadButton, since the registry maps Activity to this
view regardless of which tab is showing.
"""
...
The important point is that you only need to override the generic behavior when your module has a specific requirement that the default implementation cannot handle. This is similar to overriding methods such as get_queryset or get_context_data in a list view.
Drag Permission
Horilla also handles permission checks for moving cards.
The can_user_modify_item logic allows a user to move a card when they have either the model’s global change permission or the appropriate change_own permission together with ownership of the record.
This is based on the model’s standard permissions and OWNER_FIELDS, so there is normally no extra configuration required.
8. Handling the Empty State
Kanban views use the same empty-state configuration as list views.
For example:
def no_record_add_button(self):
if self.request.user.has_perm("accounts.add_account"):
return {
"url": f"{reverse_lazy('accounts:account_create_form_view')}?new=true",
"attrs": 'id="account-create"',
}
return None
You can also use:
- no_record_section
- no_record_msg
- no_record_add_button
There is no separate Kanban-specific empty-state system. The same settings work whether the records are displayed as a table or as cards.
9. Putting Everything Together
Here is a more complete example using an Opportunity model:
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from django.utils.decorators import method_decorator
from django.utils.functional import cached_property
from horilla.contrib.generics.views import HorillaKanbanView
from horilla.decorators import permission_required_or_denied
from .models import Opportunity
from .filters import OpportunityFilter
from .views import OpportunityListView
@method_decorator(
permission_required_or_denied(
[
"opportunities.view_opportunity",
"opportunities.view_own_opportunity",
]
),
name="dispatch",
)
class OpportunityKanbanView(LoginRequiredMixin, HorillaKanbanView):
model = Opportunity
view_id = "opportunity-kanban"
filterset_class = OpportunityFilter
search_url = reverse_lazy("opportunities:opportunities_list")
main_url = reverse_lazy("opportunities:opportunities_view")
group_by_field = "stage"
height_kanban = "h-[550px]"
actions = OpportunityListView.actions
columns = [
"name",
"amount",
"owner",
"close_date",
"expected_revenue",
]
@cached_property
def kanban_attrs(self):
return {
"hx-get": "{get_detail_url}",
"hx-target": "#mainContent",
"hx-swap": "outerHTML",
"hx-push-url": "true",
"hx-select": "#mainContent",
"permission": "opportunities.view_opportunity",
"own_permission": "opportunities.view_own_opportunity",
"owner_field": "owner",
}
This example brings together the main pieces: permissions, filtering, grouping, column height, actions, card fields, and clickable cards.
Not every attribute is required. A simple Kanban view can work with just the model, grouping field, and card columns.
Creating a Kanban board can look like a completely different task compared with building a list view, but with Horilla, much of the underlying functionality is shared.
Since HorillaKanbanView extends HorillaListView, features such as filtering, actions, permissions, and empty-state handling are already available. The main thing you need to decide is which field should control the board’s columns through group_by_field.
From there, you can add only the configuration your module actually needs. kanban_attrs can make cards clickable, height_kanban controls the column height, kanban_order_by controls card ordering, and paginate_by manages the number of records loaded into each column.
For a basic board, very little code is required. For more advanced modules, you can override specific behavior such as update_kanban_item without having to rebuild the entire Kanban system.
That makes HorillaKanbanView a practical choice when you want a board-style interface while still keeping the same patterns and permission handling used throughout Horilla’s list views.