Skip to content
Skillv1.0.0

django-admin

Django Admin pitfalls - save_formset, get_search_results, get_formset, admin queryset optimization, db_index

by CodeAtCode(0) 0 installs
Free
Sign in to install

Free account. Installing gives you the manifest plus copy-paste snippets.

See reviews

About

Imported from CodeAtCode/oss-ai-skills (frameworks/django-admin/SKILL.md). Install upstream with npx skills add CodeAtCode/oss-ai-skills --skill django-admin. Copyright stays with the author.

Django Admin

Specialized guide for Django Admin customization - save hooks, formsets, queryset optimization, and security considerations.

Overview

Django Admin provides a powerful interface for managing data, but customizing it requires understanding several critical patterns:

  • Save Hooks - save_model, save_formset order and tuple shapes
  • Search Validation - get_search_results field validation
  • Formset Restrictions - get_formset per-role child filtering
  • Queryset Optimization - select_related, prefetch_related, annotate
  • Indexing Strategy - db_index on filter/search fields
  • Audit Logging - Tracking changes via formset hooks

Security Note: Admin customization touches authorization boundaries. For comprehensive security patterns (CSRF, authentication, permissions), see the main Django skill.

Common Admin Pitfalls

Pitfall Impact Fix
.count() in list_display N+1 queries Use annotate()
Wrong save_formset shape Lost change tracking Expect [(obj, dict)]
save_model after save_formset FK violations Order is fixed: parent first
Unvalidated search fields SQL injection risk Validate against search_fields
No get_formset filtering Data leakage Filter queryset by role

Save Hooks

save_formset Tuple Shape

Django admin formsets return a specific tuple shape in save_formset:

from django.contrib import admin
from myapp.models import Parent, Child

@admin.register(Parent)
class ParentAdmin(admin.ModelAdmin):
    inlines = [ChildInline]
    
    def save_formset(self, request, parent, formset, save_model_kwargs):
        """Handle inline child objects.
        
        formset.changed_objects = [(instance, {field: old_value}), ...]
        formset.new_objects = [(instance,), ...]  # Bare tuples
        formset.deleted_objects = [instance, ...]  # Bare objects
        """
        changed_objects = []
        
        for obj, changed_data in formset.changed_objects:
            # changed_data is a dict: {'name': 'old_value', 'status': 'old_status'}
            changed_objects.append((obj, changed_data))
            
            # Log the change
            self.log_change(request, obj, changed_data)
        
        # Process new objects (bare tuples, no changed_data)
        for new_obj in formset.new_objects:
            # new_obj is just (instance,) - no change tracking
            pass
        
        # Process deleted objects (bare instances)
        for deleted_obj in formset.deleted_objects:
            # deleted_obj is just the instance
            pass

Critical distinction:

  • changed_objects[(obj, {field: old_value})] - dict with old values
  • new_objects[(obj,)] - bare tuple, no tracking
  • deleted_objects[obj, ...] - bare instances

Testing save_formset Shape

Always test with real tuple shape - mocks often get this wrong:

from django.test import TestCase
from django.contrib import admin
from myapp.models import Parent, Child

class ParentAdminTest(TestCase):
    def setUp(self):
        self.parent = Parent.objects.create(name='Parent')
        self.child1 = Child.objects.create(parent=self.parent, name='Child 1')
    
    def test_save_formset_tuple_shape(self):
        """save_formset receives [(obj, changed_data)] not bare lists."""
        admin_instance = admin.site._registry[Parent]
        
        # Track what save_formset receives
        received_changes = []
        
        def mock_save_formset(parent, formset, **kwargs):
            # Shape: [(instance, {field: old_value}), ...]
            received_changes.extend(formset.changed_objects)
        
        # Patch and submit
        original_save = admin_instance.save_formset
        admin_instance.save_formset = mock_save_formset
        
        try:
            # Submit form with changed child
            data = {
                'child_set-0-id': self.child1.id,
                'child_set-0-name': 'Updated Child 1',
                'child_set-TOTAL_FORMS': 1,
                'child_set-INITIAL_FORMS': 1,
            }
            self.client.post(
                f'/admin/myapp/parent/{self.parent.id}/change/',
                data
            )
        finally:
            admin_instance.save_formset = original_save
        
        # Verify shape
        assert len(received_changes) == 1
        obj, changed_data = received_changes[0]
        assert obj.id == self.child1.id
        assert 'name' in changed_data
        assert changed_data['name'] == 'Updated Child 1'

