Skip to content

Search

Added in: Milestone 2 (Phase 3)

Django-admin-deux provides built-in search functionality with automatic PostgreSQL full-text search detection and fallback to generic icontains-based search.

Overview

Search in django-admin-deux: - Database-agnostic: Works with any Django-supported database - Smart PostgreSQL detection: Uses full-text search when available - Django admin pattern: Splits query into words with AND/OR logic - Related field support: Search across related models - Case-insensitive: Partial matching for user-friendly search

Configuration

Enable search by setting search_fields on your ModelAdmin:

from djadmin import ModelAdmin, register

@register(Product)
class ProductAdmin(ModelAdmin):
    # Basic search across local fields
    search_fields = ['name', 'sku', 'description']

Use Django's double-underscore notation to search related fields:

@register(Product)
class ProductAdmin(ModelAdmin):
    search_fields = [
        'name',
        'sku',
        'description',
        'category__name',      # Search category name
        'tags__name',          # Search tag names
        'manufacturer__company_name',  # Nested relation
    ]

Search Behavior

Word Splitting

Search queries are split into words following Django admin's pattern:

# User searches: "laptop computer"
# Splits into: ["laptop", "computer"]
# Each word must match at least one field (OR)
# All words must be found (AND)

Example:

search_fields = ['name', 'description', 'category__name']
query = "laptop computer"

# Finds products where:
# ("laptop" in name OR "laptop" in description OR "laptop" in category.name)
# AND
# ("computer" in name OR "computer" in description OR "computer" in category.name)

Case-Insensitive Matching

All searches are case-insensitive using icontains (generic) or SearchQuery (PostgreSQL):

# These all match "MacBook Pro"
"macbook pro"
"MACBOOK PRO"
"MacBook Pro"
"mAcBoOk pRo"

Partial Matching

Searches use substring matching (contains, not startswith):

# search_fields = ['name']
# Product: name="MacBook Pro 16-inch"

"MacBook"     # ✅ Matches
"Pro"         # ✅ Matches
"16"          # ✅ Matches
"book Pro"    # ✅ Matches
"mac pro"     # ✅ Matches (two words, both found)
"windows"     # ❌ No match

When using PostgreSQL, django-admin-deux automatically uses full-text search for better performance and relevance.

Automatic Detection

Database detection happens at query time:

# djadmin/plugins/core/mixins.py (simplified)
from django.db import connection

def apply_search(self, queryset):
    if connection.vendor == 'postgresql':
        return self._apply_search_postgresql(queryset)
    else:
        return self._apply_search_generic(queryset)

PostgreSQL Implementation

Uses SearchVector and SearchQuery for full-text search:

from django.contrib.postgres.search import SearchVector, SearchQuery
from django.db.models import Q

# Combine all search_fields into a SearchVector
search_vector = SearchVector('name') + SearchVector('description')

# Create SearchQuery from user input
search_query = SearchQuery(query)

# Filter queryset
queryset = queryset.annotate(
    search=search_vector
).filter(search=search_query)

Benefits: - Faster than icontains on large datasets - Better relevance ranking - Stemming support (finds "running" when searching "run") - Stop word handling

For non-PostgreSQL databases (MySQL, SQLite, etc.), the system uses Q objects:

from django.db.models import Q

words = query.split()
search_q = Q()

for word in words:
    word_q = Q()
    for field in search_fields:
        word_q |= Q(**{f'{field}__icontains': word})
    search_q &= word_q

queryset = queryset.filter(search_q)

UI Integration

Search Widget

When search_fields is configured, a search widget appears at the top of the sidebar:

