Here is a complete Django project from scratch with solution that uses the concepts from Tutorials 13 to 20.
It includes:
ModelFormThis project is a small blog application where a user can:
This project covers the content of:
Create and display blog posts.
Handle form submission with GET/POST.
forms.pyUse Django forms properly.
Create forms directly from a model.
Create, Read, Update, Delete blog posts.
Use base.html and shared layout blocks.
Show success messages after add/edit/delete.
Create a friendly 404.html.
mini_blog/
│
├── manage.py
├── mini_blog/
│ ├── __init__.py
│ ├── settings.py
│ ├── urls.py
│ ├── asgi.py
│ └── wsgi.py
│
├── blog/
│ ├── migrations/
│ ├── __init__.py
│ ├── admin.py
│ ├── apps.py
│ ├── forms.py
│ ├── models.py
│ ├── urls.py
│ └── views.py
│
└── templates/
├── base.html
├── 404.html
└── blog/
├── post_list.html
├── post_detail.html
├── post_form.html
├── post_confirm_delete.htmlOpen terminal and run:
django-admin startproject mini_blog
cd mini_blog
python manage.py startapp blogOpen mini_blog/settings.py and add blog:
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'blog',
]Also make sure Django knows your templates folder:
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]Open blog/models.py:
from django.db import models
from django.urls import reverse
class Post(models.Model):
title = models.CharField(max_length=200)
content = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse('post_detail', args=[self.id])This model stores:
Run:
python manage.py makemigrations
python manage.py migrateOpen blog/admin.py:
from django.contrib import admin
from .models import Post
admin.site.register(Post)Create admin user:
python manage.py createsuperuserOpen blog/forms.py:
from django import forms
from .models import Post
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = ['title', 'content']
widgets = {
'title': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Enter post title'
}),
'content': forms.Textarea(attrs={
'class': 'form-control',
'placeholder': 'Write your content here',
'rows': 6
}),
}This is a ModelForm, so Django automatically creates the form from the model.
Open blog/views.py:
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib import messages
from .models import Post
from .forms import PostForm
def post_list(request):
posts = Post.objects.order_by('-created_at')
return render(request, 'blog/post_list.html', {'posts': posts})
def post_detail(request, post_id):
post = get_object_or_404(Post, id=post_id)
return render(request, 'blog/post_detail.html', {'post': post})
def post_create(request):
if request.method == 'POST':
form = PostForm(request.POST)
if form.is_valid():
post = form.save()
messages.success(request, 'Post created successfully.')
return redirect('post_detail', post_id=post.id)
else:
form = PostForm()
return render(request, 'blog/post_form.html', {
'form': form,
'page_title': 'Add New Post'
})
def post_update(request, post_id):
post = get_object_or_404(Post, id=post_id)
if request.method == 'POST':
form = PostForm(request.POST, instance=post)
if form.is_valid():
form.save()
messages.success(request, 'Post updated successfully.')
return redirect('post_detail', post_id=post.id)
else:
form = PostForm(instance=post)
return render(request, 'blog/post_form.html', {
'form': form,
'page_title': 'Edit Post'
})
def post_delete(request, post_id):
post = get_object_or_404(Post, id=post_id)
if request.method == 'POST':
post.delete()
messages.success(request, 'Post deleted successfully.')
return redirect('post_list')
return render(request, 'blog/post_confirm_delete.html', {'post': post})Open blog/urls.py:
from django.urls import path
from . import views
urlpatterns = [
path('', views.post_list, name='post_list'),
path('post/<int:post_id>/', views.post_detail, name='post_detail'),
path('create/', views.post_create, name='post_create'),
path('edit/<int:post_id>/', views.post_update, name='post_update'),
path('delete/<int:post_id>/', views.post_delete, name='post_delete'),
]
Open mini_blog/urls.py:
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('blog.urls')),
]
Create 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 %}Mini Blog{% endblock %}</title>
<style>
body {
font-family: Arial, sans-serif;
background: #f4f6f9;
margin: 0;
padding: 0;
}
nav {
background: #0d6efd;
padding: 15px 30px;
}
nav a {
color: white;
text-decoration: none;
margin-right: 20px;
font-weight: bold;
}
.container {
width: 85%;
max-width: 900px;
margin: 30px auto;
background: white;
padding: 25px;
border-radius: 10px;
box-shadow: 0 3px 10px rgba(0,0,0,0.08);
}
.btn {
display: inline-block;
padding: 10px 16px;
text-decoration: none;
border-radius: 6px;
margin-top: 10px;
border: none;
cursor: pointer;
}
.btn-primary {
background: #0d6efd;
color: white;
}
.btn-warning {
background: orange;
color: white;
}
.btn-danger {
background: crimson;
color: white;
}
.btn-secondary {
background: gray;
color: white;
}
.post-card {
border-bottom: 1px solid #ddd;
padding: 15px 0;
}
.message {
padding: 12px;
margin-bottom: 20px;
border-radius: 6px;
background: #d1e7dd;
color: #0f5132;
}
input, textarea {
width: 100%;
padding: 10px;
margin-top: 6px;
margin-bottom: 15px;
border: 1px solid #ccc;
border-radius: 6px;
}
</style>
</head>
<body>
<nav>
<a href="{% url 'post_list' %}">Home</a>
<a href="{% url 'post_create' %}">Add Post</a>
</nav>
<div class="container">
{% if messages %}
{% for message in messages %}
<div class="message">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% block content %}
{% endblock %}
</div>
</body>
</html>This is the shared layout used by all pages.
Create templates/blog/post_list.html:
{% extends 'base.html' %}
{% block title %}All Posts{% endblock %}
{% block content %}
<h1>All Blog Posts</h1>
<a href="{% url 'post_create' %}" class="btn btn-primary">Create New Post</a>
{% for post in posts %}
<div class="post-card">
<h2>{{ post.title }}</h2>
<p>{{ post.content|truncatewords:20 }}</p>
<small>Created: {{ post.created_at }}</small><br>
<a href="{% url 'post_detail' post.id %}" class="btn btn-primary">Read More</a>
</div>
{% empty %}
<p>No posts available yet.</p>
{% endfor %}
{% endblock %}Create templates/blog/post_detail.html:
{% extends 'base.html' %}
{% block title %}{{ post.title }}{% endblock %}
{% block content %}
<h1>{{ post.title }}</h1>
<p>{{ post.content }}</p>
<p><strong>Created:</strong> {{ post.created_at }}</p>
<p><strong>Last updated:</strong> {{ post.updated_at }}</p>
<a href="{% url 'post_update' post.id %}" class="btn btn-warning">Edit</a>
<a href="{% url 'post_delete' post.id %}" class="btn btn-danger">Delete</a>
<a href="{% url 'post_list' %}" class="btn btn-secondary">Back</a>
{% endblock %}Create templates/blog/post_form.html:
{% extends 'base.html' %}
{% block title %}{{ page_title }}{% endblock %}
{% block content %}
<h1>{{ page_title }}</h1>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn btn-primary">Save</button>
<a href="{% url 'post_list' %}" class="btn btn-secondary">Cancel</a>
</form>
{% endblock %}Create templates/blog/post_confirm_delete.html:
{% extends 'base.html' %}
{% block title %}Delete Post{% endblock %}
{% block content %}
<h1>Delete Post</h1>
<p>Are you sure you want to delete this post?</p>
<h3>{{ post.title }}</h3>
<form method="post">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Yes, Delete</button>
<a href="{% url 'post_detail' post.id %}" class="btn btn-secondary">Cancel</a>
</form>
{% endblock %}
Create templates/404.html:
{% extends 'base.html' %}
{% block title %}Page Not Found{% endblock %}
{% block content %}
<div style="text-align:center; padding:40px;">
<h1 style="font-size:70px; color:crimson;">404</h1>
<h2>Page Not Found</h2>
<p>Sorry, the page you are looking for does not exist.</p>
<a href="{% url 'post_list' %}" class="btn btn-primary">Go Back Home</a>
</div>
{% endblock %}
Run the server:
python manage.py runserverNow test these pages:
/ → list of posts/create/ → add new post/post/1/ → detail page/edit/1/ → edit post/delete/1/ → delete postFor 404 test, visit:
/non-existing-page/To see custom 404.html, set in settings.py:
DEBUG = False
ALLOWED_HOSTS = ['127.0.0.1', 'localhost']
The Post model and blog list/detail pages create the basic blog.
The create and update pages handle form submission with GET and POST.
forms.pyThe file forms.py is used to define the form properly.
PostForm is a ModelForm, so the form comes from the model.
The project has:
post_createpost_list, post_detailpost_updatepost_deleteAll templates extend base.html.
After create, update, and delete, success messages are shown.
A custom 404.html is included.
This project gives the student a complete real mini-application using the lessons from 13 to 20. It is simple enough for beginners but strong enough to teach real Django workflow.
The student learns how to:
ModelFormAfter finishing this project, you can improve it by adding:
Try extending the project by adding:
category field to each post500.html page