Inline Save Order

save_model is called BEFORE save_formset - sync state in formset:

@admin.register(Parent)
class ParentAdmin(admin.ModelAdmin):
    inlines = [ChildInline]
    
    def save_model(self, request, obj, form, change):
        """Parent is saved first."""
        super().save_model(request, obj, form, change)
        # obj.pk is now available
    
    def save_formset(self, request, parent, formset, save_model_kwargs):
        """Children are saved after parent.
        
        Use parent.pk here - it's guaranteed to exist.
        """
        instances = formset.save(commit=False)
        
        for obj in instances:
            # parent.pk is available because save_model ran first
            if not obj.parent_id:
                obj.parent = parent
            # Sync any parent-dependent state
            obj.sync_with_parent(parent)
            obj.save()
        
        formset.save_m2m()  # Save M2M relationships

save_model Immutability Guard

Prevent accidental overwrites in save_model:

@admin.register(Document)
class DocumentAdmin(admin.ModelAdmin):
    def save_model(self, request, obj, form, change):
        """Guard against accidental overwrites."""
        if not change:
            # New object - safe to save
            super().save_model(request, obj, form, change)
            return
        
        # Existing object - check for stale data
        old_obj = self.model.objects.get(pk=obj.pk)
        
        # Check critical fields weren't changed by another user
        if old_obj.version != obj.version:
            messages.warning(
                request,
                f"Document was modified by another user. "
                f"Old version: {old_obj.version}, Your version: {obj.version}"
            )
            # Option 1: Reject save
            # raise ValidationError("Stale data detected")
            
            # Option 2: Force refresh and re-apply changes
            # obj.refresh_from_db()
            # obj.apply_changes(form.cleaned_data)
        
        super().save_model(request, obj, form, change)

Search & Formset Security

get_search_results Field Validation

Validate that get_search_results searches only valid fields:

@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
    search_fields = ['name', 'sku', 'category__name']
    
    def get_search_results(self, request, queryset, search_term):
        """Custom search with field validation.
        
        ⚠️ search_fields must be validated - arbitrary field access!
        """
        # Validate search fields exist
        allowed_fields = {'name', 'sku', 'category__name'}
        
        # Django's search_fields are already validated at admin init
        # But custom search logic needs validation
        
        queryset, use_distinct = super().get_search_results(
            request, queryset, search_term
        )
        
        return queryset, use_distinct

get_formset Per-Role Child Restriction

Restrict inline children based on user role:

@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
    inlines = [TaskInline]
    
    def get_formset(self, request, obj=None, change=False, **kwargs):
        """Restrict visible tasks based on user role."""
        formset = super().get_formset(request, obj, change, **kwargs)
        
        original_queryset = formset.queryset
        
        def restricted_queryset(obj):
            qs = original_queryset(obj)
            
            if request.user.is_superuser:
                return qs
            
            if request.user.has_perm('myapp.view_all_tasks'):
                return qs
            
            # Regular users see only their tasks
            return qs.filter(assigned_to=request.user)
        
        formset.queryset = restricted_queryset
        return formset

Indexing Strategy

db_index on filter/search fields - Critical for admin and API performance:

class Product(models.Model):
    # Add db_index to frequently filtered fields
    sku = models.CharField(max_length=50, db_index=True, unique=True)
    category = models.ForeignKey(Category, db_index=True)
    status = models.CharField(max_length=20, db_index=True)  # Filter by status
    created_at = models.DateTimeField(db_index=True)  # Date range queries
    
    # Composite index for common query patterns
    class Meta:
        indexes = [
            models.Index(fields=['category', 'status']),
            models.Index(fields=['-created_at']),
        ]

When to Add db_index

Field Type Add Index When
ForeignKey Always (Django adds it, but verify)
CharField Used in list_filter, search_fields, or API filters
DateTimeField Used in date range queries or ordering
IntegerField Used in status/type filters

Composite Index Guidelines

class Meta:
    indexes = [
        # Leftmost prefix rule: (a, b) covers queries on (a) but not (b)
        models.Index(fields=['category', 'status']),  # Good for category+status filter
        
        # Single-field descending for common ordering
        models.Index(fields=['-created_at']),  # Good for "newest first"
        
        # Partial index (PostgreSQL) for filtered queries
        # models.Index(
        #     fields=['status'],
        #     condition=Q(status='active')
        # )
    ]

