Skip to content

Dynamic Filters

Dynamic filters provide a flexible way to filter data on the frontend and backend. This guide explains how to use and create new dynamic filters.

How it Works

The dynamic filtering system is composed of two main parts: a backend that defines the filtering logic and a frontend that provides the user interface.

Backend

The backend is responsible for defining the filters and applying them to the database queries. The core components are:

  • ColumnFilter: An abstract base class that defines the interface for a single column filter. Each ColumnFilter implementation processes URL parameters and applies the appropriate database filter to a queryset. The base class automatically maps operators to methods (e.g., "equals" → apply_equals).

  • MultiColumnFilter: A container class that holds a list of ColumnFilter instances and applies them to a queryset. It provides a columns() class method to list available filter column names and handles the orchestration of applying multiple filters.

  • Filter Types: StringColumnFilter, ChoiceColumnFilter, and TimestampFilter provide pre-built implementations for common filtering patterns, reducing boilerplate code.

  • Filter Implementations: Concrete implementations of ColumnFilter can be found in apps/web/dynamic_filters/column_filters.py (base filters like TimestampFilter, ParticipantFilter) and in app-specific filters.py files throughout the project (e.g., apps/experiments/filters.py).

  • FilterParams & ColumnFilterData: FilterParams extracts and organizes filter data from request query parameters into ColumnFilterData objects, which contain the column name, operator, and value for each filter.

Frontend

The frontend is built using Alpine.js and HTMX. It dynamically generates the filter UI based on the configuration provided by the backend.

  • Filter Template: The main filter template is templates/experiments/filters.html. This template contains the Alpine.js component that manages the filter state and UI.
  • Filter Configuration: The backend provides the filter configuration to the frontend through a set of df_* context variables. These variables are passed as JSON scripts in the HTML and then used to initialize the Alpine.js component. See apps/experiments/views/experiment.py for an example of how this data is provided to the template.

Linking query parameters to ORM operations

Query Parameters

Filter values are passed through URL query parameters using a modern format that optimizes URL length and readability:

  • f_{column_name} - Contains the filter value(s)
  • op_{column_name} - Defines the filter operation (e.g., equals, contains, before, after)

Reserved prefixes

f_ and op_ are reserved query-string prefixes. FilterParams treats every param beginning with f_ as a filter column (with op_<column> as its operator). Do not add unrelated query parameters using these prefixes to any page that renders a filterable table — they would be silently interpreted as filters.

Examples: - Single value: ?f_status=active&op_status=equals - Multiple values (separated by ~): ?f_tags=tag1~tag2&op_tags=any%20of - Values containing ~ are quoted: ?f_tags=tag1~"tag~2"~tag3&op_tags=any%20of

Several filters on one column

Because the key holds the column rather than a position, a column carrying more than one filter is expressed by repeating the key. The UI allows this — a date range is after X AND before Y on the same column — and every filter is applied, combined with AND.

  • Date range: ?f_first_message=2026-01-01&op_first_message=after&f_first_message=2026-02-01&op_first_message=before

The f_ and op_ lists are zipped positionally, so the nth value pairs with the nth operator. Anything reading or writing these params must therefore use list semantics — getlist/append, never get/set, which would keep just one of the two and silently widen the filter. Both sides of the wire have a single source of truth for this:

Side Module Read Write
Python apps/web/dynamic_filters/datastructures.py FilterParams FilterParams.to_query
JavaScript assets/javascript/filters/wireFormat.js parseFilterParams buildFilterParams / replaceFilterParams

Use those helpers rather than hand-rolling the format. The JS module is covered by assets/javascript/filters/wireFormat.test.js (pnpm test).

Legacy Format (Deprecated)

The old filter_{i}_column / filter_{i}_operator / filter_{i}_value query-string format is deprecated. The UI no longer produces it, and it is rejected when saving new filter sets. However, existing legacy URLs (bookmarks or externally-generated links) keep working on read: FilterParams.from_request transparently translates them to the new f_*/op_* format via convert_saved_filter_data. Stored filter values are migrated to the new format by data migrations.

