When you’re working with a CRM, users constantly need to perform actions on records.

For example, a user may want to edit a lead, change its owner, delete an opportunity, duplicate a contact, or update the status of several records at once. As the number of models and features grows, handling all these actions individually can quickly become repetitive.

Horilla CRM takes a different approach.

Instead of creating separate implementations for every action, Horilla provides a declarative way to define actions directly on HorillaListView. You describe what the action is, which permission it requires, and what should happen when it is clicked. The list view takes care of the rest, including permission checks, rendering, dropdown handling, and HTMX integration.

There isn’t a central @register_action decorator or a separate action registry. Actions are simply defined as class attributes on the list view.

This makes the implementation easy to find and understand because the actions for a model are usually defined right alongside that model’s list view.

In this article, we’ll look at how row actions and bulk actions work in Horilla CRM and how you can add your own.

Row Actions in Horilla

Row actions are actions performed on a single record.

Think about the actions you normally see at the end of a CRM table:

  • Edit
  • Delete
  • Change Owner
  • Convert
  • Duplicate

In Horilla, these actions are defined using the actions list on a HorillaListView subclass.

For example, the Lead list view defines actions similar to 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",
        "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"',
    },
    {
        "action": "Delete",
        "src": "assets/icons/a4.svg",
        "permissions": "leads.delete_lead",
        "owner_field": "lead_owner",
        "own_permission": "leads.delete_own_lead",
        "attrs": 'hx-post="{get_delete_url}" '
                 'hx-target="#deleteModeBox"',
    },
]

At first glance, this is just a list of dictionaries. But each dictionary contains enough information for Horilla to determine how the action should behave.

You don’t need to create another registration function or modify a shared template just to introduce a new button.

How the Action URL Works

One of the useful parts of this system is the URL placeholder used inside attrs.

For example:

“attrs”: ‘hx-get=”{get_edit_url}?new=true”‘

The {get_edit_url} value isn’t a hard-coded URL.

Horilla resolves it from the current object. In other words, for each row it can call something equivalent to:

obj.get_edit_url()

This means the model needs to provide the corresponding method.

For a custom action such as Duplicate, you could have:

def get_duplicate_url(self):

    …

and then use:

“attrs”: ‘hx-get=”{get_duplicate_url}?duplicate=true”‘

Behind that URL, you would normally have a Django URL and view that handles the action. Depending on what the action does, this might be a HorillaSingleFormView, HorillaSingleDeleteView, or another suitable view.

This keeps the responsibility nicely separated.

The list view defines what action is available, while the model and view define what the action actually does.

Permissions Are Part of the Action Definition

Another important part of the action system is permissions.

Instead of putting permission checks inside every individual action handler, Horilla lets you describe the required permissions directly in the action dictionary.

For example:

{
    "action": "Edit",
    "permission": "leads.change_lead",
}

This tells Horilla that the user needs the leads.change_lead permission to use the action.

There is also support for ownership-based permissions.

For example:

{
    "action": "Edit",
    "permission": "leads.change_lead",
    "own_permission": "leads.change_own_lead",
    "owner_field": "lead_owner",
}

This allows the permission system to distinguish between users who can edit any lead and users who are only allowed to edit leads they own.

Horilla evaluates these settings through its action permission handling rather than requiring every action to implement its own permission logic.

You can also work with multiple permissions using permissions and permission_logic, where the logic can be configured as OR or AND.

This is one of the reasons the action system stays relatively clean as the application grows. Permission rules remain part of the configuration instead of being scattered throughout individual views.

Controlling When an Action Is Visible

Not every action should be available for every record.

A good example is Lead conversion.

Once a lead has already been converted, showing the Convert button doesn’t make much sense. Similarly, an action might need to disappear when a particular field is read-only for the current user.

Horilla supports this using hidden_if and disabled_if.

For example:

{
    "action": "Change Owner",
    "hidden_if": field_readonly_hidden_if(
        Lead,
        "lead_owner"
    ),
}

The callable receives the current object and determines whether the action should be hidden.

This is useful because visibility can depend on the actual record rather than being the same for every row.

You can therefore build actions that respond to the state of the record without creating another custom template.

Adding a Custom Row Action

