Members move through article lists and detail pages that respect category structure and only expose published content to the right audience.
Project — Internal Knowledge Base (Django Templates)
Build a knowledge base with Article/Category models, draft/published workflow, slug routing, and role-gated views.
What You Are Building
A server-rendered internal knowledge base. Members can browse and read articles organised by category. Editors and admins can create and edit articles. Only admins can delete articles. Articles can be saved as drafts (visible only to editors/admins) or published (visible to all members). Django handles the full request-response cycle. No React. All pages extend base.html. All CSS in static/css/, all JS in static/js/. Data passed to JavaScript via data-* attributes only.
- MemberCan browse published articles and open detail pages, but cannot manage content.
- EditorCreate and edit articles, including draft work that is not visible to regular members.
- Admin / OwnerManage the full knowledge base, including article deletion and category-level administration.
How this product should work
This project should clearly communicate the publishing workflow: who can read, who can draft, who can publish, and who can clean up content or categories.
Editors and admins can save articles as draft or publish them, changing who is allowed to see each article.
Create and edit pages manage category, slug, excerpt, body, and status while keeping the implementation fully server-rendered.
Admins handle category creation and article deletion with proper permission checks and confirmation-driven cleanup behavior.
Features to Build
| # | Feature | Est. Time |
|---|---|---|
| 1 | Django models: Category, Article | 45m |
| 2 | Django forms: CategoryForm, ArticleForm | 30m |
| 3 | Django service: ArticleService | 45m |
| 4 | Django views: 7 views (list, detail, create, edit, delete, category list, category create) | 1h 30m |
| 5 | Templates: article_list.html, article_detail.html, article_form.html, category_list.html | 2h |
| 6 | Static CSS: article cards, category badges, search bar, status badges | 45m |
| 7 | Static JS: delete confirmation, character counter for excerpt | 30m |
1. Django Models
Category
from django.db import models
from django.utils.text import slugify
class Category(models.Model):
workspace = models.ForeignKey('Workspace', on_delete=models.CASCADE, related_name='categories')
name = models.CharField(max_length=100)
slug = models.SlugField(max_length=110)
description = models.TextField(blank=True, default='')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['name']
verbose_name_plural = 'Categories'
constraints = [
models.UniqueConstraint(
fields=['workspace', 'slug'],
name='unique_category_slug_per_workspace'
),
]
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
def __str__(self):
return self.name
Article
class Article(models.Model):
class Status(models.TextChoices):
DRAFT = 'draft', 'Draft'
PUBLISHED = 'published', 'Published'
workspace = models.ForeignKey('Workspace', on_delete=models.CASCADE, related_name='articles')
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True,
related_name='articles')
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
null=True, related_name='authored_articles')
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=220)
excerpt = models.CharField(max_length=300, blank=True, default='')
body = models.TextField()
status = models.CharField(max_length=20, choices=Status.choices, default=Status.DRAFT)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
published_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-created_at']
constraints = [
models.UniqueConstraint(
fields=['workspace', 'slug'],
name='unique_article_slug_per_workspace'
),
]
indexes = [
models.Index(fields=['workspace', 'status', '-created_at']),
models.Index(fields=['workspace', 'category', 'status']),
]
def __str__(self):
return self.title
Run checklist: Django Model
2. Django Forms
CategoryForm
class CategoryForm(forms.ModelForm):
class Meta:
model = Category
fields = ['name', 'description']
def clean_name(self):
name = self.cleaned_data.get('name', '').strip()
if not name:
raise forms.ValidationError("Category name is required.")
return name
ArticleForm
class ArticleForm(forms.ModelForm):
class Meta:
model = Article
fields = ['title', 'category', 'excerpt', 'body', 'status']
widgets = {
'excerpt': forms.Textarea(attrs={'rows': 3, 'maxlength': 300}),
'body': forms.Textarea(attrs={'rows': 20}),
}
def __init__(self, *args, workspace=None, **kwargs):
super().__init__(*args, **kwargs)
if workspace:
self.fields['category'].queryset = Category.objects.filter(workspace=workspace)
self.fields['category'].required = False
def clean_title(self):
title = self.cleaned_data.get('title', '').strip()
if not title:
raise forms.ValidationError("Title is required.")
return title
def clean_body(self):
body = self.cleaned_data.get('body', '').strip()
if not body:
raise forms.ValidationError("Body is required.")
return body
Run checklist: Django Form / Serializer
3. ArticleService
# services.py
from django.utils import timezone
from django.utils.text import slugify
from django.core.exceptions import ValidationError, PermissionDenied
class ArticleService:
@staticmethod
def create_article(workspace, author, title, body, category=None, excerpt='', status='draft'):
"""
Creates a new article.
Validates:
- title and body are not empty
- slug is unique within workspace (auto-generated from title; append suffix if collision)
Sets published_at if status == 'published'.
"""
@staticmethod
def update_article(article, editor, title, body, category=None, excerpt='', status='draft'):
"""
Updates an existing article.
Validates:
- editor is a workspace member (editor or admin role)
- title and body are not empty
Re-generates slug only if title changed and slug collision would occur.
Sets published_at if status changes to 'published' and was previously not.
"""
@staticmethod
def delete_article(article, actor):
"""
Deletes an article.
Raises PermissionDenied if actor does not have admin or owner role in the workspace.
"""
@staticmethod
def _unique_slug(workspace, base_slug, exclude_id=None):
"""
Returns a unique slug for the workspace.
If base_slug is taken, appends -2, -3, etc.
"""
4. Django Views
WorkspaceMemberMixin
class WorkspaceMemberMixin(LoginRequiredMixin):
def dispatch(self, request, *args, **kwargs):
self.workspace = get_object_or_404(Workspace, id=kwargs['workspace_id'])
self.membership = get_object_or_404(
WorkspaceMember, workspace=self.workspace, user=request.user, status='active'
)
return super().dispatch(request, *args, **kwargs)
Views to Build
ArticleListView (GET /workspace/{id}/kb/)
— WorkspaceMemberMixin
— Members see published articles only
— Editors/admins see published + their own drafts
— Supports ?search= (title/excerpt contains, DB query — not client-side)
— Supports ?category= (slug filter)
— Paginated: 12 per page
— Template: article_list.html
ArticleDetailView (GET /workspace/{id}/kb/<slug:slug>/)
— WorkspaceMemberMixin
— Members: 404 if article is draft and they are not the author or admin
— Template: article_detail.html
ArticleCreateView (GET+POST /workspace/{id}/kb/new/)
— WorkspaceMemberMixin + editor/admin role check (403 if member role)
— GET: render empty ArticleForm
— POST: validate → ArticleService.create_article() → redirect to article detail
— Template: article_form.html
ArticleEditView (GET+POST /workspace/{id}/kb/<slug:slug>/edit/)
— WorkspaceMemberMixin + editor/admin role check
— GET: render ArticleForm prefilled
— POST: validate → ArticleService.update_article() → redirect to article detail
— Template: article_form.html (same template as create, different heading)
ArticleDeleteView (POST /workspace/{id}/kb/<slug:slug>/delete/)
— WorkspaceMemberMixin + admin/owner role check (403 if editor or member)
— POST only (from a form button with CSRF)
— Calls ArticleService.delete_article()
— Redirects to article list with success/error message
CategoryListView (GET /workspace/{id}/kb/categories/)
— WorkspaceMemberMixin + admin/owner role check
— Lists all categories for the workspace
— Supports ?search= (name contains)
— Template: category_list.html
CategoryCreateView (GET+POST /workspace/{id}/kb/categories/new/)
— WorkspaceMemberMixin + admin/owner role check
— GET: render empty CategoryForm
— POST: validate → save category → redirect to category list
— On duplicate slug: show form error "A category with this name already exists."
— Template: category_form.html
Run checklist: Django New View for each view
5. Templates
All templates must:
- Extend
base.html - Have NO
<style>blocks (all CSS instatic/css/kb.css) - Have NO inline
<script>logic (all JS instatic/js/kb.js) - Load static with
{% load static %}
article_list.html
{% extends "base.html" %}
{% load static %}
{% block title %}Knowledge Base{% endblock %}
{% block content %}
<!-- Search + category filter bar (GET form) -->
<!-- Active filter chips: show if ?search= or ?category= is set -->
<!-- "New Article" button — visible to editor/admin only -->
<!-- Article cards grid -->
<!-- Each card: title, excerpt, category badge, status badge (draft/published), author, date -->
<!-- Empty state: "No articles found." -->
<!-- Pagination links -->
{% endblock %}
article_detail.html
{% extends "base.html" %}
{% load static %}
{% block title %}{{ article.title }}{% endblock %}
{% block content %}
<!-- Breadcrumb: Knowledge Base / [Category] / [Title] -->
<!-- Status badge (draft warning for editors/admins) -->
<!-- Article title, author, published date -->
<!-- Category badge linking to filtered list -->
<!-- Article body (rendered as HTML from trusted source) -->
<!-- Edit button — visible to editor/admin only -->
<!-- Delete button — visible to admin/owner only, POST form with CSRF -->
{% endblock %}
article_form.html
{% extends "base.html" %}
{% load static %}
{% block title %}{% if article %}Edit Article{% else %}New Article{% endif %}{% endblock %}
{% block content %}
<!-- Heading: "Edit Article" or "New Article" -->
<form method="post">
{% csrf_token %}
<!-- Render each field individually (not {{ form.as_p }}) -->
<!-- Show field errors below each field -->
<!-- Show non-field errors at top of form -->
<!-- Excerpt field: show character counter (300 max) via data-* and JS -->
<!-- Status field: dropdown (Draft / Published) -->
<!-- Submit button: "Save Article" with loading state via data-* -->
<!-- Cancel link back to article list or detail -->
</form>
{% endblock %}
category_list.html
{% extends "base.html" %}
{% load static %}
{% block title %}Categories{% endblock %}
{% block content %}
<!-- "New Category" button (admin/owner only) -->
<!-- Search bar (GET form) -->
<!-- Categories table: Name | Description | Article Count | Actions -->
<!-- Delete button per category — POST form with CSRF, confirmation via data-* and JS -->
<!-- Empty state: "No categories yet." -->
{% endblock %}
category_form.html
{% extends "base.html" %}
{% load static %}
{% block title %}New Category{% endblock %}
{% block content %}
<form method="post">
{% csrf_token %}
<!-- Name field with error -->
<!-- Description field with error -->
<!-- Submit button -->
</form>
{% endblock %}
Run checklist: Django Template for each template
6. Static CSS (`static/css/kb.css`)
.badge-draft { background: var(--color-warning); color: white; }
.badge-published { background: var(--color-success); color: white; }
7. Static JS (`static/js/kb.js`)
All JavaScript must use data-* attributes — never string interpolation in script tags.
DELETE CONFIRMATION:
<!-- HTML -->
<button
class="btn-delete-article"
data-article-title="{{ article.title }}"
data-form-id="delete-form-{{ article.id }}"
>Delete</button>
<form id="delete-form-{{ article.id }}" method="post"
action="{% url 'delete-article' workspace.id article.slug %}" style="display:none;">
{% csrf_token %}
</form>
// JS
document.querySelectorAll('.btn-delete-article').forEach(btn => {
btn.addEventListener('click', () => {
const title = btn.dataset.articleTitle;
if (confirm(`Delete "${title}"? This cannot be undone.`)) {
document.getElementById(btn.dataset.formId).submit();
}
});
});
CHARACTER COUNTER FOR EXCERPT:
<!-- HTML -->
<textarea
name="excerpt"
class="excerpt-field"
data-max-length="300"
data-counter-id="excerpt-counter"
maxlength="300"
></textarea>
<span id="excerpt-counter" class="char-counter">0 / 300</span>
// JS
document.querySelectorAll('.excerpt-field').forEach(field => {
const counterId = field.dataset.counterId;
const maxLength = parseInt(field.dataset.maxLength, 10);
const counter = document.getElementById(counterId);
const update = () => {
counter.textContent = `${field.value.length} / ${maxLength}`;
};
field.addEventListener('input', update);
update();
});
SUBMIT LOADING STATE:
<!-- HTML -->
<button type="submit" class="btn-submit-form" data-loading-text="Saving...">
Save Article
</button>
// JS
document.querySelectorAll('.btn-submit-form').forEach(btn => {
btn.closest('form').addEventListener('submit', () => {
btn.disabled = true;
btn.textContent = btn.dataset.loadingText;
});
});
URL Configuration
urlpatterns = [
path('workspace/<int:workspace_id>/kb/',
ArticleListView.as_view(), name='article-list'),
path('workspace/<int:workspace_id>/kb/new/',
ArticleCreateView.as_view(), name='article-create'),
path('workspace/<int:workspace_id>/kb/categories/',
CategoryListView.as_view(), name='category-list'),
path('workspace/<int:workspace_id>/kb/categories/new/',
CategoryCreateView.as_view(), name='category-create'),
path('workspace/<int:workspace_id>/kb/<slug:slug>/',
ArticleDetailView.as_view(), name='article-detail'),
path('workspace/<int:workspace_id>/kb/<slug:slug>/edit/',
ArticleEditView.as_view(), name='article-edit'),
path('workspace/<int:workspace_id>/kb/<slug:slug>/delete/',
ArticleDeleteView.as_view(), name='article-delete'),
]
Note: Place the new/ and categories/ URLs before the <slug:slug>/ pattern to avoid new and categories being matched as slugs.
Permissions Summary
| Action | Member | Editor | Admin / Owner |
|---|---|---|---|
| Read published articles | Yes | Yes | Yes |
| Read draft articles | Own only | Own only | All |
| Create / Edit articles | No | Yes | Yes |
| Delete articles | No | No | Yes |
| Manage categories | No | No | Yes |
What You Should NOT Do
× Filter draft/published visibility in the template — do it in the queryset
× Skip the slug uniqueness check — two articles with the same title would collide
× Let member-role users access the create/edit views (check role, raise PermissionDenied)
× Let editor-role users delete articles (admin/owner only)
× Pass article content to JavaScript via {{ variable }} in a script tag
× Use client-side search filtering — search must query the database
× Not extend base.html in every template
× Add <style> blocks to templates — all CSS in static/css/kb.css
× Skip CSRF token on any POST form (delete, category create)
× Use {{ form.as_p }} — render each field individually
× Generate slugs with collisions — use _unique_slug() with suffix fallback