Admin get_queryset Optimization

Prevent N+1 in list display:

@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
    list_display = ['name', 'category', 'status', 'created_at', 'order_count']
    list_filter = ['status', 'category', 'created_at']  # These fields need db_index
    
    def get_queryset(self, request):
        qs = super().get_queryset(request)
        # select_related for ForeignKey (1 query instead of N)
        # prefetch_related for ManyToMany/Reverse FK (2 queries instead of N+1)
        return qs.select_related('category').prefetch_related('tags')
    
    def order_count(self, obj):
        # NEVER use .count() here - it triggers a query per row!
        # Use prefetch_related with Prefetch object instead
        return obj.orders.count() if hasattr(obj, '_prefetched_objects_cache') else 'N/A'
    order_count.short_description = 'Orders'

Per-Row .count() Antipattern

Avoid .count() in admin list_display methods - it triggers N+1 queries:

# BAD - N+1 queries
@admin.register(User)
class UserAdmin(admin.ModelAdmin):
    list_display = ['name', 'post_count']
    
    def post_count(self, obj):
        return obj.posts.count()  # Query per row!

Solution: Annotate once

from django.db.models import Count

class UserAdmin(admin.ModelAdmin):
    def get_queryset(self, request):
        return super().get_queryset(request).annotate(
            post_count=Count('posts')
        )
    
    list_display = ['name', 'post_count']

Prefetch for Complex Aggregations

For more complex aggregations, use Prefetch with filtered querysets:

from django.db.models import Prefetch, Count

@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    list_display = ['customer', 'total_items', 'completed_items']
    
    def get_queryset(self, request):
        return super().get_queryset(request).prefetch_related(
            Prefetch(
                'orderitems',
                queryset=OrderItem.objects.annotate(
                    item_count=Count('id')
                )
            ),
            Prefetch(
                'orderitems',
                queryset=OrderItem.objects.filter(status='completed').annotate(
                    completed_count=Count('id')
                ),
                to_attr='completed_items'
            )
        )
    
    def total_items(self, obj):
        # Uses cached prefetch, no extra query
        return sum(item.quantity for item in obj.orderitems.all())
    
    def completed_items(self, obj):
        # Uses to_attr cache
        return sum(item.quantity for item in obj.completed_items)

Benchmarking Admin Performance

Measure query count to catch N+1 issues:

from django.test import TestCase
from django.contrib.admin import site

class AdminQueryCountTest(TestCase):
    def test_list_view_query_count(self):
        """Admin list view should not exceed N+2 queries."""
        # Create test data
        UserFactory.create_batch(10)
        
        with self.assertNumQueries(12):  # Adjust based on your admin config
            self.client.get('/admin/myapp/user/')


Audit Logging with Formsets

Track changes via save_formset for audit trails:

from django.contrib import admin
from django.utils import timezone
from myapp.models import AuditLog

@admin.register(Parent)
class ParentAdmin(admin.ModelAdmin):
    inlines = [ChildInline]
    
    def save_formset(self, request, parent, formset, save_model_kwargs):
        """Save formset and log all changes."""
        # Capture changes before saving
        changes_to_log = []
        
        for obj, changed_data in formset.changed_objects:
            changes_to_log.append({
                'object': obj,
                'field_changes': changed_data,
                'action': 'change'
            })
        
        for new_obj in formset.new_objects:
            changes_to_log.append({
                'object': new_obj[0],  # Tuple unpacking
                'field_changes': None,
                'action': 'create'
            })
        
        for deleted_obj in formset.deleted_objects:
            changes_to_log.append({
                'object': deleted_obj,
                'field_changes': None,
                'action': 'delete'
            })
        
        # Call parent save first
        super().save_formset(request, parent, formset, save_model_kwargs)
        
        # Log after successful save
        for change in changes_to_log:
            AuditLog.objects.create(
                user=request.user,
                content_type=ContentType.objects.get_for_model(change['object']),
                object_id=change['object'].id,
                action=change['action'],
                field_changes=change['field_changes'],
                timestamp=timezone.now()
            )


Ecosystem Libraries

Complementary libraries that extend Django Admin functionality:

django-admin-action-forms

URL: https://github.com/michalpokusa/django-admin-action-forms

Provides forms for admin actions with user input and validation. Allows creating admin actions that require intermediate input screens before execution.

Usage pattern:

