MofidBoard — A mini blog and learning platform in Django
This project is designed to cover all the subjects from Tutorials 21 to 35 in one practical application.
It includes:
We will build a small platform where:
Used with TemplateView, ListView, DetailView
Used with CreateView, UpdateView, DeleteView
Used in search, filtering, ordering, pagination
Used with ForeignKey, OneToOneField, ManyToManyField
Used in Article and Profile
Used in article detail pages
Used with article cover images and profile avatar
Login, logout, protected pages
Custom signup form + profile creation
Dashboard, author-only editing, permission-based access
Theme preference cookie + session-based visit counter
Article list pagination
Search in title and content
Global categories, site name, current year
Custom price/date/text/group helper tags
mofidboard/
│
├── manage.py
├── mofidboard/
│ ├── settings.py
│ ├── urls.py
│ ├── asgi.py
│ └── wsgi.py
│
├── core/
│ ├── views.py
│ ├── urls.py
│ ├── context_processors.py
│ └── templatetags/
│ ├── __init__.py
│ └── custom_tags.py
│
├── accounts/
│ ├── models.py
│ ├── forms.py
│ ├── views.py
│ ├── urls.py
│ └── signals.py
│
├── blog/
│ ├── models.py
│ ├── forms.py
│ ├── views.py
│ ├── urls.py
│ └── admin.py
│
├── templates/
│ ├── base.html
│ ├── core/
│ ├── accounts/
│ └── blog/
│
└── media/django-admin startproject mofidboard
cd mofidboard
python manage.py startapp core
python manage.py startapp accounts
python manage.py startapp blogAdd apps in settings.py:
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'core',
'accounts',
'blog',
]settings.pyfrom pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = 'your-secret-key'
DEBUG = True
ALLOWED_HOSTS = []
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'core',
'accounts',
'blog',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'mofidboard.urls'
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',
'core.context_processors.site_defaults',
],
},
},
]
WSGI_APPLICATION = 'mofidboard.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
AUTH_PASSWORD_VALIDATORS = []
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'Africa/Casablanca'
USE_I18N = True
USE_TZ = True
STATIC_URL = '/static/'
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
LOGIN_URL = 'login'
LOGIN_REDIRECT_URL = 'dashboard'
LOGOUT_REDIRECT_URL = 'home'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'accounts/models.pyfrom django.db import models
from django.contrib.auth.models import User
class Profile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
phone = models.CharField(max_length=20, blank=True)
city = models.CharField(max_length=100, blank=True)
bio = models.TextField(blank=True)
avatar = models.ImageField(upload_to='avatars/', blank=True, null=True)
def __str__(self):
return self.user.username
@property
def full_name(self):
full = f"{self.user.first_name} {self.user.last_name}".strip()
return full if full else self.user.username
blog/models.pyfrom django.db import models
from django.contrib.auth.models import User
from django.urls import reverse
from django.utils.text import slugify
class Category(models.Model):
name = models.CharField(max_length=100, unique=True)
slug = models.SlugField(unique=True, blank=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 Tag(models.Model):
name = models.CharField(max_length=50, unique=True)
def __str__(self):
return self.name
class Article(models.Model):
author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='articles')
category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name='articles')
tags = models.ManyToManyField(Tag, related_name='articles', blank=True)
title = models.CharField(max_length=200)
slug = models.SlugField(unique=True, blank=True)
content = models.TextField()
cover = models.ImageField(upload_to='articles/', blank=True, null=True)
is_published = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
permissions = [
('can_publish_article', 'Can publish article'),
]
ordering = ['-created_at']
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse('article_detail', kwargs={'slug': self.slug})
def short_content(self):
return self.content[:120] + '...' if len(self.content) > 120 else self.content
@property
def reading_time(self):
words = len(self.content.split())
return max(1, words // 200)
def save(self, *args, **kwargs):
if not self.slug:
base_slug = slugify(self.title)
slug = base_slug
counter = 1
while Article.objects.filter(slug=slug).exclude(pk=self.pk).exists():
slug = f'{base_slug}-{counter}'
counter += 1
self.slug = slug
super().save(*args, **kwargs)accounts/signals.pyfrom django.db.models.signals import post_save
from django.contrib.auth.models import User
from django.dispatch import receiver
from .models import Profile
@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
if created:
Profile.objects.create(user=instance)accounts/apps.pyfrom django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'accounts'
def ready(self):
import accounts.signalsAnd in accounts/__init__.py nothing special needed if modern Django reads AppConfig automatically via apps.py.
accounts/forms.pyfrom django import forms
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import User
class AdvancedSignUpForm(UserCreationForm):
email = forms.EmailField(required=True)
first_name = forms.CharField(max_length=100, required=True)
last_name = forms.CharField(max_length=100, required=True)
phone = forms.CharField(max_length=20, required=False)
city = forms.CharField(max_length=100, required=False)
bio = forms.CharField(widget=forms.Textarea, required=False)
class Meta:
model = User
fields = [
'username', 'first_name', 'last_name', 'email',
'password1', 'password2', 'phone', 'city', 'bio'
]
def clean_email(self):
email = self.cleaned_data['email']
if User.objects.filter(email=email).exists():
raise forms.ValidationError("This email is already used.")
return emailblog/forms.pyfrom django import forms
from .models import Article
class ArticleForm(forms.ModelForm):
class Meta:
model = Article
fields = ['category', 'tags', 'title', 'content', 'cover', 'is_published']
widgets = {
'content': forms.Textarea(attrs={'rows': 8}),
}
class SearchForm(forms.Form):
q = forms.CharField(required=False, max_length=100)core/context_processors.pyfrom datetime import datetime
from blog.models import Category
def site_defaults(request):
cart = request.session.get('saved_articles', [])
return {
'site_name': 'MofidBoard',
'current_year': datetime.now().year,
'global_categories': Category.objects.all(),
'saved_articles_count': len(cart),
}This covers Tutorial 14.
core/templatetags/custom_tags.pyfrom django import template
from datetime import datetime
register = template.Library()
@register.filter
def mad(value):
return f"{value} MAD"
@register.filter
def short_text(value, length=80):
value = str(value)
length = int(length)
return value if len(value) <= length else value[:length] + '...'
@register.filter
def has_group(user, group_name):
return user.groups.filter(name=group_name).exists()
@register.simple_tag
def current_year_tag():
return datetime.now().year
@register.simple_tag
def full_name(user):
full = f"{user.first_name} {user.last_name}".strip()
return full if full else user.usernameThis covers Tutorial 15.
core/views.pyfrom django.shortcuts import render
from django.views.generic import TemplateView
class HomeView(TemplateView):
template_name = 'core/home.html'
def set_theme(request, theme):
response = render(request, 'core/theme_set.html', {'theme': theme})
response.set_cookie('theme', theme, max_age=60 * 60 * 24 * 30)
return response
accounts/views.pyfrom django.shortcuts import render, redirect
from django.contrib.auth import login
from django.contrib.auth.decorators import login_required
from .forms import AdvancedSignUpForm
def signup_view(request):
if request.method == 'POST':
form = AdvancedSignUpForm(request.POST)
if form.is_valid():
user = form.save(commit=False)
user.email = form.cleaned_data['email']
user.first_name = form.cleaned_data['first_name']
user.last_name = form.cleaned_data['last_name']
user.save()
profile = user.profile
profile.phone = form.cleaned_data['phone']
profile.city = form.cleaned_data['city']
profile.bio = form.cleaned_data['bio']
profile.save()
login(request, user)
return redirect('dashboard')
else:
form = AdvancedSignUpForm()
return render(request, 'accounts/signup.html', {'form': form})
@login_required
def dashboard(request):
visits = request.session.get('dashboard_visits', 0)
visits += 1
request.session['dashboard_visits'] = visits
return render(request, 'accounts/dashboard.html', {'visits': visits})This covers Tutorials 8, 9, 10, 11.
blog/views.pyfrom django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.core.paginator import Paginator
from django.db.models import Q, Count
from django.http import HttpResponseForbidden
from django.shortcuts import render
from django.urls import reverse_lazy
from django.views.generic import (
ListView, DetailView, CreateView, UpdateView, DeleteView
)
from .forms import ArticleForm
from .models import Article, Category
class ArticleListView(ListView):
model = Article
template_name = 'blog/article_list.html'
context_object_name = 'articles'
paginate_by = 5
def get_queryset(self):
return (
Article.objects.select_related('author', 'category')
.prefetch_related('tags')
.filter(is_published=True)
.annotate(tag_count=Count('tags'))
)
class ArticleDetailView(DetailView):
model = Article
template_name = 'blog/article_detail.html'
context_object_name = 'article'
slug_field = 'slug'
slug_url_kwarg = 'slug'
class ArticleCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView):
model = Article
form_class = ArticleForm
template_name = 'blog/article_form.html'
permission_required = 'blog.add_article'
def form_valid(self, form):
form.instance.author = self.request.user
return super().form_valid(form)
class ArticleUpdateView(LoginRequiredMixin, UpdateView):
model = Article
form_class = ArticleForm
template_name = 'blog/article_form.html'
slug_field = 'slug'
slug_url_kwarg = 'slug'
def dispatch(self, request, *args, **kwargs):
article = self.get_object()
if request.user != article.author:
return HttpResponseForbidden("You can edit only your own articles.")
return super().dispatch(request, *args, **kwargs)
class ArticleDeleteView(LoginRequiredMixin, DeleteView):
model = Article
template_name = 'blog/article_confirm_delete.html'
success_url = reverse_lazy('article_list')
slug_field = 'slug'
slug_url_kwarg = 'slug'
def dispatch(self, request, *args, **kwargs):
article = self.get_object()
if request.user != article.author:
return HttpResponseForbidden("You can delete only your own articles.")
return super().dispatch(request, *args, **kwargs)
def search_articles(request):
query = request.GET.get('q', '')
results = Article.objects.none()
if query:
results = (
Article.objects.select_related('author', 'category')
.prefetch_related('tags')
.filter(
Q(title__icontains=query) |
Q(content__icontains=query) |
Q(category__name__icontains=query) |
Q(tags__name__icontains=query)
)
.distinct()
)
paginator = Paginator(results, 5)
page_number = request.GET.get('page')
page_obj = paginator.get_page(page_number)
return render(request, 'blog/search_results.html', {
'query': query,
'page_obj': page_obj,
})
class CategoryArticleListView(ListView):
model = Article
template_name = 'blog/category_articles.html'
context_object_name = 'articles'
paginate_by = 5
def get_queryset(self):
return Article.objects.filter(
category__slug=self.kwargs['slug'],
is_published=True
).select_related('author', 'category').prefetch_related('tags')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['category'] = Category.objects.get(slug=self.kwargs['slug'])
return contextThis covers Tutorials 1, 2, 3, 6, 7, 10, 12, 13.
mofidboard/urls.pyfrom django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('core.urls')),
path('accounts/', include('accounts.urls')),
path('blog/', include('blog.urls')),
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)core/urls.pyfrom django.urls import path
from .views import HomeView, set_theme
urlpatterns = [
path('', HomeView.as_view(), name='home'),
path('theme/<str:theme>/', set_theme, name='set_theme'),
]accounts/urls.pyfrom django.urls import path
from django.contrib.auth import views as auth_views
from .views import signup_view, dashboard
urlpatterns = [
path('signup/', signup_view, name='signup'),
path('login/', auth_views.LoginView.as_view(template_name='accounts/login.html'), name='login'),
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
path('dashboard/', dashboard, name='dashboard'),
]blog/urls.pyfrom django.urls import path
from .views import (
ArticleListView, ArticleDetailView, ArticleCreateView,
ArticleUpdateView, ArticleDeleteView, search_articles,
CategoryArticleListView
)
urlpatterns = [
path('', ArticleListView.as_view(), name='article_list'),
path('search/', search_articles, name='search_articles'),
path('create/', ArticleCreateView.as_view(), name='article_create'),
path('category/<slug:slug>/', CategoryArticleListView.as_view(), name='category_articles'),
path('<slug:slug>/', ArticleDetailView.as_view(), name='article_detail'),
path('<slug:slug>/edit/', ArticleUpdateView.as_view(), name='article_update'),
path('<slug:slug>/delete/', ArticleDeleteView.as_view(), name='article_delete'),
]templates/base.html{% load custom_tags %}
<!DOCTYPE html>
<html>
<head>
<title>{{ site_name }}</title>
</head>
<body class="{{ request.COOKIES.theme|default:'light' }}">
<header>
<h1><a href="{% url 'home' %}">{{ site_name }}</a></h1>
<nav>
<a href="{% url 'article_list' %}">Articles</a>
<a href="{% url 'search_articles' %}">Search</a>
<a href="{% url 'dashboard' %}">Dashboard</a>
{% if user.is_authenticated %}
<span>Welcome {% full_name user %}</span>
<a href="{% url 'logout' %}">Logout</a>
{% else %}
<a href="{% url 'login' %}">Login</a>
<a href="{% url 'signup' %}">Register</a>
{% endif %}
</nav>
<form method="get" action="{% url 'search_articles' %}">
<input type="text" name="q" placeholder="Search articles...">
<button type="submit">Search</button>
</form>
<p>Saved: {{ saved_articles_count }}</p>
<ul>
{% for category in global_categories %}
<li><a href="{% url 'category_articles' category.slug %}">{{ category.name }}</a></li>
{% endfor %}
</ul>
</header>
<hr>
{% block content %}{% endblock %}
<hr>
<footer>
<p>© {{ current_year }} {{ site_name }}</p>
<a href="{% url 'set_theme' 'light' %}">Light</a>
<a href="{% url 'set_theme' 'dark' %}">Dark</a>
</footer>
</body>
</html>templates/core/home.html{% extends 'base.html' %}
{% block content %}
<h2>Welcome to {{ site_name }}</h2>
<p>A project covering Django tutorials 21 to 35.</p>
<p><a href="{% url 'article_list' %}">Browse articles</a></p>
{% endblock %}templates/accounts/signup.html{% extends 'base.html' %}
{% block content %}
<h2>Register</h2>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Create account</button>
</form>
{% endblock %}
templates/accounts/login.html{% extends 'base.html' %}
{% block content %}
<h2>Login</h2>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Login</button>
</form>
{% endblock %}
templates/accounts/dashboard.html{% extends 'base.html' %}
{% block content %}
<h2>Dashboard</h2>
<p>Hello {{ user.username }}</p>
<p>Dashboard visits in this session: {{ visits }}</p>
{% if user.profile.avatar %}
<img src="{{ user.profile.avatar.url }}" width="120" alt="avatar">
{% endif %}
<p>City: {{ user.profile.city }}</p>
<p>Phone: {{ user.profile.phone }}</p>
<p>Bio: {{ user.profile.bio|short_text:100 }}</p>
{% if perms.blog.add_article %}
<p><a href="{% url 'article_create' %}">Create Article</a></p>
{% endif %}
{% endblock %}
templates/blog/article_list.html{% extends 'base.html' %}
{% load custom_tags %}
{% block content %}
<h2>Articles</h2>
{% for article in articles %}
<article>
<h3><a href="{% url 'article_detail' article.slug %}">{{ article.title }}</a></h3>
<p>By {{ article.author.username }} | {{ article.category.name }}</p>
<p>{{ article.short_content|short_text:150 }}</p>
<p>Reading time: {{ article.reading_time }} min</p>
<p>Tags: {{ article.tag_count }}</p>
{% if article.cover %}
<img src="{{ article.cover.url }}" width="200" alt="{{ article.title }}">
{% endif %}
</article>
<hr>
{% empty %}
<p>No articles found.</p>
{% endfor %}
{% if is_paginated %}
<div>
{% if page_obj.has_previous %}
<a href="?page=1">First</a>
<a href="?page={{ page_obj.previous_page_number }}">Previous</a>
{% endif %}
Page {{ page_obj.number }} of {{ paginator.num_pages }}
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}">Next</a>
<a href="?page={{ paginator.num_pages }}">Last</a>
{% endif %}
</div>
{% endif %}
{% endblock %}templates/blog/article_detail.html{% extends 'base.html' %}
{% block content %}
<h2>{{ article.title }}</h2>
<p>By {{ article.author.username }} in {{ article.category.name }}</p>
<p>Reading time: {{ article.reading_time }} min</p>
{% if article.cover %}
<img src="{{ article.cover.url }}" width="300" alt="{{ article.title }}">
{% endif %}
<p>{{ article.content }}</p>
<p>
Tags:
{% for tag in article.tags.all %}
<span>{{ tag.name }}</span>
{% empty %}
<span>No tags</span>
{% endfor %}
</p>
{% if user == article.author %}
<a href="{% url 'article_update' article.slug %}">Edit</a>
<a href="{% url 'article_delete' article.slug %}">Delete</a>
{% endif %}
{% endblock %}templates/blog/article_form.html{% extends 'base.html' %}
{% block content %}
<h2>Article Form</h2>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Save</button>
</form>
{% endblock %}templates/blog/article_confirm_delete.html{% extends 'base.html' %}
{% block content %}
<h2>Delete Article</h2>
<p>Are you sure you want to delete "{{ object.title }}"?</p>
<form method="post">
{% csrf_token %}
<button type="submit">Yes, delete</button>
</form>
{% endblock %}
templates/blog/search_results.html{% extends 'base.html' %}
{% block content %}
<h2>Search Results</h2>
<p>Query: {{ query }}</p>
{% for article in page_obj %}
<h3><a href="{% url 'article_detail' article.slug %}">{{ article.title }}</a></h3>
<p>{{ article.short_content }}</p>
<hr>
{% empty %}
{% if query %}
<p>No results found.</p>
{% endif %}
{% endfor %}
{% if page_obj.paginator.num_pages > 1 %}
<div>
{% if page_obj.has_previous %}
<a href="?q={{ query }}&page={{ page_obj.previous_page_number }}">Previous</a>
{% endif %}
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
{% if page_obj.has_next %}
<a href="?q={{ query }}&page={{ page_obj.next_page_number }}">Next</a>
{% endif %}
</div>
{% endif %}
{% endblock %}blog/admin.pyfrom django.contrib import admin
from .models import Category, Tag, Article
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
prepopulated_fields = {'slug': ('name',)}
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
list_display = ['title', 'author', 'category', 'is_published', 'created_at']
list_filter = ['category', 'is_published']
search_fields = ['title', 'content']
prepopulated_fields = {'slug': ('title',)}
admin.site.register(Tag)python manage.py makemigrations
python manage.py migrate
python manage.py createsuperuser
Used in HomeView, ArticleListView, ArticleDetailView
Used in CreateView, UpdateView, DeleteView
Used in:
select_relatedprefetch_relatedannotateCountQProfile.user → OneToOneFieldArticle.author and Article.category → ForeignKeyArticle.tags → ManyToManyFieldArticle.short_content()Article.reading_timeProfile.full_nameProfile.avatarArticle.coverlogin_requiredPermissionRequiredMixinsite_defaultsmadshort_texthas_groupfull_namecurrent_year_tagCreate:
Create an Editors group in admin and assign:
blog.add_articleblog.change_articleblog.delete_articleThen add one user to that group.
After finishing, your project will have:
This is a real mini-project that combines all topics from Tutorials 1 to 15 into one coherent application.
The best next improvement would be to extend this project with: