Musa Yılmaz
·5 min read

Turning Django Admin Into a CMS Your Client Can Actually Use

djangopythonadmincms

One of the most expensive decisions on a client project is deciding to build a content management panel from scratch. Weeks disappear, and what you end up with does roughly what Django's built-in admin already did.

In most cases what you actually need isn't a new panel — it's the existing admin made comfortable for someone non-technical. This post covers the concrete settings that get you there.

Making the list view readable

By default the admin renders each row using __str__. Fine for ten records, unusable for thousands.

from django.contrib import admin
 
@admin.register(Listing)
class ListingAdmin(admin.ModelAdmin):
    list_display = ("title", "city", "price", "is_published", "updated_at")
    list_filter = ("is_published", "city", "category")
    search_fields = ("title", "description")
    ordering = ("-updated_at",)
    list_per_page = 50

Those five lines turn a pile of records into a workable table. list_filter adds a filter sidebar, search_fields adds a search box.

One caveat: piling fields into search_fields builds an OR chain on every search, which gets slow on large tables. Include only what people actually search by.

Editing straight from the list

The most frequent action for a content team is usually flipping one field — unpublishing a listing, say. Instead of opening the detail page every time:

list_editable = ("is_published",)

Now they toggle it right in the list. Fields in list_editable must also appear in list_display, and cannot be the first column.

Bulk actions

Some tasks are too repetitive to do one at a time. A custom admin action solves it:

@admin.action(description="Publish selected records")
def publish_selected(modeladmin, request, queryset):
    updated = queryset.update(is_published=True)
    modeladmin.message_user(request, f"{updated} records published.")
 
class ListingAdmin(admin.ModelAdmin):
    actions = [publish_selected]

queryset.update() runs as a single query — far faster than looping and calling save(). But note: update() does not fire model signals. If you rely on a save signal (cache invalidation, for instance), you need the loop or an explicit call.

Splitting the form into sections

A model with twenty fields renders as a flat list of twenty rows, with no indication of what relates to what:

fieldsets = (
    ("Basics", {
        "fields": ("title", "slug", "category"),
    }),
    ("Pricing", {
        "fields": ("price", "currency"),
    }),
    ("SEO", {
        "fields": ("meta_title", "meta_description"),
        "classes": ("collapse",),
        "description": "Leave blank to generate from the title and description.",
    }),
)

The collapse class starts a section folded, hiding fields people rarely touch. The description answers "do I have to fill this in?" before anyone asks.

Managing a listing's photos from a separate menu is tedious. Inlines fix that:

class ListingImageInline(admin.TabularInline):
    model = ListingImage
    extra = 1
    fields = ("image", "alt_text", "order")
 
class ListingAdmin(admin.ModelAdmin):
    inlines = [ListingImageInline]

TabularInline gives a compact table; StackedInline renders each record as a stacked form. Few fields favor tabular, many favor stacked.

Managing multilingual content

When content needs to exist in fifteen languages, adding a field per language bloats the model. The common approach is a separate translation model attached via an inline:

class RouteTranslation(models.Model):
    route = models.ForeignKey("Route", on_delete=models.CASCADE,
                              related_name="translations")
    language = models.CharField(max_length=5, choices=LANGUAGES)
    title = models.CharField(max_length=200)
    description = models.TextField(blank=True)
 
    class Meta:
        unique_together = ("route", "language")
 
 
class RouteTranslationInline(admin.TabularInline):
    model = RouteTranslation
    extra = 0

unique_together prevents the same language being added twice at the database level. Editors see and edit every language from one screen.

Validating before save

Content editors won't remember every rule. Encoding the rule in the model turns it into an automatic admin error:

from django.core.exceptions import ValidationError
 
class Route(models.Model):
    price_min = models.DecimalField(max_digits=10, decimal_places=2)
    price_max = models.DecimalField(max_digits=10, decimal_places=2)
 
    def clean(self):
        if self.price_max < self.price_min:
            raise ValidationError({
                "price_max": "Maximum price cannot be lower than the minimum.",
            })

Raising with a dict makes the admin show the message under the relevant field. Raise a bare string and it appears at the top of the form, leaving the user to hunt for what to fix.

Making publication state scannable

In a system with draft/published states, colouring the status makes the list scannable:

from django.utils.html import format_html
 
@admin.display(description="Status")
def status_badge(self, obj):
    color = "#0d7d72" if obj.is_published else "#94a3b8"
    label = "Published" if obj.is_published else "Draft"
    return format_html(
        '<span style="color:{};font-weight:600">{}</span>', color, label
    )

Use format_html rather than string concatenation — it escapes interpolated values and closes off an XSS vector.

Don't skip permissions

Content editors shouldn't be able to delete users or change settings. Django's group system covers this: create a "Content Editor" group and grant only add, change, and view on the relevant models. Withholding delete is the right default in most setups — deleted content doesn't come back.

Moving the admin off its default URL also filters out most automated scanners:

# config/urls.py
urlpatterns = [
    path("<unguessable-path>/", admin.site.urls),
]

Remember this is not a real security layer — strong passwords, two-factor authentication, and correct ALLOWED_HOSTS are. Changing the path only reduces noise.

When it isn't enough

Django admin doesn't cover everything. You may need a custom interface when:

  • Users need drag-and-drop ordering
  • Several models must be edited together in a complex flow
  • The panel will be exposed to end users, not staff

Outside those cases, the settings above cover most content management needs and save you weeks of development.