Building a Custom CMS with Django

In today's digital landscape, having a flexible content management system (CMS) tailored to your specific needs can be a game-changer. While there are numerous off-the-shelf solutions like WordPress, Drupal, or Wagtail, building your own CMS with Django offers unparalleled customization and control.

In this comprehensive guide, we'll walk through creating a modern, feature-rich custom CMS using Django in 2025. We'll cover everything from initial setup to advanced features, with clear explanations and practical code examples at each step.

Table of Contents

  1. Introduction and Project Setup

  2. Creating the Core CMS Models

  3. Building the Admin Interface

  4. Content Rendering and Templates

  5. Implementing Media Management

  6. Adding User Authentication and Permissions

  7. Building a RESTful API

  8. Frontend Integration with HTMX

  9. Implementing Search Functionality

  10. Deployment and Performance Optimization

  11. Conclusion

Introduction and Project Setup

Let's start by setting up our Django project. In 2025, Django 5.2 is the latest stable version, bringing numerous improvements and features.

First, let's create a virtual environment and install Django:

# Create and activate a virtual environment
python -m venv venv
source venv/bin/activate  # On Windows: venv\Scripts\activate

# Install Django and other dependencies
pip install django==5.2.0 pillow django-mptt django-ckeditor djangorestframework

Now, let's create our project and core app:

# Create the Django project
django-admin startproject custom_cms

# Navigate to the project directory
cd custom_cms

# Create the core app
python manage.py startapp cms_core

Let's update our settings.py to include our new app and required dependencies:

# custom_cms/settings.py

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    # Third-party apps
    'mptt',  # For handling hierarchical data like categories
    'ckeditor',  # Rich text editor
    'rest_framework',  # For our API

    # Our apps
    'cms_core',
]

# Media and static files settings
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'

STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'static'
STATICFILES_DIRS = [
    BASE_DIR / 'custom_cms' / 'static',
]

# CKEditor configuration
CKEDITOR_UPLOAD_PATH = 'uploads/'
CKEDITOR_CONFIGS = {
    'default': {
        'toolbar': 'Full',
        'height': 300,
        'width': '100%',
    },
}

Let's update our main URL configuration:

# custom_cms/urls.py

from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static

urlpatterns = [
    path('admin/', admin.site.urls),
    path('ckeditor/', include('ckeditor_uploader.urls')),
    path('api/', include('cms_core.api.urls')),
    path('', include('cms_core.urls')),
]

# Serve media files in development
if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

Creating the Core CMS Models

Now, let's design our core CMS models. We'll create a flexible system that can handle various content types.

# cms_core/models.py

from django.db import models
from django.contrib.auth.models import User
from django.utils.text import slugify
from django.urls import reverse
from mptt.models import MPTTModel, TreeForeignKey
from ckeditor_uploader.fields import RichTextUploadingField


class Category(MPTTModel):
    """
    Hierarchical category model using MPTT
    """
    name = models.CharField(max_length=100)
    slug = models.SlugField(unique=True)
    description = models.TextField(blank=True)
    parent = TreeForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children')

    class MPTTMeta:
        order_insertion_by = ['name']

    class Meta:
        verbose_name_plural = 'Categories'

    def __str__(self):
        return self.name

    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.name)
        super().save(*args, **kwargs)


class Tag(models.Model):
    """
    Simple tag model for content categorization
    """
    name = models.CharField(max_length=50)
    slug = models.SlugField(unique=True)

    def __str__(self):
        return self.name

    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.name)
        super().save(*args, **kwargs)


class ContentType(models.Model):
    """
    Content type definition (e.g., Article, Page, Product)
    """
    name = models.CharField(max_length=50)
    slug = models.SlugField(unique=True)
    description = models.TextField(blank=True)
    template = models.CharField(max_length=100, default='cms_core/content_detail.html')

    def __str__(self):
        return self.name


class Content(models.Model):
    """
    Main content model for all types of content
    """
    title = models.CharField(max_length=200)
    slug = models.SlugField(unique=True)
    excerpt = models.TextField(blank=True)
    content = RichTextUploadingField()
    featured_image = models.ImageField(upload_to='content_images/%Y/%m/', blank=True)

    # Meta data
    author = models.ForeignKey(User, on_delete=models.CASCADE)
    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
    categories = models.ManyToManyField(Category, blank=True)
    tags = models.ManyToManyField(Tag, blank=True)

    # Status and dates
    STATUS_CHOICES = (
        ('draft', 'Draft'),
        ('published', 'Published'),
        ('archived', 'Archived'),
    )
    status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='draft')
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    published_at = models.DateTimeField(null=True, blank=True)

    # SEO fields
    meta_title = models.CharField(max_length=100, blank=True)
    meta_description = models.TextField(blank=True)

    class Meta:
        ordering = ['-published_at', '-created_at']
        indexes = [
            models.Index(fields=['slug']),
            models.Index(fields=['status', 'published_at']),
        ]

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return reverse('content_detail', kwargs={'slug': self.slug})

    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.title)
        super().save(*args, **kwargs)


class CustomField(models.Model):
    """
    Custom fields for extending content types
    """
    name = models.CharField(max_length=100)
    slug = models.SlugField()
    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name='custom_fields')

    FIELD_TYPE_CHOICES = (
        ('text', 'Text'),
        ('textarea', 'Text Area'),
        ('number', 'Number'),
        ('boolean', 'Boolean'),
        ('date', 'Date'),
        ('image', 'Image'),
        ('file', 'File'),
    )
    field_type = models.CharField(max_length=20, choices=FIELD_TYPE_CHOICES)
    required = models.BooleanField(default=False)

    def __str__(self):
        return f"{self.name} ({self.content_type.name})"


