Skip to content
Back to skills

Django Admin Customization

ASecurity

Customize Django admin: ModelAdmin, inline models, custom actions, list_display, list_filter, search, admin site customization, and custom views. Use when the user configures Django admin, asks about admin customization, registers models, or needs admin-side data management features. Trigger when you see bare admin.site.register() calls without ModelAdmin classes.

  • 4 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added May 27, 2026
data-aipythongodjango

Security analysis

A100/100

Scanned May 27, 2026

npx -y skills add VersoXBT/claude-initial-setup --skill django-admin-customization --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Django Admin Customization?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Django Admin Customization
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/versoxbt-django-admin-customization/badge)](https://www.skillsdirectory.com/skills/versoxbt-django-admin-customization)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: django-admin-customization
description: >
  Customize Django admin: ModelAdmin, inline models, custom actions, list_display,
  list_filter, search, admin site customization, and custom views. Use when the user
  configures Django admin, asks about admin customization, registers models, or needs
  admin-side data management features. Trigger when you see bare admin.site.register()
  calls without ModelAdmin classes.
---

# Django Admin Customization

Build powerful admin interfaces with Django's built-in admin. A well-configured admin
panel eliminates the need for custom CRUD dashboards and gives non-technical users
a safe way to manage data.

## When to Use
- User registers Django models in admin
- User needs custom list views, filters, or search
- User asks about inline editing or custom actions
- User wants to customize the admin site appearance
- User needs admin-only business operations (bulk actions, exports)

## Core Patterns

### ModelAdmin Configuration

```python
from django.contrib import admin
from django.utils.html import format_html

@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    # List view configuration
    list_display = [
        "title", "author_name", "category", "status",
        "view_count", "colored_status", "created_at",
    ]
    list_display_links = ["title"]
    list_editable = ["status", "category"]
    list_filter = ["status", "category", "created_at"]
    list_per_page = 25
    list_select_related = ["author", "category"]

    # Search
    search_fields = ["title", "body", "author__username", "author__email"]
    search_help_text = "Search by title, body, or author"

    # Detail view
    readonly_fields = ["slug", "view_count", "created_at", "updated_at"]
    prepopulated_fields = {"slug": ("title",)}
    autocomplete_fields = ["author", "category"]
    filter_horizontal = ["tags"]

    # Fieldsets -- organize detail view into sections
    fieldsets = [
        (None, {
            "fields": ["title", "slug", "body"],
        }),
        ("Classification", {
            "fields": ["category", "tags", "status"],
        }),
        ("Metadata", {
            "classes": ["collapse"],  # Collapsible section
            "fields": ["author", "view_count", "created_at", "updated_at"],
        }),
    ]

    # Date hierarchy for drill-down navigation
    date_hierarchy = "created_at"

    # Ordering
    ordering = ["-created_at"]

    @admin.display(description="Author", ordering="author__last_name")
    def author_name(self, obj):
        return obj.author.get_full_name()

    @admin.display(description="Status")
    def colored_status(self, obj):
        colors = {"draft": "gray", "published": "green", "archived": "red"}
        color = colors.get(obj.status, "black")
        return format_html(
            '<span style="color: {};">{}</span>',
            color,
            obj.get_status_display(),
        )
```

### Inline Models

Edit related objects directly on the parent's admin page.

```python
class CommentInline(admin.TabularInline):
    model = Comment
    extra = 0  # No empty forms by default
    readonly_fields = ["author", "created_at"]
    fields = ["author", "text", "is_approved", "created_at"]

    def has_add_permission(self, request, obj=None):
        return False  # Comments are created by users, not admins

class ImageInline(admin.StackedInline):
    model = ArticleImage
    extra = 1
    max_num = 10

@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    inlines = [ImageInline, CommentInline]
    list_display = ["title", "status", "comment_count"]

    def get_queryset(self, request):
        return (
            super().get_queryset(request)
            .select_related("author", "category")
            .annotate(comment_count=Count("comments"))
        )

    @admin.display(description="Comments", ordering="comment_count")
    def comment_count(self, obj):
        return obj.comment_count
```

### Custom Actions

Bulk operations on selected objects from the list view.

```python
from django.contrib import messages
from django.http import HttpResponse
import csv

@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    actions = ["publish_selected", "archive_selected", "export_as_csv"]

    @admin.action(description="Publish selected articles")
    def publish_selected(self, request, queryset):
        updated = queryset.filter(status="draft").update(status="published")
        self.message_user(
            request,
            f"{updated} article(s) published.",
            messages.SUCCESS,
        )

    @admin.action(description="Archive selected articles")
    def archive_selected(self, request, queryset):
        updated = queryset.exclude(status="archived").update(status="archived")
        self.message_user(request, f"{updated} article(s) archived.")

    @admin.action(description="Export selected as CSV")
    def export_as_csv(self, request, queryset):
        response = HttpResponse(content_type="text/csv")
        response["Content-Disposition"] = 'attachment; filename="articles.csv"'

        writer = csv.writer(response)
        writer.writerow(["Title", "Author", "Status", "Views", "Created"])

        for article in queryset.select_related("author"):
            writer.writerow([
                article.title,
                article.author.get_full_name(),
                article.status,
                article.view_count,
                article.created_at.isoformat(),
            ])

        return response
```

### Custom List Filters

```python
from django.utils import timezone
from datetime import timedelta

class ViewCountRangeFilter(admin.SimpleListFilter):
    title = "view count"
    parameter_name = "views"

    def lookups(self, request, model_admin):
        return [
            ("low", "Low (< 100)"),
            ("medium", "Medium (100-1000)"),
            ("high", "High (> 1000)"),
        ]

    def queryset(self, request, queryset):
        if self.value() == "low":
            return queryset.filter(view_count__lt=100)
        if self.value() == "medium":
            return queryset.filter(view_count__gte=100, view_count__lte=1000)
        if self.value() == "high":
            return queryset.filter(view_count__gt=1000)
        return queryset

class RecentFilter(admin.SimpleListFilter):
    title = "recency"
    parameter_name = "recent"

    def lookups(self, request, model_admin):
        return [
            ("today", "Today"),
            ("week", "This week"),
            ("month", "This month"),
        ]

    def queryset(self, request, queryset):
        now = timezone.now()
        ranges = {
            "today": now - timedelta(days=1),
            "week": now - timedelta(weeks=1),
            "month": now - timedelta(days=30),
        }
        if self.value() in ranges:
            return queryset.filter(created_at__gte=ranges[self.value()])
        return queryset

@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    list_filter = ["status", "category", ViewCountRangeFilter, RecentFilter]
```

### Admin Site Customization

```python
# admin.py -- customize the default admin site
admin.site.site_header = "My Project Admin"
admin.site.site_title = "My Project"
admin.site.index_title = "Dashboard"

# Custom admin site for multi-tenant or separate admin panels
class AnalyticsAdminSite(admin.AdminSite):
    site_header = "Analytics Dashboard"
    site_title = "Analytics"

    def has_permission(self, request):
        return request.user.is_active and request.user.groups.filter(
            name="analytics"
        ).exists()

analytics_admin = AnalyticsAdminSite(name="analytics_admin")
analytics_admin.register(PageView, PageViewAdmin)

# urls.py
urlpatterns = [
    path("admin/", admin.site.urls),
    path("analytics/", analytics_admin.urls),
]
```

### Overriding Save and Permissions

```python
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    def save_model(self, request, obj, form, change):
        if not change:  # Creating new object
            obj.author = request.user
        super().save_model(request, obj, form, change)

    def get_queryset(self, request):
        qs = super().get_queryset(request)
        if request.user.is_superuser:
            return qs
        return qs.filter(author=request.user)

    def has_change_permission(self, request, obj=None):
        if obj is None:
            return True
        return obj.author == request.user or request.user.is_superuser

    def has_delete_permission(self, request, obj=None):
        return request.user.is_superuser

    def get_readonly_fields(self, request, obj=None):
        if not request.user.is_superuser:
            return self.readonly_fields + ["status", "author"]
        return self.readonly_fields
```

## Anti-Patterns

- **Bare `admin.site.register(Model)`**: Always create a ModelAdmin class. Even a
  minimal one with `list_display` and `search_fields` vastly improves usability.
- **No `list_select_related`**: Admin list views trigger N+1 queries when displaying
  related fields. Always set `list_select_related` or override `get_queryset`.
- **Business logic in admin actions**: Admin actions should call service functions,
  not contain business logic directly. Keep actions thin.
- **No search fields**: Users will need to find records. Always configure
  `search_fields` on models with more than a handful of records.
- **Using `list_editable` without caution**: Editable list fields bypass normal form
  validation. Use sparingly and only for simple status-like fields.

## Quick Reference

| Feature | Configuration |
|---|---|
| Columns | `list_display = ["field1", "field2"]` |
| Editable in list | `list_editable = ["status"]` |
| Sidebar filters | `list_filter = ["status", CustomFilter]` |
| Search | `search_fields = ["title", "author__name"]` |
| Auto-slug | `prepopulated_fields = {"slug": ("title",)}` |
| Sections | `fieldsets = [(name, {"fields": [...]})]` |
| Inline editing | `inlines = [CommentInline]` |
| Bulk actions | `actions = ["my_action"]` |
| Date drill-down | `date_hierarchy = "created_at"` |
| FK autocomplete | `autocomplete_fields = ["author"]` |
| M2M widget | `filter_horizontal = ["tags"]` |
| Per-page count | `list_per_page = 25` |

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.