Example (legacy — auto-translated to the new format on read): - ?filter_0_column=status&filter_0_operator=equals&filter_0_value=active - ?filter_0_column=tags&filter_0_operator=any%20of&filter_0_value=["tag1", "tag2"]

Value Encoding for Multiple Values

For operators that accept multiple values (any of, all of, excludes), values are separated by ~: - Simple values: tag1~tag2~tag3 - Values with special characters: tag1~"tag~2"~tag3 (quoted using CSV formatting)

FilterParams and ColumnFilterData

The FilterParams class extracts filter parameters from request query parameters and organizes them into ColumnFilterData objects. Each ColumnFilterData contains the column name, operator, and value for a single filter.

The same column may appear more than once — a date range is expressed as after X plus before Y on the same column — so FilterParams keeps a flat, ordered list of filters rather than one entry per column. Use FilterParams.get_all(column) to retrieve every filter for a column.

Column Filter

The ColumnFilter class acts as a bridge between query parameters and ORM filters. Each filter defines a query_param attribute that corresponds to the column name in the query parameters. When a request contains an f_<query_param> value (paired with an op_<query_param> operator) matching this query_param, the filter processes the associated operator and value to generate the appropriate database query.

The ColumnFilter.apply() method: 1. Retrieves every ColumnFilterData for its query_param from FilterParams 2. For each one, converts the operator to a method name (e.g., "starts with" → apply_starts_with) 3. Calls the appropriate apply_* method with the parsed value, narrowing the queryset further each time (filters on the same column combine with AND)

Available Filter Types

The dynamic filter system provides several filter types that implements common filtering patterns:

StringColumnFilter

Provides methods for string-based filtering operations: - apply_equals() - Exact match - apply_contains() - Case-insensitive contains - apply_does_not_contain() - Case-insensitive exclusion - apply_starts_with() - Case-insensitive starts with - apply_ends_with() - Case-insensitive ends with - apply_any_of() - Match any value from a JSON list

Requires setting a columns class variable with a list of database field paths. When multiple columns are provided, the filter automatically applies OR logic across all specified fields.

Examples:

# Single column search
StringColumnFilter(columns=["name"], query_param="name", label="Name")

# Multiple columns with OR logic (searches identifier OR name)
StringColumnFilter(columns=["identifier", "name"], query_param="participant", label="Participant")

ChoiceColumnFilter

Provides methods for choice-based filtering operations: - apply_any_of() - Match any value from a list - apply_all_of() - Match all values from a list (AND logic) - apply_excludes() - Exclude all values from a list

Requires setting a column class variable with the database field path.

Available Operators

The system supports the following operators defined in the Operators enum:

  • String operations: equals, contains, does not contain, starts with, ends with, any of
  • Date/time operations: on, before, after, range
  • Choice operations: any of, all of, excludes

Operators are configured in the ColumnFilter class. They are determined automatically based on the filter type but can be overridden.

Step-by-Step Walkthrough: Creating a Product Inventory Filter

This walkthrough will guide you through creating a complete filtering system for a hypothetical product inventory feature. We'll create a custom filter column, integrate it into a multi-column filter, and wire it up to a view.

Step 1: Create a Custom Column Filter

First, let's create a filter for product categories using the existing filter types:

# apps/inventory/filters.py
from apps.web.dynamic_filters.base import ChoiceColumnFilter, StringColumnFilter
from apps.web.dynamic_filters.column_filters import TimestampFilter


class ProductCategoryFilter(ChoiceColumnFilter):
    """Filter products by category name."""

    query_param: str = "category"
    column: str = "category__name"  # Database field path
    label: str = "Category"

    def prepare(self, team, **kwargs):
        self.options = [{"id": cat.id, "label": cat.name} for cat in Category.objects.filter(team=team).all()]