class CustomFieldValue(models.Model):
    """
    Values for custom fields
    """
    content = models.ForeignKey(Content, on_delete=models.CASCADE, related_name='custom_field_values')
    field = models.ForeignKey(CustomField, on_delete=models.CASCADE)

    # Different value fields based on field type
    text_value = models.CharField(max_length=255, blank=True, null=True)
    textarea_value = models.TextField(blank=True, null=True)
    number_value = models.DecimalField(max_digits=12, decimal_places=2, blank=True, null=True)
    boolean_value = models.BooleanField(blank=True, null=True)
    date_value = models.DateField(blank=True, null=True)
    image_value = models.ImageField(upload_to='custom_fields/images/', blank=True, null=True)
    file_value = models.FileField(upload_to='custom_fields/files/', blank=True, null=True)

    def __str__(self):
        return f"{self.field.name} for {self.content.title}"

Now let's register our models in the admin:

# cms_core/admin.py

from django.contrib import admin
from mptt.admin import MPTTModelAdmin
from .models import Category, Tag, ContentType, Content, CustomField, CustomFieldValue

@admin.register(Category)
class CategoryAdmin(MPTTModelAdmin):
    list_display = ('name', 'slug', 'parent')
    prepopulated_fields = {'slug': ('name',)}
    search_fields = ('name',)

@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
    list_display = ('name', 'slug')
    prepopulated_fields = {'slug': ('name',)}
    search_fields = ('name',)

@admin.register(ContentType)
class ContentTypeAdmin(admin.ModelAdmin):
    list_display = ('name', 'slug', 'template')
    prepopulated_fields = {'slug': ('name',)}
    search_fields = ('name',)

class CustomFieldInline(admin.TabularInline):
    model = CustomField
    extra = 1

class CustomFieldValueInline(admin.StackedInline):
    model = CustomFieldValue
    extra = 1
    fields = ('field', 'text_value', 'textarea_value', 'number_value', 
              'boolean_value', 'date_value', 'image_value', 'file_value')

@admin.register(Content)
class ContentAdmin(admin.ModelAdmin):
    list_display = ('title', 'content_type', 'author', 'status', 'created_at', 'published_at')
    list_filter = ('status', 'content_type', 'categories', 'tags')
    search_fields = ('title', 'excerpt', 'content')
    prepopulated_fields = {'slug': ('title',)}
    inlines = [CustomFieldValueInline]
    date_hierarchy = 'created_at'
    raw_id_fields = ('author',)
    filter_horizontal = ('categories', 'tags')

Let's run migrations to create our database schema:

python manage.py makemigrations
python manage.py migrate

Building the Admin Interface

Django's admin is already powerful, but we'll extend it further with a custom dashboard and improved user experience.

First, let's create a custom admin file:

# cms_core/admin_dashboard.py

from django.contrib import admin
from django.urls import path
from django.shortcuts import render
from django.db.models import Count
from django.contrib.auth.models import User
from .models import Content, Category, Tag

class DashboardAdmin(admin.AdminSite):
    site_header = 'Custom CMS Admin'
    site_title = 'Custom CMS Admin Portal'
    index_title = 'Welcome to Custom CMS'

    def get_urls(self):
        urls = super().get_urls()
        custom_urls = [
            path('dashboard/', self.admin_view(self.dashboard_view), name='dashboard'),
        ]
        return custom_urls + urls

    def dashboard_view(self, request):
        # Content statistics
        content_count = Content.objects.count()
        published_count = Content.objects.filter(status='published').count()
        draft_count = Content.objects.filter(status='draft').count()

        # User statistics
        user_count = User.objects.count()

        # Category statistics
        category_count = Category.objects.count()

        # Recent content
        recent_content = Content.objects.order_by('-created_at')[:5]

        # Content by author
        content_by_author = User.objects.annotate(content_count=Count('content')).order_by('-content_count')[:5]

        context = {
            'content_count': content_count,
            'published_count': published_count,
            'draft_count': draft_count,
            'user_count': user_count,
            'category_count': category_count,
            'recent_content': recent_content,
            'content_by_author': content_by_author,
            **admin.site.each_context(request),
        }

        return render(request, 'admin/dashboard.html', context)

dashboard_admin = DashboardAdmin(name='dashboard')

Now, let's create our dashboard template:

<!-- templates/admin/dashboard.html -->
{% extends "admin/base_site.html" %}
{% load static %}

{% block extrastyle %}
<style>
    .dashboard-container {
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
        grid-gap: 20px;
        margin-top: 20px;
    }
    .dashboard-card {
        background-color: #fff;
        border-radius: 4px;
        box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        padding: 20px;
    }
    .stats-container {
        display: flex;
        justify-content: space-between;
        margin-bottom: 20px;
    }
    .stat-card {
        background-color: #fff;
        border-radius: 4px;
        box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        padding: 15px;
        width: 18%;
        text-align: center;
    }
    .stat-number {
        font-size: 24px;
        font-weight: bold;
        margin: 10px 0;
    }
    .stat-label {
        color: #666;
    }
    .dashboard-title {
        margin-bottom: 20px;
    }
    .content-list {
        list-style: none;
        padding: 0;
    }
    .content-item {
        padding: 10px 0;
        border-bottom: 1px solid #eee;
    }
    .content-item:last-child {
        border-bottom: none;
    }
</style>
{% endblock %}

{% block content %}
<h1 class="dashboard-title">Dashboard</h1>

<div class="stats-container">
    <div class="stat-card">
        <div class="stat-number">{{ content_count }}</div>
        <div class="stat-label">Total Content</div>
    </div>
    <div class="stat-card">
        <div class="stat-number">{{ published_count }}</div>
        <div class="stat-label">Published</div>
    </div>
    <div class="stat-card">
        <div class="stat-number">{{ draft_count }}</div>
        <div class="stat-label">Drafts</div>
    </div>
    <div class="stat-card">
        <div class="stat-number">{{ user_count }}</div>
        <div class="stat-label">Users</div>
    </div>
    <div class="stat-card">
        <div class="stat-number">{{ category_count }}</div>
        <div class="stat-label">Categories</div>
    </div>
