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
Introduction and Project Setup
Creating the Core CMS Models
Building the Admin Interface
Content Rendering and Templates
Implementing Media Management
Adding User Authentication and Permissions
Building a RESTful API
Frontend Integration with HTMX
Implementing Search Functionality
Deployment and Performance Optimization
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>© {% 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">« 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 »</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.
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