{# djadmin/templates/djadmin/includes/search_widget.html #}
<h3>Search</h3>
<form method="get" action="">
    {% load djadmin_tags %}
    {% query_params_as_hidden_inputs 'search' 'page' %}
    <div class="search-input-wrapper">
        <input type="search"
               name="search"
               value="{{ request.GET.search|default:'' }}"
               placeholder="Search..."
               class="search-input">
        <button type="submit" class="search-button" title="Search">
            {% include "djadmin/icons/magnifying-glass.html" %}
            <span class="sr-only">Search</span>
        </button>
    </div>
    <div class="search-actions">
        {% if request.GET.search %}
            <a href="{% querystring search=None page=None %}" class="clear-button">
                Clear
            </a>
        {% endif %}
    </div>
</form>

Query Parameter Preservation

Search preserves other query parameters (filters, ordering):

# Before search
/djadmin/webshop/product/?category=1&ordering=-price

# After searching for "laptop"
/djadmin/webshop/product/?category=1&ordering=-price&search=laptop

This is handled by the query_params_as_hidden_inputs template tag.

URL Parameter

Search uses the search query parameter:

# No search
/djadmin/webshop/product/

# Single word
/djadmin/webshop/product/?search=laptop

# Multiple words (URL encoded)
/djadmin/webshop/product/?search=laptop+computer

# With other parameters
/djadmin/webshop/product/?search=laptop&category=1&ordering=price

Advanced Examples

Search with Annotations

Search across computed fields using annotations:

from django.db.models import Value
from django.db.models.functions import Concat

@register(Person)
class PersonAdmin(ModelAdmin):
    def get_queryset(self, request):
        qs = super().get_queryset(request)
        # Annotate full_name for searching
        return qs.annotate(
            full_name=Concat('first_name', Value(' '), 'last_name')
        )

    search_fields = [
        'full_name',  # Search the annotated field
        'email',
        'phone',
    ]

For models with custom database routing:

@register(Product)
class ProductAdmin(ModelAdmin):
    search_fields = ['name', 'sku']

    def get_queryset(self, request):
        # SearchMixin will detect the database vendor
        # from the queryset's connection
        return Product.objects.using('analytics_db').all()

Search with Permissions

Combine search with permission filtering:

@register(Document)
class DocumentAdmin(ModelAdmin):
    search_fields = ['title', 'content', 'author__username']

    def get_queryset(self, request):
        qs = super().get_queryset(request)

        # Filter by user permissions
        if not request.user.is_superuser:
            qs = qs.filter(
                Q(owner=request.user) | Q(is_public=True)
            )

        return qs

Performance Considerations

PostgreSQL Tips

  1. Add GIN indexes for better full-text search performance:
from django.db import models
from django.contrib.postgres.indexes import GinIndex

class Product(models.Model):
    name = models.CharField(max_length=200)
    description = models.TextField()

    class Meta:
        indexes = [
            GinIndex(fields=['name', 'description']),
        ]
  1. Use SearchVectorField for pre-computed search vectors:
from django.contrib.postgres.search import SearchVectorField

class Product(models.Model):
    # ... other fields ...
    search_vector = SearchVectorField(null=True)

    class Meta:
        indexes = [
            GinIndex(fields=['search_vector']),
        ]

Generic Database Tips

  1. Add indexes on frequently searched fields:
class Product(models.Model):
    name = models.CharField(max_length=200, db_index=True)
    sku = models.CharField(max_length=50, db_index=True)
  1. Limit search_fields to essential fields only:
# Good - Only essential fields
search_fields = ['name', 'sku']

# Avoid - Too many fields, including large text
search_fields = ['name', 'sku', 'description', 'notes', 'meta_data']
  1. Use select_related/prefetch_related for related field searches:
@register(Product)
class ProductAdmin(ModelAdmin):
    search_fields = ['name', 'category__name']

    def get_queryset(self, request):
        return super().get_queryset(request).select_related('category')

Testing

Test search functionality using the DynamicURLConf pattern:

from django.test import TestCase, override_settings
from django.urls import clear_url_caches
from djadmin import ModelAdmin, site
from myapp.models import Product

class DynamicURLConf:
    @property
    def urlpatterns(self):
        from django.urls import path, include
        return [path('djadmin/', include(site.urls))]

@override_settings(ROOT_URLCONF=DynamicURLConf())
class TestProductSearch(TestCase):
    def setUp(self):
        if Product in site._registry:
            site.unregister(Product)

        self.laptop = Product.objects.create(name='MacBook Pro')
        self.phone = Product.objects.create(name='iPhone 15')

    def tearDown(self):
        if Product in site._registry:
            site.unregister(Product)
        clear_url_caches()

    def test_search(self):
        class ProductAdmin(ModelAdmin):
            search_fields = ['name']

        site.register(Product, ProductAdmin, override=True)
        url = site.reverse('myapp_product_list')

        response = self.client.get(url, {'search': 'macbook'})

        self.assertEqual(len(response.context['object_list']), 1)
        self.assertEqual(response.context['object_list'][0], self.laptop)

See Also