</div>

<div class="dashboard-container">
    <div class="dashboard-card">
        <h2>Recent Content</h2>
        <ul class="content-list">
            {% for content in recent_content %}
            <li class="content-item">
                <a href="{% url 'admin:cms_core_content_change' content.id %}">{{ content.title }}</a>
                <br>
                <small>{{ content.status }} - {{ content.created_at|date:"M d, Y" }}</small>
            </li>
            {% empty %}
            <li>No content available</li>
            {% endfor %}
        </ul>
    </div>

    <div class="dashboard-card">
        <h2>Top Contributors</h2>
        <ul class="content-list">
            {% for user in content_by_author %}
            <li class="content-item">
                <strong>{{ user.username }}</strong>
                <br>
                <small>{{ user.content_count }} piece(s) of content</small>
            </li>
            {% empty %}
            <li>No contributors yet</li>
            {% endfor %}
        </ul>
    </div>
</div>
{% endblock %}

Now, let's update our main cms_core/apps.py file:

# cms_core/apps.py

from django.apps import AppConfig
from django.contrib.admin.apps import AdminConfig

class CmsAdminConfig(AdminConfig):
    default_site = 'cms_core.admin_dashboard.DashboardAdmin'

class CmsCoreConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'cms_core'

And update the project's settings to use our custom admin:

# custom_cms/settings.py

INSTALLED_APPS = [
    'cms_core.apps.CmsAdminConfig',  # Replace default admin
    # ... rest of the installed apps
]

Content Rendering and Templates

Now, let's create views and templates to render our content.

# cms_core/views.py

from django.shortcuts import render, get_object_or_404
from django.views.generic import ListView, DetailView
from django.utils import timezone
from .models import Content, Category, Tag

def index(request):
    """Home page view"""
    # Get latest published content
    latest_content = Content.objects.filter(
        status='published',
        published_at__lte=timezone.now()
    ).order_by('-published_at')[:6]

    # Get featured content (you could add a featured field to the model)
    featured_content = Content.objects.filter(
        status='published',
        published_at__lte=timezone.now()
    ).order_by('?')[:3]

    # Get categories with content counts
    categories = Category.objects.all()

    context = {
        'latest_content': latest_content,
        'featured_content': featured_content,
        'categories': categories,
    }

    return render(request, 'cms_core/index.html', context)


class ContentListView(ListView):
    """List view for all content or filtered by category/tag"""
    model = Content
    template_name = 'cms_core/content_list.html'
    context_object_name = 'content_list'
    paginate_by = 10

    def get_queryset(self):
        queryset = Content.objects.filter(
            status='published',
            published_at__lte=timezone.now()
        )

        # Filter by category if provided
        category_slug = self.kwargs.get('category_slug')
        if category_slug:
            category = get_object_or_404(Category, slug=category_slug)
            queryset = queryset.filter(categories=category)

        # Filter by tag if provided
        tag_slug = self.kwargs.get('tag_slug')
        if tag_slug:
            tag = get_object_or_404(Tag, slug=tag_slug)
            queryset = queryset.filter(tags=tag)

        return queryset.order_by('-published_at')

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)

        # Add category or tag to context if filtering
        category_slug = self.kwargs.get('category_slug')
        if category_slug:
            context['category'] = get_object_or_404(Category, slug=category_slug)

        tag_slug = self.kwargs.get('tag_slug')
        if tag_slug:
            context['tag'] = get_object_or_404(Tag, slug=tag_slug)

        return context


class ContentDetailView(DetailView):
    """Detail view for a specific content item"""
    model = Content
    template_name = 'cms_core/content_detail.html'
    context_object_name = 'content'

    def get_queryset(self):
        if self.request.user.is_staff:
            # Staff users can see all content
            return Content.objects.all()

        # Regular users can only see published content
        return Content.objects.filter(
            status='published',
            published_at__lte=timezone.now()
        )

    def get_template_names(self):
        """Use content type's template if available"""
        template_names = super().get_template_names()

        # Add the content-type specific template
        if self.object and self.object.content_type and self.object.content_type.template:
            template_names = [self.object.content_type.template] + template_names

        return template_names

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)

        # Add custom field values
        content = self.object
        custom_field_values = {}

        for value in content.custom_field_values.all():
            field_type = value.field.field_type

            if field_type == 'text':
                field_value = value.text_value
            elif field_type == 'textarea':
                field_value = value.textarea_value
            elif field_type == 'number':
                field_value = value.number_value
            elif field_type == 'boolean':
                field_value = value.boolean_value
            elif field_type == 'date':
                field_value = value.date_value
            elif field_type == 'image':
                field_value = value.image_value
            elif field_type == 'file':
                field_value = value.file_value
            else:
                field_value = None

            custom_field_values[value.field.slug] = {
                'name': value.field.name,
                'value': field_value,
                'type': field_type,
            }

        context['custom_fields'] = custom_field_values

        # Add related content
        if content.categories.exists():
            category = content.categories.first()
            related_content = Content.objects.filter(
                categories=category,
                status='published',
                published_at__lte=timezone.now()
            ).exclude(id=content.id)[:3]
            context['related_content'] = related_content

        return context

Let's set up our URL routes:

# cms_core/urls.py

from django.urls import path
from . import views

urlpatterns = [
    path('', views.index, name='index'),
    path('content/', views.ContentListView.as_view(), name='content_list'),
    path('category/<slug:category_slug>/', views.ContentListView.as_view(), name='category_content'),
    path('tag/<slug:tag_slug>/', views.ContentListView.as_view(), name='tag_content'),
    path('content/<slug:slug>/', views.ContentDetailView.as_view(), name='content_detail'),
]

Now, let's create our templates. First, a base template:

<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}Custom CMS{% endblock %}</title>
    <meta name="description" content="{% block meta_description %}A custom CMS built with Django{% endblock %}">

    <!-- Include Bootstrap CSS -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/css/bootstrap.min.css" rel="stylesheet">

    <!-- Custom CSS -->
    <style>
        .content-card {
            height: 100%;
            transition: transform 0.3s;
        }
        .content-card:hover {
            transform: translateY(-5px);
        }
        .footer {
            margin-top: 3rem;
            padding: 2rem 0;
            background-color: #f8f9fa;
        }
    </style>

    {% block extra_head %}{% endblock %}
</head>
<body>
    <!-- Navigation -->
    <nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
        <div class="container">
            <a class="navbar-brand" href="{% url 'index' %}">Custom CMS</a>
            <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
                <span class="navbar-toggler-icon"></span>
            </button>
            <div class="collapse navbar-collapse" id="navbarNav">
                <ul class="navbar-nav me-auto">
                    <li class="nav-item">
                        <a class="nav-link" href="{% url 'index' %}">Home</a>
                    </li>
                    <li class="nav-item">
                        <a class="nav-link" href="{% url 'content_list' %}">Content</a>
                    </li>
                    {% if request.user.is_staff %}
                    <li class="nav-item">
                        <a class="nav-link" href="{% url 'admin:index' %}">Admin</a>
                    </li>
                    {% endif %}
                </ul>
                <form class="d-flex" action="{% url 'content_list' %}" method="get">
                    <input class="form-control me-2" type="search" placeholder="Search" name="q">
                    <button class="btn btn-outline-light" type="submit">Search</button>
                </form>
            </div>
        </div>
    </nav>

    <!-- Main Content -->
    <main class="container">
        {% block content %}{% endblock %}
    </main>

    <!-- Footer -->
    <footer class="footer mt-auto">
        <div class="container">
            <div class="row">
                <div class="col-md-6">
                    <h5>Custom CMS</h5>
                    <p>A flexible content management system built with Django.</p>
                </div>
                <div class="col-md-6 text-md-end">
                    <p>&copy; {% now "Y" %} Custom CMS. All rights reserved.</p>
                </div>
            </div>
        </div>
    </footer>

    <!-- Bootstrap and JS -->
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/js/bootstrap.bundle.min.js"></script>

    <!-- HTMX -->
    <script src="https://unpkg.com/htmx.org@1.9.6"></script>

    {% block extra_js %}{% endblock %}
</body>
</html>

Now let's create our index template:

<!-- templates/cms_core/index.html -->
{% extends 'base.html' %}

{% block title %}Home - Custom CMS{% endblock %}

{% block content %}
<div class="py-5 text-center">
    <h1 class="display-4">Welcome to Custom CMS</h1>
    <p class="lead">A flexible content management system built with Django.</p>
</div>

<!-- Featured Content -->
{% if featured_content %}
<section class="mb-5">
    <h2 class="mb-4">Featured Content</h2>
    <div class="row">
        {% for content in featured_content %}
        <div class="col-md-4 mb-4">
            <div class="card content-card h-100">
                {% if content.featured_image %}
                <img src="{{ content.featured_image.url }}" class="card-img-top" alt="{{ content.title }}">
                {% endif %}
                <div class="card-body">
                    <h5 class="card-title">{{ content.title }}</h5>
                    <p class="card-text">{{ content.excerpt|truncatewords:20 }}</p>
                </div>
                <div class="card-footer bg-transparent border-top-0">
                    <a href="{{ content.get_absolute_url }}" class="btn btn-primary">Read More</a>
                </div>
            </div>
        </div>
        {% endfor %}
    </div>
</section>
{% endif %}

<!-- Latest Content -->
{% if latest_content %}
<section class="mb-5">
    <h2 class="mb-4">Latest Content</h2>
    <div class="row">
        {% for content in latest_content %}
        <div class="col-md-4 mb-4">
            <div class="card content-card h-100">
                {% if content.featured_image %}
                <img src="{{ content.featured_image.url }}" class="card-img-top" alt="{{ content.title }}">
                {% endif %}
                <div class="card-body">
                    <h5 class="card-title">{{ content.title }}</h5>
                    <p class="card-text small">
                        <span class="text-muted">{{ content.published_at|date:"M d, Y" }}</span>
                        {% if content.categories.all %}
                        | 
                        {% for category in content.categories.all %}
                        <a href="{% url 'category_content' category.slug %}">{{ category.name }}</a>{% if not forloop.last %}, {% endif %}
                        {% endfor %}
                        {% endif %}
                    </p>
                    <p class="card-text">{{ content.excerpt|truncatewords:15 }}</p>
                </div>
                <div class="card-footer bg-transparent border-top-0">
                    <a href="{{ content.get_absolute_url }}" class="btn btn-outline-primary btn-sm">Read More</a>
                </div>
            </div>
        </div>
        {% endfor %}
    </div>
    <div class="text-center mt-4">
        <a href="{% url 'content_list' %}" class="btn btn-primary">View All Content</a>
    </div>
</section>
{% endif %}