from admin_action_forms import ActionForm

class BulkUpdateForm(ActionForm):
    """Action form with intermediate input screen."""
    new_status = forms.ChoiceField(choices=STATUS_CHOICES)
    
    class Meta:
        model = MyModel
        fields = ['new_status']

# Integrates with django.contrib.admin actions
@admin.register(MyModel)
class MyModelAdmin(admin.ModelAdmin):
    actions = [bulk_update_action]

Key features:

  • Action forms with Form classes for user input
  • Intermediate page before action execution
  • Full integration with django.contrib.admin actions system

django-hijack

URL: https://github.com/django-hijack/django-hijack

Allows administrators to log in as other users without needing their credentials. Useful for debugging user-reported issues and understanding user experiences from their perspective.

django-simple-history

URL: https://github.com/django-commons/django-simple-history

Tracks model history and allows reverting to previous versions directly from the admin. Pairs well with the manual audit logging patterns shown above, providing a drop-in solution for change tracking.

django-easy-audit

URL: https://github.com/soynatan/django-easy-audit

Automatic user action tracking that monitors CRUD events, login/logout, and auth events. An alternative to manual audit logging implementation with minimal configuration required.

django-admin-sortable2

URL: https://github.com/jrief/django-admin-sortable2

Adds drag-and-drop ordering support for model inlines and related objects in the admin interface. Useful for managing ordered relationships without custom JavaScript.

django-admin-inline-paginator-plus

URL: https://github.com/DmytroLitvinov/django-admin-inline-paginator-plus

Adds pagination support for inline formsets in the admin. Particularly relevant when dealing with large numbers of inline objects, addressing challenges with the save_formset patterns discussed earlier.


References

Use it

Copy one of these into your project. Installing also returns the manifest and these snippets.

yaml
targets:
  - https://api.opensmartroute.ai/api/v1/registry/codeatcode-oss-ai-skills-django-admin/manifest   # or paste the manifest below

Manifest

An Open Capability Manifest: the router reads it to know what this does, what it costs and when to pick it.

codeatcode-oss-ai-skills-django-admin.ocm.jsonjson
{
  "ocm": "1",
  "id": "codeatcode-oss-ai-skills-django-admin",
  "kind": "skill",
  "name": "django-admin",
  "description": "Django Admin pitfalls - save_formset, get_search_results, get_formset, admin queryset optimization, db_index",
  "publisher": "CodeAtCode",
  "version": "1.0.0",
  "capabilities": {
    "domains": [
      "general"
    ],
    "tags": [
      "skill-md",
      "python",
      "django",
      "admin",
      "orm",
      "optimization",
      "github"
    ],
    "languages": [
      "en"
    ]
  },
  "quality_prior": 0.6,
  "examples": [
    "Django Admin pitfalls - save_formset, get_search_results, get_formset, admin queryset optimization, db_index"
  ],
  "primary": false,
  "metadata": {
    "source": {
      "provider": "github",
      "repository": "https://github.com/CodeAtCode/oss-ai-skills",
      "path": "frameworks/django-admin/SKILL.md",
      "ref": "69e2a40eb0c0b0526a03daad873deb3d7dbc0792",
      "url": "https://github.com/CodeAtCode/oss-ai-skills/blob/69e2a40eb0c0b0526a03daad873deb3d7dbc0792/frameworks/django-admin/SKILL.md",
      "key": "CodeAtCode/oss-ai-skills/frameworks/django-admin/SKILL.md"
    }
  },
  "instructions": "# Django Admin\n\nSpecialized guide for Django Admin customization - save hooks, formsets, queryset optimization, and security considerations.\n\n## Overview\n\nDjango Admin provides a powerful interface for managing data, but customizing it requires understanding several critical patterns:\n\n- **Save Hooks** - `save_model`, `save_formset` order and tuple shapes\n- **Search Validation** - `get_search_results` field validation\n- **Formset Restrictions** - `get_formset` per-role child filtering\n- **Queryset Optimization** - `select_related`, `prefetch_related`, `annotate`\n- **Indexing Strategy** - `db_i",
  "cost": {
    "context_tokens": 4434
  }
}

Fetch it by URL: GET /api/v1/registry/codeatcode-oss-ai-skills-django-admin/manifest?version=1.0.0

Reviews

Star ratings from people who tried it. One review per account; edit yours any time.

No reviews yet. Install it, try it, and be the first to rate it.