An end-to-end blogging platform demonstrating Django's MVT architecture: models, views, URLs, templates, ModelForms, admin customization, and a Bootstrap interface.
This project is a concise yet production-ready Django application implementing a classic blog. It showcases the full Django workflow: a validated `Post` model, admin management, views for listing, reading, and creating posts, URL routing, and responsive templates with Bootstrap. The codebase is intentionally clean and readable, emphasizing best practices like `ModelForm` usage, server-side validation, CSRF protection, and a one-command setup script. It is an ideal artifact to demonstrate practical Django proficiency.
Aim:
Build a maintainable Django blog with a minimal feature set that
highlights the MVT pattern and developer ergonomics.
Objectives:
The following excerpts capture the essential files. They are formatted for readability and mirror the live code in the repository.
from django.core.validators import MinLengthValidator
from django.db import models
class Post(models.Model):
title = models.CharField(max_length=200, validators=[MinLengthValidator(3)])
content = models.TextField(validators=[MinLengthValidator(10)])
author = models.CharField(max_length=100, validators=[MinLengthValidator(2)])
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self) -> str:
return self.title
from django import forms
from django.forms import ModelForm
from .models import Post
class PostForm(ModelForm):
class Meta:
model = Post
fields = ['title', 'content', 'author']
widgets = {
'title': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Post title'}),
'content': forms.Textarea(attrs={'class': 'form-control', 'rows': 6, 'placeholder': 'Write your post here...'}),
'author': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Author name'}),
}
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from .forms import PostForm
from .models import Post
def post_list(request: HttpRequest) -> HttpResponse:
posts = Post.objects.order_by('-created_at')
return render(request, 'blog/post_list.html', {'posts': posts})
def post_detail(request: HttpRequest, pk: int) -> HttpResponse:
post = get_object_or_404(Post, pk=pk)
return render(request, 'blog/post_detail.html', {'post': post})
def post_create(request: HttpRequest) -> HttpResponse:
if request.method == 'POST':
form = PostForm(request.POST)
if form.is_valid():
post = form.save()
return redirect(reverse('blog:post_detail', kwargs={'pk': post.pk}))
else:
form = PostForm()
return render(request, 'blog/post_create.html', {'form': form})
from django.urls import path
from . import views
app_name = 'blog'
urlpatterns = [
path('', views.post_list, name='post_list'),
path('post/<int:pk>/', views.post_detail, name='post_detail'),
path('create/', views.post_create, name='post_create'),
]
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('blog.urls')),
]
from django.contrib import admin
from .models import Post
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = ('id', 'title', 'author', 'created_at')
search_fields = ('title', 'content', 'author')
list_filter = ('created_at',)
from pathlib import Path
import os
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.environ.get(
'DJANGO_SECRET_KEY',
'django-insecure-0c3m!xk#%kq0u3yp6v@a!h9u^8b5&0v^zq2rs1y)2x1o4s7h^d'
)
DEBUG = os.environ.get('DJANGO_DEBUG', '1') == '1'
ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', '*').split(',')
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'blog',
]
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
STATIC_URL = 'static/'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Simple Blog</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="{% url 'blog:post_list' %}">Simple Blog</a>
<div>
<a class="btn btn-outline-light" href="{% url 'blog:post_create' %}">Create Post</a>
<a class="btn btn-outline-light ms-2" href="/admin/">Admin</a>
</div>
</div>
</nav>
<main class="container py-4">
{% block content %}{% endblock %}
</main>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
</body>
</html>
{% extends 'blog/base.html' %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3">All Posts</h1>
<a class="btn btn-primary" href="{% url 'blog:post_create' %}">New Post</a>
</div>
{% if posts %}
<div class="list-group">
{% for post in posts %}
<a href="{% url 'blog:post_detail' post.pk %}" class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">{{ post.title }}</h5>
<small class="text-muted">{{ post.created_at|date:'Y-m-d H:i' }}</small>
</div>
<p class="mb-1 text-truncate">{{ post.content }}</p>
<small class="text-muted">By {{ post.author }}</small>
</a>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info">No posts yet. Be the first to <a href="/create/">create one</a>!</div>
{% endif %}
{% endblock %}
{% extends 'blog/base.html' %}
{% block content %}
<article class="card">
<div class="card-body">
<h1 class="card-title">{{ post.title }}</h1>
<h6 class="card-subtitle mb-2 text-muted">By {{ post.author }} · {{ post.created_at|date:'Y-m-d H:i' }}</h6>
<p class="card-text" style="white-space: pre-line;">{{ post.content }}</p>
<a href="{% url 'blog:post_list' %}" class="card-link">Back to list</a>
</div>
</article>
{% endblock %}
{% extends 'blog/base.html' %}
{% block content %}
<h1 class="h3 mb-3">Create New Post</h1>
<form method="post" novalidate>
{% csrf_token %}
<div class="mb-3">
<label for="id_title" class="form-label">Title</label>
{{ form.title }}
{% if form.title.errors %}
<div class="text-danger small">{{ form.title.errors|join:', ' }}</div>
{% endif %}
</div>
<div class="mb-3">
<label for="id_content" class="form-label">Content</label>
{{ form.content }}
{% if form.content.errors %}
<div class="text-danger small">{{ form.content.errors|join:', ' }}</div>
{% endif %}
</div>
<div class="mb-3">
<label for="id_author" class="form-label">Author</label>
{{ form.author }}
{% if form.author.errors %}
<div class="text-danger small">{{ form.author.errors|join:', ' }}</div>
{% endif %}
</div>
<button type="submit" class="btn btn-primary">Create</button>
<a href="/" class="btn btn-secondary">Cancel</a>
</form>
{% endblock %}
param(
[switch]$SkipAdminCreation,
[switch]$NoServer
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Write-Section($text) {
Write-Host "`n=== $text ===" -ForegroundColor Cyan
}
$root = Split-Path -Parent $PSScriptRoot
Set-Location $root
Write-Section 'Detecting Python'
$python = 'python'
if (Get-Command py -ErrorAction SilentlyContinue) { $python = 'py' }
(Get-Command $python) | Out-Null
Write-Host "Using Python launcher: $python"
Write-Section 'Creating virtual environment (.venv) if missing'
if (-not (Test-Path .venv)) {
& $python -m venv .venv
}
Write-Section 'Activating virtual environment'
. .\.venv\Scripts\Activate.ps1
Write-Section 'Upgrading pip and installing dependencies'
python -m pip install --upgrade pip | Out-Null
pip install -r requirements.txt
Write-Section 'Applying database migrations'
python .\simple_blog\manage.py makemigrations
python .\simple_blog\manage.py migrate
if (-not $SkipAdminCreation) {
Write-Section 'Creating/Updating admin superuser (admin / admin12345)'
$code = "from django.contrib.auth import get_user_model; User=get_user_model(); username='admin'; email='[email protected]'; password='admin12345'; user, created = User.objects.get_or_create(username=username, defaults={'email': email}); user.email=email; user.is_staff=True; user.is_superuser=True; user.set_password(password); user.save(); print('Superuser ready:', username)"
python .\simple_blog\manage.py shell -c "$code"
}
Write-Section 'Starting development server at http://127.0.0.1:8000'
if (-not $NoServer) {
Start-Process "http://127.0.0.1:8000" | Out-Null
python .\simple_blog\manage.py runserver 127.0.0.1:8000
} else {
Write-Host "Setup complete. Run the server manually with:" -ForegroundColor Green
Write-Host " .\\.venv\\Scripts\\Activate.ps1" -ForegroundColor Yellow
Write-Host " python .\\simple_blog\\manage.py runserver 127.0.0.1:8000" -ForegroundColor Yellow
}
# From the project root (contains requirements.txt)
powershell -ExecutionPolicy Bypass -File .\scripts\setup_and_run.ps1
# Or manual:
py -m venv .venv
. .\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
python .\simple_blog\manage.py migrate
python .\simple_blog\manage.py runserver 127.0.0.1:8000
This application encapsulates core Django competencies and pragmatic developer experience. It serves as a reliable template for CRUD products and a foundation for expanding into production-grade web applications.
For questions or collaborations, reach me via the Contact page. I appreciate your time and feedback.
Best regards,
Damilare Lekan Adekeye