<!-- Categories -->
{% if
{% if categories %}
<section class="mb-5">
    <h2 class="mb-4">Browse by Category</h2>
    <div class="row">
        {% for category in categories %}
        <div class="col-lg-3 col-md-4 col-sm-6 mb-4">
            <div class="card content-card h-100">
                <div class="card-body text-center">
                    <h5 class="card-title">{{ category.name }}</h5>
                    <p class="card-text small text-muted">{{ category.description|truncatewords:10 }}</p>
                    <a href="{% url 'category_content' category.slug %}" class="btn btn-outline-secondary btn-sm">Browse</a>
                </div>
            </div>
        </div>
        {% endfor %}
    </div>
</section>
{% endif %}
{% endblock %}

Let's create the content list template:

<!-- templates/cms_core/content_list.html -->
{% extends 'base.html' %}

{% block title %}
{% if category %}{{ category.name }} - 
{% elif tag %}{{ tag.name }} - 
{% endif %}
Content - Custom CMS
{% endblock %}

{% block content %}
<div class="row mb-4">
    <div class="col">
        {% if category %}
        <h1>Category: {{ category.name }}</h1>
        {% if category.description %}
        <p class="lead">{{ category.description }}</p>
        {% endif %}
        {% elif tag %}
        <h1>Tag: {{ tag.name }}</h1>
        {% else %}
        <h1>All Content</h1>
        {% endif %}
    </div>
</div>

<div class="row">
    <!-- Content List -->
    <div class="col-lg-8">
        {% if content_list %}
        <div class="list-group mb-4">
            {% for content in content_list %}
            <div class="list-group-item list-group-item-action flex-column align-items-start mb-3 border rounded">
                <div class="row">
                    {% if content.featured_image %}
                    <div class="col-md-4">
                        <img src="{{ content.featured_image.url }}" alt="{{ content.title }}" class="img-fluid rounded">
                    </div>
                    <div class="col-md-8">
                    {% else %}
                    <div class="col-12">
                    {% endif %}
                        <div class="d-flex w-100 justify-content-between">
                            <h5 class="mb-1">{{ content.title }}</h5>
                            <small>{{ content.published_at|date:"M d, Y" }}</small>
                        </div>
                        <p class="mb-1">{{ content.excerpt|truncatewords:30 }}</p>
                        <div class="mt-2">
                            {% for category in content.categories.all %}
                            <a href="{% url 'category_content' category.slug %}" class="badge bg-secondary text-decoration-none">{{ category.name }}</a>
                            {% endfor %}
                            {% for tag in content.tags.all %}
                            <a href="{% url 'tag_content' tag.slug %}" class="badge bg-light text-dark text-decoration-none">{{ tag.name }}</a>
                            {% endfor %}
                        </div>
                        <div class="mt-3">
                            <a href="{{ content.get_absolute_url }}" class="btn btn-primary btn-sm">Read More</a>
                        </div>
                    </div>
                </div>
            </div>
            {% endfor %}
        </div>

        <!-- Pagination -->
        {% if is_paginated %}
        <nav aria-label="Page navigation">
            <ul class="pagination justify-content-center">
                {% if page_obj.has_previous %}
                <li class="page-item">
                    <a class="page-link" href="?page=1">&laquo; First</a>
                </li>
                <li class="page-item">
                    <a class="page-link" href="?page={{ page_obj.previous_page_number }}">Previous</a>
                </li>
                {% endif %}

                {% for num in page_obj.paginator.page_range %}
                {% if page_obj.number == num %}
                <li class="page-item active">
                    <span class="page-link">{{ num }}</span>
                </li>
                {% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
                <li class="page-item">
                    <a class="page-link" href="?page={{ num }}">{{ num }}</a>
                </li>
                {% endif %}
                {% endfor %}

                {% if page_obj.has_next %}
                <li class="page-item">
                    <a class="page-link" href="?page={{ page_obj.next_page_number }}">Next</a>
                </li>
                <li class="page-item">
                    <a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">Last &raquo;</a>
                </li>
                {% endif %}
            </ul>
        </nav>
        {% endif %}

        {% else %}
        <div class="alert alert-info">
            No content available.
        </div>
        {% endif %}
    </div>

    <!-- Sidebar -->
    <div class="col-lg-4">
        <div class="card mb-4">
            <div class="card-header">Categories</div>
            <div class="card-body">
                <ul class="list-unstyled mb-0">
                    {% for category in categories %}
                    <li><a href="{% url 'category_content' category.slug %}">{{ category.name }}</a></li>
                    {% empty %}
                    <li>No categories available</li>
                    {% endfor %}
                </ul>
            </div>
        </div>

        <div class="card">
            <div class="card-header">Popular Tags</div>
            <div class="card-body">
                <div class="d-flex flex-wrap gap-2">
                    {% for tag in tags %}
                    <a href="{% url 'tag_content' tag.slug %}" class="badge bg-light text-dark text-decoration-none">{{ tag.name }}</a>
                    {% empty %}
                    <p>No tags available</p>
                    {% endfor %}
                </div>
            </div>
        </div>
    </div>
</div>
{% endblock %}

Finally, let's create the content detail template:

<!-- templates/cms_core/content_detail.html -->
{% extends 'base.html' %}

{% block title %}{{ content.meta_title|default:content.title }} - Custom CMS{% endblock %}
{% block meta_description %}{{ content.meta_description|default:content.excerpt }}{% endblock %}

{% block extra_head %}
<!-- Open Graph tags -->
<meta property="og:title" content="{{ content.title }}">
<meta property="og:description" content="{{ content.excerpt }}">
{% if content.featured_image %}
<meta property="og:image" content="{{ request.scheme }}://{{ request.get_host }}{{ content.featured_image.url }}">
{% endif %}
<meta property="og:url" content="{{ request.scheme }}://{{ request.get_host }}{{ request.path }}">
<meta property="og:type" content="article">
{% endblock %}

{% block content %}
<article>
    <!-- Content Header -->
    <header class="mb-4">
        <h1 class="fw-bold mb-1">{{ content.title }}</h1>
        <div class="text-muted mb-2">
            Published on {{ content.published_at|date:"F d, Y" }} by {{ content.author.get_full_name|default:content.author.username }}
        </div>
        <div class="mb-3">
            {% for category in content.categories.all %}
            <a href="{% url 'category_content' category.slug %}" class="badge bg-secondary text-decoration-none">{{ category.name }}</a>
            {% endfor %}
            {% for tag in content.tags.all %}
            <a href="{% url 'tag_content' tag.slug %}" class="badge bg-light text-dark text-decoration-none">{{ tag.name }}</a>
            {% endfor %}
        </div>
    </header>

    <!-- Featured Image -->
    {% if content.featured_image %}
    <figure class="mb-4">
        <img class="img-fluid rounded" src="{{ content.featured_image.url }}" alt="{{ content.title }}">
    </figure>
    {% endif %}

    <!-- Content Excerpt -->
    {% if content.excerpt %}
    <div class="lead mb-4">
        {{ content.excerpt }}
    </div>
    {% endif %}

    <!-- Content -->
    <section class="mb-5">
        {{ content.content|safe }}
    </section>

    <!-- Custom Fields -->
    {% if custom_fields %}
    <section class="card mb-5">
        <div class="card-header">Additional Information</div>
        <div class="card-body">
            <dl class="row">
                {% for key, field in custom_fields.items %}
                <dt class="col-sm-3">{{ field.name }}</dt>
                <dd class="col-sm-9">
                    {% if field.type == 'image' and field.value %}
                    <img src="{{ field.value.url }}" alt="{{ field.name }}" class="img-fluid" style="max-height: 200px;">
                    {% elif field.type == 'file' and field.value %}
                    <a href="{{ field.value.url }}" download>Download {{ field.name }}</a>
                    {% elif field.type == 'boolean' %}
                    {{ field.value|yesno:"Yes,No" }}
                    {% else %}
                    {{ field.value|default:"--" }}
                    {% endif %}
                </dd>
                {% endfor %}
            </dl>
        </div>
    </section>
    {% endif %}
</article>

<!-- Related Content -->
{% if related_content %}
<section class="mb-5">
    <h2 class="mb-4">Related Content</h2>
    <div class="row">
        {% for content in related_content %}
        <div class="col-md-4 mb-4">
            <div class="card content-card h-100">
                {% if content.featured_image %}
                <img src="{{ content.featured_image.url }}" class="card-img-top" alt="{{ content.title }}">
                {% endif %}
                <div class="card-body">
                    <h5 class="card-title">{{ content.title }}</h5>
                    <p class="card-text">{{ content.excerpt|truncatewords:15 }}</p>
                </div>
                <div class="card-footer bg-transparent border-top-0">
                    <a href="{{ content.get_absolute_url }}" class="btn btn-outline-primary btn-sm">Read More</a>
                </div>
            </div>
        </div>
        {% endfor %}
    </div>
</section>
{% endif %}
{% endblock %}

Implementing Media Management

Let's enhance our CMS with a robust media management system:

# cms_core/models_media.py

from django.db import models
from django.contrib.auth.models import User
from django.utils.text import slugify
import os

def get_upload_path(instance, filename):
    """Define dynamic upload path based on content type and date"""
    return f'media/{instance.content_type}/{instance.created_at.strftime("%Y/%m/%d")}/{filename}'

class MediaItem(models.Model):
    """Model for managing media files (images, documents, videos, etc.)"""
    title = models.CharField(max_length=200)
    slug = models.SlugField(unique=True)
    description = models.TextField(blank=True)

    file = models.FileField(upload_to=get_upload_path)
    file_size = models.IntegerField(editable=False, null=True)

    # Media type choices
    TYPE_CHOICES = (
        ('image', 'Image'),
        ('document', 'Document'),
        ('video', 'Video'),
        ('audio', 'Audio'),
        ('other', 'Other'),
    )
    content_type = models.CharField(max_length=10, choices=TYPE_CHOICES)
    mime_type = models.CharField(max_length=100, blank=True)

    # Meta information
    alt_text = models.CharField(max_length=255, blank=True, help_text="Alternative text for images")
    caption = models.CharField(max_length=255, blank=True)

    # Usage tracking
    used_in_content = models.ManyToManyField('Content', blank=True, related_name='used_media')

    # Metadata
    uploaded_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='uploaded_media')
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.title

    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.title)

        # Calculate file size
        if self.file:
            self.file_size = self.file.size

        # Determine mime type
        if self.file and not self.mime_type:
            file_ext = os.path.splitext(self.file.name)[1].lower()
            if file_ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp']:
                self.content_type = 'image'
            elif file_ext in ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt']:
                self.content_type = 'document'
            elif file_ext in ['.mp4', '.mov', '.avi', '.wmv']:
                self.content_type = 'video'
            elif file_ext in ['.mp3', '.wav', '.ogg']:
                self.content_type = 'audio'
            else:
                self.content_type = 'other'

        super().save(*args, **kwargs)

    def get_absolute_url(self):
        return f"/media/{self.slug}/"

    @property
    def file_size_display(self):
        """Return human-readable file size"""
        size = self.file_size
        if size < 1024:
            return f"{size} bytes"
        elif size < 1024 * 1024:
            return f"{size/1024:.1f} KB"
        elif size < 1024 * 1024 * 1024:
            return f"{size/(1024*1024):.1f} MB"
        else:
            return f"{size/(1024*1024*1024):.1f} GB"


