When users open a record from a list or Kanban view, they usually expect to see more than just a few basic details. They may want to check the record’s information, current stage, available actions, related activities, attachments, and other connected data.
Building all these features manually can take a lot of time. You would need to handle field display, permissions, breadcrumbs, stage indicators, actions, previous and next record navigation, and related tabs separately.
Horilla makes this easier with HorillaDetailView.
HorillaDetailView is available in horilla/contrib/generics/views/details.py and extends Django’s built-in DetailView. It works alongside HorillaListView and HorillaKanbanView. While the list and Kanban views are used to display multiple records, HorillaDetailView is designed to display one complete record with its fields, actions, and other related features.
1. Creating a Basic Detail View
If you have already used HorillaListView, the configuration of HorillaDetailView will feel familiar.
A basic detail view mainly needs a model and a body that defines the fields to display.
For example, a simple Lead detail view can be created like this:
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from horilla.contrib.generics.views import HorillaDetailView
from .models import Lead
from .views import LeadListView
class LeadDetailView(LoginRequiredMixin, HorillaDetailView):
model = Lead
tab_url = reverse_lazy("leads:lead_detail_view_tabs")
pipeline_field = "lead_status"
actions = LeadListView.actions
body = [
"title",
"first_name",
"last_name",
"email",
"lead_source",
"industry",
"lead_owner",
]
This is enough to create a functional detail page.
Here, the first field in body, which is “title”, is used as the page heading when header_fields is not defined. The remaining fields are displayed in the details section.
The pipeline_field adds a stage indicator to the page. For example, a lead can move through stages such as New, Contacted, and Qualified.
The actions attribute allows the detail page to reuse the actions already defined for the list view.
Another useful feature is permission handling. You do not need to manually add permission checks just to open the detail page. HorillaDetailView handles this through dispatch().
A user can access the page when they have the model’s view permission or the appropriate view_own permission along with ownership of the record.
2. Understanding body, header_fields, and fieldsets
Horilla provides three main ways to control how fields are displayed:
- body
- header_fields
- fieldsets
Using body
Body is the simplest option. It contains a list of field names or (label, field_name) tuples.
For example:
body = [
"title",
"first_name",
"last_name",
"email",
]
When header_fields is not defined, the first item in body is treated as the page title and the remaining fields are displayed in the details grid.
Using header_fields
If you want to explicitly define the fields that appear in the header, you can use header_fields:
header_fields = ["title"]
When header_fields is defined, all fields in body remain in the details grid. The first field is no longer automatically removed from the grid for use as the header.
Using fieldsets
For larger detail pages, grouping related fields into sections makes the page easier to understand.
For example:
fieldsets = (
(_("Personal Information"), {
"fields": ("first_name", "last_name", "email", "contact_number"),
"icon": "fas fa-user",
}),
(_("Address"), {
"fields": ("city", "state", "country", "zip_code"),
"icon": "fas fa-map-marker-alt",
}),
)
Each fieldset can have a title, a list of fields, and an icon.
You can also add a description if you want to display additional information below the section heading.
If fieldsets is not defined, Horilla automatically falls back to a single unnamed section using the fields returned by get_body().
Field labels are taken from the model’s verbose_name, which also means they continue to work correctly with translations.
3. Excluding Fields
Not every model field needs to be displayed to users.
Horilla already excludes common internal fields through base_excluded_fields. This means fields such as id, created_at, updated_at, and other internal bookkeeping fields do not need to be manually removed every time.
If your model has additional fields that should not appear on the detail page, you can define them using excluded_fields:
excluded_fields = ["is_convert", "message_id"]
Field exclusion is not the only mechanism that controls visibility. Horilla also checks field-level permissions for the current user. Therefore, a field can be hidden because of user permissions even when it has not been added to excluded_fields.
4. Reusing Actions from the List View
Detail pages often need actions such as Edit, Delete, Change Owner, or Convert.
Instead of defining the same actions again, you can reuse the actions from the list view:
class LeadDetailView(LoginRequiredMixin, HorillaDetailView):
model = Lead
actions = LeadListView.actions
For example, an action definition can look like this:
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",
"hidden_if": field_readonly_hidden_if(Lead, "lead_owner"),
"attrs": """
hx-get="{get_change_owner_url}"
hx-target="#modalBox"
hx-swap="innerHTML"
onclick="openModal()"
""",
},
]
The same permission settings used by list and Kanban actions can also be used on the detail page.
This keeps action behavior consistent across different views. If an action is unavailable to a particular user in the list view because of permissions, the same permission rules can be applied to the detail view as well.
5. Adding a Pipeline
A detail page can also display the current stage of a record as a pipeline.
To enable it, define pipeline_field:
pipeline_field = "lead_status"
The field can be a choice field or a ForeignKey.
For a choice field, the stages are taken from the field’s choices in the order they are defined.
For a ForeignKey, Horilla can use the related records as the stages. If the related model has an order field, the stages are ordered using that field. The stage’s color can also be displayed when the related model provides one.
Users can click a stage in the pipeline to update the record. The update is performed through HTMX and follows the same change and ownership permission checks used elsewhere in Horilla.
Handling the Final Stage
Some workflows need a special action when a record reaches its final stage.
For this, HorillaDetailView provides final_stage_action.
For example:
from django.utils.functional import cached_property
class LeadDetailView(LoginRequiredMixin, HorillaDetailView):
model = Lead
pipeline_field = "lead_status"
@cached_property
def final_stage_action(self):
if _convert_action_hidden_if(self.object):
return {
"hx-on:click": "event.preventDefault();",
"aria-label": _("You don't have permission to convert this lead"),
}
return {
"hx-get": reverse_lazy(
"leads:convert_lead",
kwargs={"pk": self.object.pk},
),
"hx-target": "#contentModalBox",
"hx-swap": "innerHTML",
"hx-on:click": "openContentModal();",
}
This can be used to display a Convert action when a lead reaches its final status.
If the user does not have permission to perform the conversion, the action can remain visible but become inactive with an appropriate accessibility label.
6. Adding Badges
Sometimes a record needs a small visual indicator beside its title.
For example, an Opportunity might need a Won or Lost badge.
Horilla supports this through the badge attribute:
badge = [
{
"condition": lambda obj: obj.stage and obj.stage.is_final,
"label": _("Won"),
"class": "bg-green-100 text-green-700",
"icon": "fa-solid fa-check",
"icon_class": "text-green-600",
},
]
Each badge can contain:
- condition
- label
- CSS class
- icon
- icon_class
- icon_bg_class
The condition function receives the current object and determines whether the badge should be displayed.
For more complicated conditions, you can override get_badges() instead.
For example:
def get_badges(self):
obj = self.get_object()
badges = []
if obj.stage and obj.stage.is_final and obj.stage.name == "Won":
badges.append({
"label": _("Won"),
"class": "bg-green-100 text-green-700",
})
elif obj.stage and obj.stage.is_final:
badges.append({
"label": _("Lost"),
"class": "bg-red-100 text-red-700",
})
return badges
Using badge is convenient for simple conditions, while get_badges() is more suitable when the logic requires multiple checks.
7. Breadcrumbs and Record Navigation
Breadcrumbs help users understand where they are and easily return to the previous page.
HorillaDetailView can automatically build breadcrumbs based on the page the user came from. It can use the current HTMX URL or the HTTP referer.
For example, if a user opens a Lead from the Lead list, the breadcrumb can provide a path back to that list.
You can also define fixed breadcrumbs manually:
breadcrumbs = [
("Sales", "leads:leads_view"),
("Opportunities", "opportunities:opportunities_view"),
]
Another useful feature is previous and next record navigation.
When a user opens a record from a list, Horilla keeps track of the record IDs from that list in the session. The detail page can then provide previous and next navigation without requiring additional configuration.
This is particularly useful when users need to review several records one after another.
8. Tabs for Activities, Notes, Attachments, and Related Records
A complete detail page often contains more than basic model fields.
For example, a CRM record may have:
- Activities
- Notes
- Attachments
- Related Contacts
- Other linked records
Horilla supports these sections through tab_url:
tab_url = reverse_lazy(“leads:lead_detail_view_tabs”)
The related tabs also follow Horilla’s permission system.
Functions such as check_record_access, check_record_change_access, and check_record_delete_access are used to determine whether the current user can view, add to, or delete related records.
This means related tabs do not need a completely separate permission system.
9. Creating a Dynamic Detail Body
Sometimes the fields shown on a detail page need to change depending on the record.
For example, an Activity can represent different types of activities, such as calls or meetings. Each type may require different fields.
In such cases, you can override get_body():
class ActivityDetailView(LoginRequiredMixin, HorillaDetailView):
model = Activity
pipeline_field = "status"
def get_body(self):
self.body = get_activity_detail_view_fields(
self.get_object().activity_type
)
return super().get_body()
This approach allows the field list to change according to the current record while still keeping the existing behavior of HorillaDetailView.
The parent get_body() method continues to handle permission filtering, excluded fields, and user-level field visibility.
10. Using the Split-View Layout
Sometimes users need to view a record without completely leaving the list page.
For example, a salesperson may want to open a Lead while keeping the Lead list visible.
HorillaDetailView supports this through the following query parameter:
?layout=split
When layout=split is used, Horilla switches to the split-view template:
detail_view_split_fragment.html
The split layout uses get_detail_section_body() to build the displayed fields. This is also where split_excluded_fields is applied.
No special configuration is required in the detail view itself, as long as the model provides get_detail_url().
For example:
def get_detail_url(self):
return reverse_lazy(
"leads:leads_detail",
kwargs={"pk": self.pk},
)
This makes the split layout useful when users need to move through multiple records while keeping the original list available.
11. Using HorillaModalDetailView
Not every record needs a complete detail page.
Sometimes you only need to show a few pieces of information in a modal. For example, a user may want to quickly check a holiday’s dates without leaving the current page.
For these situations, Horilla provides HorillaModalDetailView.
It follows a simpler structure using header, body, and actions.
For example:
class HolidayDetailView(LoginRequiredMixin, HorillaModalDetailView):
model = Holiday
header = {
"title": "name",
"subtitle": "",
"avatar": "get_avatar",
}
body = [
(_("Holiday Start Date"), "start_date"),
(_("Holiday End Date"), "end_date"),
]
actions = [
{
"action": "Edit",
"permission": "core.change_holiday",
"attrs": (
'hx-get="{get_edit_url}" '
'hx-target="#modalBox" '
'hx-swap="innerHTML"'
),
},
{
"action": "Delete",
"permission": "core.delete_holiday",
"attrs": (
'hx-post="{get_delete_url}" '
'hx-target="#modalBox"'
),
},
]
HorillaModalDetailView also supports previous and next navigation.
It keeps an ordered list of instance IDs in the session using ids_key, which defaults to “instance_ids”.
For small, focused record lookups, the modal detail view is a good choice. For full record pages that need pipelines, breadcrumbs, tabs, badges, and other features, HorillaDetailView is more suitable.
12. Putting Everything Together
Here is an example that combines several of the features discussed above in an Opportunity detail view:
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from horilla.contrib.generics.views import HorillaDetailView
from .models import Opportunity
from .views import OpportunityListView
class OpportunityDetailView(LoginRequiredMixin, HorillaDetailView):
model = Opportunity
pipeline_field = "stage"
tab_url = reverse_lazy(
"opportunities:opportunity_detail_view_tabs"
)
actions = OpportunityListView.actions
breadcrumbs = [
("Sales", "leads:leads_view"),
("Opportunities", "opportunities:opportunities_view"),
]
body = [
"name",
"amount",
"expected_revenue",
"quantity",
"close_date",
"probability",
"forecast_category",
]
def get_badges(self):
obj = self.get_object()
badges = []
if obj.stage and obj.stage.is_final:
won = obj.stage.name.lower() == "won"
badges.append({
"label": _("Won") if won else _("Lost"),
"class": (
"bg-green-100 text-green-700"
if won
else "bg-red-100 text-red-700"
),
})
return badges
This example combines several important features:
- The model defines the record being displayed.
- pipeline_field adds the Opportunity stage pipeline.
- tab_url enables related tabs.
- actions reuses the actions from the list view.
- breadcrumbs defines the navigation path.
- body controls the fields displayed on the page.
- get_badges() adds a badge based on the Opportunity’s final stage.
The result is a complete detail page without having to build each of these features separately.
When Should You Use HorillaDetailView?
A simple detail page does not require every available feature.
If you only need to display a few fields, you can start with:
class LeadDetailView(LoginRequiredMixin, HorillaDetailView):
model = Lead
body = [
"title",
"first_name",
"last_name",
"email",
]
As the module becomes more complex, you can add only the features you actually need:
- header_fields for custom header fields
- fieldsets for grouped sections
- excluded_fields for additional hidden fields
- actions for record-level actions
- pipeline_field for stage-based workflows
- final_stage_action for actions at the final stage
- badge or get_badges() for status indicators
- breadcrumbs for navigation
- tab_url for related information
- get_body() for dynamic fields
- layout=split for split-view behavior
This makes the detail view flexible without forcing every module to have the same configuration.
Creating a useful detail page usually involves more than simply displaying model fields. Permissions, actions, pipelines, breadcrumbs, related tabs, badges, and record navigation all become important as the application grows.
HorillaDetailView brings these features together in one reusable Django view.
Instead of implementing each feature separately, a module can mainly focus on defining its model, fields, actions, and workflow-specific behavior.
It also works consistently with HorillaListView and HorillaKanbanView, especially when it comes to actions, permissions, and field configuration. This makes it easier to build different views for the same model without having to learn a completely different pattern for each one.
For a basic record page, HorillaDetailView can be as simple as a model and a body. When a module needs more advanced functionality, the same view can be extended with pipelines, tabs, badges, breadcrumbs, dynamic fields, and split-view support.
That makes HorillaDetailView a practical choice whenever a Horilla module needs a complete and reusable record detail page.