p_filter = ProductCategoryFilter()

# Alternately, you can construct it directly using kwargs:

p_filter = ChoiceColumnFilter(label="Category", query_param="category", column="category__name", options=[...])

Step 2: Create a Multi-Column Filter

Now let's create a multi-column filter that combines our new category filter with existing filters:

# apps/inventory/filters.py (continued)
from typing import ClassVar
from collections.abc import Sequence
from apps.web.dynamic_filters.base import MultiColumnFilter


class ProductInventoryFilter(MultiColumnFilter):
    """Filter for product inventory using multiple column filters."""

    filters: ClassVar[Sequence[ColumnFilter]] = [
        ProductCategoryFilter(),
        TimestampFilter(label="Created At", column="created_at", query_param="created_date"),
        TimestampFilter(label="Updated At", column="updated_at", query_param="last_updated"),
        # Example: Search category name or description with OR logic
        StringColumnFilter(
            label="Category",
            columns=["category__name", "category__description"],  # Searches both fields with OR logic
            query_param="category_search",
        ),
    ]

    def prepare_queryset(self, queryset):
        """Prepare the queryset with any necessary annotations or select_related calls."""
        return queryset.select_related("category")

Step 3: Create the Model and Table (for completeness)

# apps/inventory/models.py
from django.db import models


class Category(models.Model):
    name = models.CharField(max_length=100)
    description = models.TextField(blank=True)


class Product(models.Model):
    name = models.CharField(max_length=200)
    category = models.ForeignKey(Category, on_delete=models.CASCADE)
    price = models.DecimalField(max_digits=10, decimal_places=2)
    stock_quantity = models.IntegerField()
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
# apps/inventory/tables.py
import django_tables2 as tables
from .models import Product


class ProductTable(tables.Table):
    class Meta:
        model = Product
        fields = ("name", "category", "price", "stock_quantity", "created_at", "updated_at")
        attrs = {"class": "table table-striped"}

Step 4: Create the View

Create a view that uses our new filter system:

# apps/inventory/views.py
from django.views.generic import TemplateView
from django_tables2 import SingleTableView
from apps.web.dynamic_filters.datastructures import FilterParams
from apps.experiments.filters import get_filter_context_data
from .models import Product
from .tables import ProductTable
from .filters import ProductInventoryFilter


class ProductInventoryView(SingleTableView):
    """View for displaying filtered product inventory."""

    model = Product
    table_class = ProductTable
    template_name = "inventory/product_list.html"

    def get_queryset(self):
        """Apply filters to the queryset."""
        queryset = super().get_queryset()

        # Create filter instance and apply it
        product_filter = ProductInventoryFilter()
        timezone = self.request.session.get("detected_tz")

        filter_params = FilterParams.from_request(self.request)
        return product_filter.apply(queryset, filter_params, timezone)

    def get_context_data(self, **kwargs):
        """Add filter configuration to the template context."""
        context = super().get_context_data(**kwargs)

        # Add filter context data using the helper function
        filter_context = get_filter_context_data(
            team=self.request.team,  # Assuming team is available in request
            columns=ProductInventoryFilter.columns(self.request.team),
            filter_class=ProductInventoryFilter,
            table_url=reverse("inventory:product_table"),  # Your HTMX table URL
            table_container_id="product-table",
            table_type="your-table-type",
        )

        context.update(filter_context)
        return context

Step 5: Create the Template and Update Filters

Create the template that includes the filter interface:

<!-- apps/inventory/templates/inventory/product_list.html -->
{% extends "base.html" %}
{% load django_tables2 %}

{% block title %}Product Inventory{% endblock title %}

{% block content %}
<div class="container mx-auto px-4 py-8">
    <h1 class="text-2xl font-bold mb-6">Product Inventory</h1>
    <!-- Include the dynamic filters -->
    {% include "experiments/filters.html" with df_table_type="your-table-type" %}
    <!-- Rest of the page -->
</div>

{% endblock content %}