class MediaGallery(models.Model):
    """Group media items into galleries"""
    name = models.CharField(max_length=100)
    slug = models.SlugField(unique=True)
    description = models.TextField(blank=True)

    items = models.ManyToManyField(MediaItem, through='GalleryItem')
    created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.name

    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.name)
        super().save(*args, **kwargs)

    class Meta:
        verbose_name_plural = 'Media galleries'


class GalleryItem(models.Model):
    """Intermediate model for ordering items within a gallery"""
    gallery = models.ForeignKey(MediaGallery, on_delete=models.CASCADE)
    media_item = models.ForeignKey(MediaItem, on_delete=models.CASCADE)
    order = models.PositiveIntegerField(default=0)

    class Meta:
        ordering = ['order']
        unique_together = ('gallery', 'media_item')

Now, let's update our admin to include media management:

# cms_core/admin_media.py

from django.contrib import admin
from django.utils.html import format_html
from .models_media import MediaItem, MediaGallery, GalleryItem

class GalleryItemInline(admin.TabularInline):
    model = GalleryItem
    extra = 1
    raw_id_fields = ('media_item',)


@admin.register(MediaItem)
class MediaItemAdmin(admin.ModelAdmin):
    list_display = ('title', 'content_type', 'file_size_display', 'preview', 'uploaded_by', 'created_at')
    list_filter = ('content_type', 'created_at', 'uploaded_by')
    search_fields = ('title', 'description', 'alt_text', 'caption')
    prepopulated_fields = {'slug': ('title',)}
    readonly_fields = ('file_size', 'mime_type', 'preview_large')
    fieldsets = (
        (None, {
            'fields': ('title', 'slug', 'description', 'file')
        }),
        ('Media Information', {
            'fields': ('content_type', 'mime_type', 'file_size', 'preview_large')
        }),
        ('Metadata', {
            'fields': ('alt_text', 'caption', 'uploaded_by')
        })
    )

    def preview(self, obj):
        """Generate thumbnail preview for list view"""
        if obj.content_type == 'image':
            return format_html('<img src="{}" style="max-height: 50px; max-width: 50px;" />', obj.file.url)
        elif obj.content_type == 'document':
            return format_html('<i class="fas fa-file-alt" style="font-size: 24px;"></i>')
        elif obj.content_type == 'video':
            return format_html('<i class="fas fa-video" style="font-size: 24px;"></i>')
        elif obj.content_type == 'audio':
            return format_html('<i class="fas fa-music" style="font-size: 24px;"></i>')
        else:
            return format_html('<i class="fas fa-file" style="font-size: 24px;"></i>')

    def preview_large(self, obj):
        """Generate larger preview for detail view"""
        if obj.content_type == 'image':
            return format_html('<img src="{}" style="max-height: 200px; max-width: 100%;" />', obj.file.url)
        elif obj.content_type == 'video':
            return format_html('<video controls style="max-width: 100%;"><source src="{}" type="{}"></video>', obj.file.url, obj.mime_type)
        elif obj.content_type == 'audio':
            return format_html('<audio controls><source src="{}" type="{}"></audio>', obj.file.url, obj.mime_type)
        else:
            return format_html('<a href="{}" target="_blank">View File</a>', obj.file.url)

    def save_model(self, request, obj, form, change):
        if not change:  # If creating a new object
            obj.uploaded_by = request.user
        super().save_model(request, obj, form, change)