Suppose you have created a new CRM model and want to add a Duplicate action.

Your list view could contain:

actions = [
    # Existing actions...


    {
        "action": _("Duplicate"),
        "src": "assets/icons/duplicate.svg",
        "permission": "myapp.add_mymodel",
        "owner_field": "owner",
        "own_permission": "myapp.add_own_mymodel",
        "attrs": (
            'hx-get="{get_duplicate_url}?duplicate=true" '
            'hx-target="#modalBox" '
            'hx-swap="innerHTML" '
            'onclick="openModal()"'
        ),
    },
]

Then your model provides the corresponding URL method:

def get_duplicate_url(self):

    …

Finally, you create the Django URL and view that handles the duplication.

The important point is that there is no separate action registration step.

Adding the dictionary to actions is effectively the registration.

What Happens When There Are Too Many Actions?

A list can become difficult to use if every possible action is displayed as a button.

Horilla handles this automatically.

HorillaListView can divide actions into visible actions and dropdown actions based on max_visible_actions.

So if a list has several actions, you don’t have to manually create your own “More” menu or decide which actions should move into it.

You define the actions, and the list view takes care of the presentation.

This is a small feature, but it becomes particularly useful in CRM screens where records can have many possible operations.

Bulk Actions

Row actions are useful when working with one record, but CRM users often need to perform the same operation on many records.

For example:

Select 20 leads and change their owner.

Or:

Select several opportunities and change their stage.

This is where bulk actions come in.

Horilla’s bulk action system can be thought of in three levels.

Built-in Bulk Actions

The first level requires no custom implementation.

HorillaListView already integrates bulk delete and bulk export through its bulk action mixins.

That means a list view can get common bulk operations without having to implement them separately for every model.

This is useful because these operations are generic. There is no reason for every CRM module to reinvent bulk deletion or export functionality.

The selected records are tracked on the client side, and Horilla sends the selected record IDs when the bulk action is performed.

Bulk Updating Fields

The second level is bulk field editing.

Suppose users should be able to update several fields across multiple selected records.

You can simply specify the fields:

bulk_update_fields = [
    "annual_revenue",
    "no_of_employees",
    "lead_source",
    "lead_owner",
    "industry",
    "lead_status",
]

That’s enough to tell Horilla which fields can participate in the bulk update interface.

The HorillaBulkUpdateMixin takes care of displaying the update form and applying the selected values to the selected records.

This is particularly useful for fields such as:

  • Owner
  • Status
  • Industry
  • Source
  • Stage
  • Priority

Instead of writing a separate endpoint for every field, you declare the fields once and let the generic bulk update system handle them.

Bulk Updates Still Respect Permissions

There is an important detail here.

Bulk editing isn’t simply a matter of trusting the fields sent by the browser.

Horilla checks which fields the current user is actually allowed to edit using the field permission system.

The bulk update process uses:

get_editable_fields(
request.user,
self.model,
self.bulk_update_fields
)

This means that even if someone modifies the request manually and tries to submit a field that shouldn’t be editable, the server-side permission check still applies.

That is an important distinction.

The frontend determines what the user sees, but the backend remains responsible for enforcing what the user can actually change.

Creating a Fully Custom Bulk Action

Sometimes a simple field update isn’t enough.

Imagine you want an action called Mark as Won.

Changing the opportunity’s stage might be part of the operation, but perhaps you also need to:

  • Update another field
  • Create a history record
  • Trigger a notification
  • Perform some additional business logic

This is where custom_bulk_actions becomes useful.

You can define:

custom_bulk_actions = [
{
“name”: “mark_won”,
“label”: _(“Mark as Won”),
“icon”: “fa-trophy”,
“handler”: “handle_mark_won”,
},
]

Then implement the handler:

def handle_mark_won(self, record_ids, request):
updated = (
Opportunity.objects
.filter(id__in=record_ids)
.update(stage=”won”)
)

# Additional business logic… return HttpResponse(…)

The important part here is the handler.

When the bulk request reaches HorillaListView.post(), Horilla checks whether the submitted action matches one of the configured custom bulk actions.

If it does, the specified handler is called with:

(record_ids, request)

Your handler is then responsible for performing the operation and returning an HttpResponse.

This gives you a much more flexible extension point than simply changing model fields.

