Django Simple Blog App – Clean CRUD with MVT, ModelForms, and Bootstrap

An end-to-end blogging platform demonstrating Django's MVT architecture: models, views, URLs, templates, ModelForms, admin customization, and a Bootstrap interface.

Category: Web Development, Django, Backend Engineering
Tools & Technologies: Python, Django 4.2, SQLite, Bootstrap 5, PowerShell, Virtual Environments

Status: Completed

Introduction

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.

System Overview


Aim and Objectives

Aim:
Build a maintainable Django blog with a minimal feature set that highlights the MVT pattern and developer ergonomics.

Objectives:

  1. Define a robust `Post` model with validation and readable string representation.
  2. Expose `post_list`, `post_detail`, and `post_create` views wired via namespaced URLs.
  3. Use a `ModelForm` for ergonomic form rendering and validation.
  4. Design Bootstrap templates for a clean, responsive UI.
  5. Register the model in Django Admin with useful list, search, and filter capabilities.
  6. Provide a PowerShell script for one-command setup, migration, admin seeding, and run.

Features & Deliverables

  • Post Model: Title, content, author, and timestamp with MinLength validators.
  • Three Core Views: List, detail, and create with redirects on success.
  • ModelForm: Declarative fields and Bootstrap widgets for UX consistency.
  • Templates: `base.html` layout with navigation; semantic pages for list/detail/create.
  • Admin: Registered model with `list_display`, `search_fields`, and `list_filter`.
  • Routing: Namespaced app URLs included at the project level.
  • Bootstrap Styling: Responsive components with minimal CSS overhead.
  • One-command Run: PowerShell automation for venv, deps, migrations, superuser, server.

Code Implementation

The following excerpts capture the essential files. They are formatted for readability and mirror the live code in the repository.

Model: `blog/models.py`
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
Form: `blog/forms.py`
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'}),
        }
Views: `blog/views.py`
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})
App Routing: `blog/urls.py`
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'),
]
Project Routing: `simple_blog/urls.py`
from django.contrib import admin
from django.urls import path, include


urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('blog.urls')),
]
Admin: `blog/admin.py`
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',)
Settings (key parts): `simple_blog/settings.py`
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'
Templates: `blog/templates/blog/base.html`
<!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>
Templates: `blog/templates/blog/post_list.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 %}
Templates: `blog/templates/blog/post_detail.html`
{% 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 %}
Templates: `blog/templates/blog/post_create.html`
{% 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 %}
Automation: `scripts/setup_and_run.ps1`
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
}

Results & Impact

  • MVT in Practice: Demonstrates a clean separation of concerns and predictable flow.
  • Developer Velocity: ModelForms and Admin reduce boilerplate and accelerate CRUD.
  • UX Quality: Bootstrap ensures accessible defaults and good mobile behavior.
  • Reusability: Patterns scale to larger apps (pagination, auth, comments, tags).
One Blog Post Created

System Overview

Two Blogs Post Created

System Overview

Backend Admin Database

System Overview


Process / Methodology

End-to-End Request Flow

  • URLs: Root project includes `blog.urls`; named routes enable robust reverse lookups.
  • Views: Functions fetch data, validate input with `ModelForm`, and redirect on success.
  • Templates: `base.html` hosts navigation and a content block; pages extend it.
  • Security: CSRF tokens embedded, server-side validation via `MinLengthValidator`.
  • Database: SQLite for development simplicity via Django ORM.

Data Validation

  • Model-level: `MinLengthValidator` for title, content, and author.
  • Form-level: Widgets add UX clarity; `form.is_valid()` gates persistence.

Operational Tooling

  • Requirements: `Django>=4.2,<5.0` pinned for stability.
  • Scripted Setup: PowerShell automates environment, dependencies, migration, admin, and run.

Challenges & Solutions

  • Validation UX: Inline error display to guide users without losing inputs.
  • Navigation: Namespaced URL patterns and `reverse()` to avoid hardcoded paths.
  • Styling Consistency: Bootstrap widgets in forms to match layout and accessibility.

What I Learned

  • How to apply Django's MVT pattern to a real feature set.
  • Effective use of `ModelForm` for validation and rendering.
  • Practical admin customization that empowers non-technical users.
  • Bootstrap integration for rapid, responsive UI development.

Demonstration / Access

  • GitHub Repository: View Code & Documentation
  • Run Locally (Windows): Use the provided script or manual commands (see `README_RUN.md`).
Quick Start Commands
# 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

Future Enhancements

  1. Pagination, search, and ordering controls for the list view.
  2. Authentication (login/signup) and author attribution from the user model.
  3. Slugs for SEO-friendly post URLs; edit/delete with permission checks.
  4. Comments, tags, and categories with related filtering.
  5. Unit tests with `pytest` or Django TestCase; GitHub Actions CI.
  6. Dockerization and `.env`-driven configuration; deployment to Render/Fly.io.

Thank You for Reviewing My Django Simple Blog App

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