@admin.register(MediaGallery)
class MediaGalleryAdmin(admin.ModelAdmin):
    list_display = ('name', 'item_count', 'created_by', 'created_at')
    prepopulated_fields = {'slug': ('name',)}
    inlines = [GalleryItemInline]

    def item_count(self, obj):
        return obj.items.count()
    item_count.short_description = 'Number of items'

    def save_model(self, request, obj, form, change):
        if not change:  # If creating a new object
            obj.created_by = request.user
        super().save_model(request, obj, form, change)

Let's add a media browser view for the admin:

# cms_core/views_media.py

from django.shortcuts import render, get_object_or_404
from django.contrib.admin.views.decorators import staff_member_required
from django.http import JsonResponse
from django.core.paginator import Paginator
from .models_media import MediaItem, MediaGallery

@staff_member_required
def media_browser(request):
    """Media browser for admin"""
    # Get query parameters
    media_type = request.GET.get('type', '')
    q = request.GET.get('q', '')
    page = request.GET.get('page', 1)

    # Filter media items
    media_items = MediaItem.objects.all().order_by('-created_at')

    if media_type:
        media_items = media_items.filter(content_type=media_type)

    if q:
        media_items = media_items.filter(title__icontains=q) | media_items.filter(description__icontains=q)

    # Paginate results
    paginator = Paginator(media_items, 12)
    page_obj = paginator.get_page(page)

    context = {
        'page_obj': page_obj,
        'media_type': media_type,
        'q': q,
        'is_popup': '_popup' in request.GET,
    }

    if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
        # Return JSON response for AJAX requests
        return JsonResponse({
            'html': render(request, 'admin/media_browser_items.html', context).content.decode('utf-8'),
            'has_next': page_obj.has_next(),
            'has_previous': page_obj.has_previous(),
            'page': page_obj.number,
            'num_pages': paginator.num_pages,
        })

    return render(request, 'admin/media_browser.html', context)


@staff_member_required
def media_item_detail(request, slug):
    """API endpoint for media item details"""
    media_item = get_object_or_404(MediaItem, slug=slug)

    return JsonResponse({
        'id': media_item.id,
        'title': media_item.title,
        'url': media_item.file.url,
        'type': media_item.content_type,
        'size': media_item.file_size_display,
        'alt': media_item.alt_text,
        'caption': media_item.caption,
        'description': media_item.description,
    })


@staff_member_required
def gallery_detail(request, slug):
    """Gallery detail view for admin"""
    gallery = get_object_or_404(MediaGallery, slug=slug)
    items = gallery.galleryitem_set.all().select_related('media_item')

    context = {
        'gallery': gallery,
        'items': items,
    }

    return render(request, 'admin/gallery_detail.html', context)

Finally, let's update our URLs:

# cms_core/urls.py

from django.urls import path
from . import views, views_media