You Can Also Use a Normal HTMX Request

A custom bulk action doesn’t necessarily have to use a Python handler.

If you don’t provide a handler, Horilla can use the generic HTMX configuration instead.

For example, the action can specify things such as:

{
    "name": "some_action",
    "label": _("Some Action"),
    "url": "...",
    "method": "POST",
    "target": "#some-target",
    "swap": "innerHTML",
}

This allows the bulk action button to behave like a normal HTMX request to a view that you provide.

So there are effectively two approaches:

Use a handler when the operation is tightly coupled to the list view and requires custom Python logic.

Use URL/method/target/swap configuration when you want the button to make a regular HTMX request to another view.

How Horilla Connects the Pieces

The interesting part of this system is that the list view doesn’t actually contain all of the rendering logic.

The Python configuration describes the actions, while the generic templates and template tags turn that configuration into the actual UI.

For row actions, the list view passes the action definitions to the list rendering system.

The row action templates use helpers such as:

resolve_row_actions
filter_actions_by_permission
render_action_button

These determine which actions should actually be shown for the current user and record.

For bulk actions, the main list template renders the available bulk operations and builds the HTMX request using the IDs collected by the JavaScript selection system.

The selected IDs are maintained by:

selectedRecordIds(viewId)

This means the templates themselves don’t need to know anything about individual CRM models.

They simply understand the generic action structure.

That’s what makes the system reusable.

If you add another action to a Lead, Opportunity, Campaign, or your own custom model, the same generic rendering infrastructure can handle it.

Why This Design Works Well

The biggest advantage of this approach is that actions are treated as configuration rather than separate code paths.

Imagine the alternative.

Every model could have its own custom template, its own action buttons, its own permission checks, its own JavaScript, and its own bulk update endpoint.

That might work for one model.

But as a CRM grows, maintaining all of those implementations becomes expensive.

Horilla instead gives the list view a common language for describing actions.

A typical action can tell the framework:

  • What should the action be called?
  • Which icon should it use?
  • Which permission is required?
  • Does ownership matter?
  • When should it be hidden?
  • What URL should be requested?
  • Which HTMX attributes should be attached?

The generic list infrastructure then interprets that configuration.

This also makes the code easier to inspect. When you open a model’s list view, you can usually see its available actions in one place.

When Should You Use Each Approach?

The choice between the different action types is actually quite straightforward.

If you’re working with one record, use actions.

For example:

Edit
Delete
Convert
Duplicate
Change Owner

If you need to update one or more fields on many records, use:

bulk_update_fields

If the operation involves custom business logic or side effects, use:

custom_bulk_actions

And if an action should only appear for certain records, use:

hidden_if

or:

disabled_if

This keeps the implementation proportional to the problem instead of forcing every operation into a custom view.

One More Important Detail

At the moment, custom_bulk_actions is more of an extension point than an established CRM pattern in the codebase.

The existing CRM modules generally use bulk_update_fields for operations such as updating opportunity stages rather than implementing those operations as custom bulk handlers.

That’s worth keeping in mind when extending Horilla.

If your requirement can be expressed as a normal field update, the built-in bulk update system is usually the simpler choice.

A custom bulk handler makes more sense when there is genuinely additional business logic that can’t be represented by a simple update.

Horilla’s action system is a good example of how a generic framework can remove a lot of repetitive code from a CRM.

Instead of creating a separate implementation every time a new button is needed, you describe the action in the list view and let the existing infrastructure handle the common pieces.

For individual records, the actions list gives you control over URLs, permissions, ownership, visibility, and HTMX behavior.

For multiple records, built-in bulk operations cover common tasks, bulk_update_fields handles straightforward field changes, and custom_bulk_actions gives you an escape hatch when the operation needs custom business logic.

The real benefit isn’t just fewer lines of code. It’s consistency.

Once the list view understands how an action is described, the same permission handling, rendering, dropdown behavior, and HTMX integration can be reused across Leads, Opportunities, Campaigns, and any other model that uses HorillaListView.

So in Horilla, registering an action doesn’t mean adding another registry entry or building another custom UI.

Most of the time, it simply means adding one more entry to a list and letting the framework do the repetitive work for you.

Share this article