urlpatterns = [
    # Existing URLs...

    # Media browser URLs (admin only)
    path('admin/media-browser/', views_media.media_browser, name='media_browser'),
    path('admin/media-item/<slug:slug>/', views_media.media_item_detail, name='media_item_detail'),
    path('admin/gallery/<slug:slug>/', views_media.gallery_detail, name='gallery_detail'),
]

Adding User Authentication and Permissions

Let's implement a robust user authentication and permission system:

# cms_core/models_auth.py

from django.db import models
from django.contrib.auth.models import User, Group
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericForeignKey

class UserProfile(models.Model):
    """Extended user profile"""
    user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
    bio = models.TextField(blank=True)
    avatar = models.ImageField(upload_to='user_avatars/', blank=True, null=True)
    website = models.URLField(blank=True)
    location = models.CharField(max_length=100, blank=True)

    # Social media links
    twitter = models.CharField(max_length=100, blank=True)
    linkedin = models.CharField(max_length=100, blank=True)
    github = models.CharField(max_length=100, blank=True)

    # Preferences
    email_notifications = models.BooleanField(default=True)

    def __str__(self):
        return f"Profile of {self.user.username}"


class Role(models.Model):
    """Custom role model for fine-grained permissions"""
    name = models.CharField(max_length=100, unique=True)
    description = models.TextField(blank=True)
    group = models.OneToOneField(Group, on_delete=models.CASCADE, related_name='role')

    # Define which content types this role can access
    can_view_all_content = models.BooleanField(default=False)
    can_edit_all_content = models.BooleanField(default=False)
    can_publish_content = models.BooleanField(default=False)
    can_manage_categories = models.BooleanField(default=False)
    can_manage_media = models.BooleanField(default=False)
    can_manage_users = models.BooleanField(default=False)

    def __str__(self):
        return self.name


class ContentPermission(models.Model):
    """Permissions for specific content items"""
    # The content object
    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
    object_id = models.PositiveIntegerField()
    content_object = GenericForeignKey('content_type', 'object_id')

    # Who has permission
    user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True)
    group = models.ForeignKey(Group, on_delete=models.CASCADE, null=True, blank=True)

    # Permission types
    can_view = models.BooleanField(default=True)
    can_edit = models.BooleanField(default=False)
    can_delete = models.BooleanField(default=False)

    class Meta:
        unique_together = [
            ('content_type', 'object_id', 'user'),
            ('content_type', 'object_id', 'group'),
        ]

    def __str__(self):
        target = self.user.username if self.user else self.group.name
        perms = []
        if self.can_view:
            perms.append('view')
        if self.can_edit:
            perms.append('edit')
        if self.can_delete:
            perms.append('delete')

        return f"{target} can {', '.join(perms)} {self.content_object}"


class AuditLog(models.Model):
    """Track user actions for audit purposes"""
    # Who performed the action
    user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)

    # What was affected
    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
    object_id = models.PositiveIntegerField()
    content_object = GenericForeignKey('content_type', 'object_id')

    # The action performed
    ACTION_CHOICES = (
        ('create', 'Create'),
        ('update', 'Update'),
        ('delete', 'Delete'),
        ('publish', 'Publish'),
        ('unpublish', 'Unpublish'),
        ('view', 'View'),
        ('login', 'Login'),
        ('logout', 'Logout'),
    )
    action = models.CharField(max_length=20, choices=ACTION_CHOICES)

    # When it happened
    timestamp = models.DateTimeField(auto_now_add=True)

    # Additional information
    ip_address = models.GenericIPAddressField(null=True, blank=True)
    user_agent = models.TextField(blank=True)
    details = models.JSONField(null=True, blank=True)

    def __str__(self):
        return f"{self.user} {self.action} {self.content_object} at {self.timestamp}"

    class Meta:
        ordering = ['-timestamp']

Let's add the auth models to our admin:

# cms_core/admin_auth.py

from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import User
from .models_auth import UserProfile, Role, ContentPermission, AuditLog

# Extend User admin
class UserProfileInline(admin.StackedInline):
    model = UserProfile
    can_delete = False
    verbose_name_plural = 'Profile'
    fk_name = 'user'

class UserAdmin(BaseUserAdmin):
    inlines = (UserProfileInline,)
    list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff', 'is_active')

    def get_inline_instances(self, request, obj=None):
        if not obj:
            return []
        return super().get_inline_instances(request, obj)

# Re-register UserAdmin
admin.site.unregister(User)
admin.site.register(User, UserAdmin)

@admin.register(Role)
class RoleAdmin(admin.ModelAdmin):
    list_display = ('name', 'group', 'can_publish_content', 'can_manage_users')
    filter_horizontal = ('permissions',)
    search_fields = ('name', 'description')

    fieldsets = (
        (None, {
            'fields': ('name', 'description', 'group')
        }),
        ('Content Permissions', {
            'fields': (
                'can_view_all_content', 
                'can_edit_all_content', 
                'can_publish_content',
                'can_manage_categories'
            )
        }),
        ('System Permissions', {
            'fields': (
                'can_manage_media',
                'can_manage_users'
            )
        }),
    )

@admin.register(ContentPermission)
class ContentPermissionAdmin(admin.ModelAdmin):
    list_display = ('content_object', 'user', 'group', 'can_view', 'can_edit', 'can_delete')
    list_filter = ('can_view', 'can_edit', 'can_delete', 'content_type')
    search_fields = ('user__username', 'group__name')

    fieldsets = (
        (None, {
            'fields': ('content_type', 'object_id')
        }),
        ('Assigned To', {
            'fields': ('user', 'group')
        }),
        ('Permissions', {
            'fields': ('can_view', 'can_edit', 'can_delete')
        }),
    )

@admin.
0
Subscribe to my newsletter

Read articles from Learn Computer Academy directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Learn Computer Academy
Learn Computer Academy