Contents

Week 15: Django Part 1 — Django Fundamentals

Month 4 | Main goal: The student starts a Django project, understands its structure (settings, URLs, views, templates, static), creates models and migrations, uses the Django admin and ORM, and ends with a small blog that shows posts from the database.


Teacher Preparation (read before the week starts)

  • Read/do first: The official Django tutorial, parts 1–3 and 7:
  • Then: Build this week's whole project (wiki15_django) yourself by following "Type this code live" for Sessions 1–3. Don't go to class without having run it.
  • Version: We use Django 5.2 (LTS: long-term support). It needs Python 3.10+.
  • Install:

    powershell
    mkdir wiki15_django
    cd wiki15_django
    python -m venv venv
    .\venv\Scripts\Activate.ps1
    pip install "Django>=5.2,<6.0"
    python -m django --version
    
  • VS Code: Recommend the "Django" extension (by Baptiste Darthenay): it colours the {% %} and {{ }} templates.

  • Main thing to remember: Django has "many files" at the start. The student may get confused. Draw the map (Session 1) and return to it every session:

    text
    Browser -> urls.py -> views.py -> (models.py <-> database) -> template.html -> Browser
    

Week Goals (by the end of the week the student will be able to...)

  1. Explain the difference between Django and FastAPI, and when to use each.
  2. Start a project (startproject) and an app (startapp), and explain the job of each main file.
  3. Change settings.py (INSTALLED_APPS, LANGUAGE_CODE, TIME_ZONE, TEMPLATES).
  4. Connect a URL to a view (function-based), including URLs with parameters (<int:pk>).
  5. Use templates: {{ variable }}, {% if %}, {% for %}, filters, {% url %}, and template inheritance (base.html).
  6. Set up static files (CSS) with {% load static %}.
  7. Write models, and run makemigrations and migrate.
  8. Use the Django admin (createsuperuser, admin.site.register, ModelAdmin).
  9. Write ORM queries: create, all, filter, get, exclude, order_by, count, __icontains.
  10. Show posts from the database on a list page and a single-post page.

Session 1 — What is Django? Project, App, URLs and Views

Session flow

  1. Review of the Week 14 homework (Recipe Assistant)
  2. Django vs FastAPI; the map of a request in Django
  3. Installing, startproject, runserver, the folder structure
  4. settings.py: the important parts
  5. startapp, the first view, URLs, URLs with parameters
  6. In-class exercise
  7. Q&A + homework

Explanation for the teacher (read this first)

What is Django? It is a Python web framework with "batteries included": it comes with an ORM (database), a template system (HTML), a ready-made admin panel, authentication (login), forms, and security protections (CSRF, SQL injection, XSS). Instagram, Pinterest and Mozilla have used it.

Django vs FastAPI:

FastAPI (which we learned in Month 3) Django (Weeks 15–16)
What it usually returns JSON (an API for other apps/frontends) Full HTML pages (a website); it can also do JSON (with Django REST Framework)
Database You choose (we used SQLModel) Its own built-in ORM
Admin panel None Ready-made!
Login/users We wrote JWT ourselves Built in
Size Small, you add what you need Large, everything is there
Suited to Fast APIs, ML/AI endpoints, microservices Full websites: blogs, shops, school/hospital systems

Project vs App:

  • Project = the whole website (the overall settings). We call it config.
  • App = one part with a specific job inside the project (e.g. blog, duka (shop), accounts). One project can have many apps.

The map of a request — MVT (Model-View-Template):

  1. The user opens http://127.0.0.1:8000/blog/.
  2. Django looks at config/urls.py → it sees that blog/ is directed to blog/urls.py.
  3. blog/urls.py shows which view to call.
  4. The view (a function in views.py) gets data from the Model (database) if needed.
  5. The view sends the data to the Template (HTML) and returns the page to the browser.

Django calls this "MVT": Model (data), View (logic), Template (appearance). It is the same as the "MVC" of other frameworks.

Important files after startproject:

  • manage.py: the project's "remote control": runserver, migrate, createsuperuser, shell...
  • config/settings.py: all the settings.
  • config/urls.py: the main URLs (the first door).
  • config/wsgi.py, config/asgi.py: for deployment (we don't touch them now).

App files (startapp blog): views.py, models.py, admin.py, apps.py, tests.py, migrations/. There is no urls.py: we create it ourselves.

runserver is a development server only: it isn't suitable for production. It restarts itself when you change code (like uvicorn's --reload).

Say this to the student

"FastAPI is like a chips stall: small, light, you add what you need. Django is like a big supermarket: everything is already inside: a guard (security), a manager's office (admin), a storeroom (database), and a counter (templates)."

"The project is the whole school. Apps are departments: the maths department, the library, the canteen. Each department has its own job, but they are all under one school with one set of rules (settings)."

"urls.py is like a hospital's reception: 'You want dental? Go to room 3.' The view is the doctor in that room. The template is the report he hands you."

Type this code live (share screen)

Step 1: Start the project

powershell
cd wiki15_django
.\venv\Scripts\Activate.ps1
django-admin startproject config .

Watch the dot . at the end! It means "create it right here". Without it you get a folder inside a folder (config/config/), which is confusing.

Structure:

text
wiki15_django/
├── venv/
├── manage.py
└── config/
    ├── __init__.py
    ├── settings.py
    ├── urls.py
    ├── asgi.py
    └── wsgi.py
powershell
python manage.py runserver

Open http://127.0.0.1:8000 → the rocket page "The install worked successfully!" 🚀

The terminal will show a warning "You have 18 unapplied migration(s)". Say: "We will deal with this in Session 3. It's normal."
Stop the server: Ctrl + C.

Step 2: Settings — open config/settings.py and change:

python
LANGUAGE_CODE = "sw"                    # Kiswahili (admin itakuwa sehemu kwa Kiswahili)
TIME_ZONE = "Africa/Dar_es_Salaam"

Explain the other parts without changing them:

  • SECRET_KEY: the project's secret (don't commit it on a real project; we will see this in Week 16).
  • DEBUG = True: shows errors in detail. It must be False in production.
  • INSTALLED_APPS: the list of apps in use.
  • DATABASES: the default is SQLite (db.sqlite3), as we used with SQLModel.

Step 3: Create an app

powershell
python manage.py startapp blog

Register it in config/settings.py:

python
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "blog",                 # <- app yetu
]

Step 4: The first view — blog/views.py

python
from django.http import HttpResponse


def nyumbani(request):
    return HttpResponse("<h1>Karibu kwenye Blog yetu!</h1><p>Imetengenezwa kwa Django.</p>")


def kuhusu(request):
    return HttpResponse("<h1>Kuhusu sisi</h1><p>Sisi ni wanafunzi wa Python.</p>")


def salamu(request, jina):
    return HttpResponse(f"<h1>Habari {jina}!</h1>")


def post_namba(request, pk):
    return HttpResponse(f"<h1>Hii ni post namba {pk}</h1><p>Aina ya pk: {type(pk).__name__}</p>")

Compare with FastAPI: request is like the request object; a view must return an HttpResponse (or something that returns one).

Step 5: The app's URLs — create a new file blog/urls.py

python
from django.urls import path
from . import views

app_name = "blog"     # "namespace" — inasaidia kutaja URLs kwa jina: "blog:nyumbani"

urlpatterns = [
    path("", views.nyumbani, name="nyumbani"),
    path("kuhusu/", views.kuhusu, name="kuhusu"),
    path("salamu/<str:jina>/", views.salamu, name="salamu"),
    path("post/<int:pk>/", views.post_namba, name="post_namba"),
]

Step 6: Connect to the main URLs — config/urls.py

python
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    path("", include("blog.urls")),     # blog itakuwa ukurasa wa mwanzo
]
powershell
python manage.py runserver

Try in the browser:

Step 7: Git .gitignore:

text
venv/
__pycache__/
db.sqlite3
.env
powershell
git init
git add .
git commit -m "Wiki 15: mradi wa Django na app ya blog"

Questions to ask the student

  1. Question: Would you use Django or FastAPI to build a school results system with pages for teachers and an admin? Answer: Django: it has HTML pages, admin and login built in.
  2. Question: And a price prediction API (Week 13)? Answer: FastAPI: small, fast, returns JSON.
  3. Question: The difference between a project and an app? Answer: A project is the whole website and settings; an app is one part with a specific job.
  4. Question: Why does /post/abc/ give a 404? Answer: <int:pk> only accepts numbers; "abc" doesn't match any pattern.
  5. Question: What is the job of include("blog.urls")? Answer: It hands URLs starting with that path over to the blog app's URLs file.

Common mistakes

Mistake Message Fix
Forgetting the . in startproject Folder config/config/ Delete the folder, start again with django-admin startproject config .
django-admin not recognised django-admin : The term 'django-admin' is not recognized Activate the venv; or use python -m django startproject config .
Forgetting to register the app The app's templates/models don't show; TemplateDoesNotExist Add "blog" to INSTALLED_APPS
Forgetting the / at the end of a path Works, but the URL is confusing Every path should end with /: "kuhusu/"
A forgotten comma in urlpatterns SyntaxError or TypeError: 'tuple' object is not callable Put a , at the end of each path(...)
View returns nothing ValueError: The view blog.views.kuhusu didn't return an HttpResponse object. It returned None instead. Add return HttpResponse(...)
Port in use Error: That port is already in use. Close the other server or python manage.py runserver 8001
Running manage.py from a different folder can't open file 'manage.py' cd to the folder containing manage.py

In-class exercise

Exercise 1.1: Add a view mawasiliano (contact) at the URL /mawasiliano/ that shows the blog's phone number and email.

Exercise 1.2: Add a URL /jumlisha/<int:a>/<int:b>/ (jumlisha = add) that shows the sum of two numbers. E.g. /jumlisha/7/5/ → "7 + 5 = 12".

Exercise 1.3 (challenge): Add a URL /mwaka/<int:mwaka>/ (mwaka = year) that shows "Mwaka 2027 una siku 365" (Year 2027 has 365 days; or 366 for a leap year). Hint: calendar.isleap(mwaka).

Answer
python
# blog/views.py (ongeza)
import calendar
from django.http import HttpResponse


def mawasiliano(request):
    return HttpResponse("<h1>Mawasiliano</h1><p>Simu: 0712 345 678</p><p>Email: blog@mfano.co.tz</p>")


def jumlisha(request, a, b):
    return HttpResponse(f"<h1>{a} + {b} = {a + b}</h1>")


def mwaka(request, mwaka):
    siku = 366 if calendar.isleap(mwaka) else 365
    return HttpResponse(f"<h1>Mwaka {mwaka} una siku {siku}</h1>")
python
# blog/urls.py (ongeza kwenye urlpatterns)
    path("mawasiliano/", views.mawasiliano, name="mawasiliano"),
    path("jumlisha/<int:a>/<int:b>/", views.jumlisha, name="jumlisha"),
    path("mwaka/<int:mwaka>/", views.mwaka, name="mwaka"),

Session 2 — Templates, Inheritance and Static files

Session flow

  1. Review of the homework (Session 1)
  2. Why templates? The problem of HTML inside Python
  3. render(), context, {{ }}, {% if %}, {% for %}, filters
  4. Template inheritance: base.html, {% block %}, {% extends %}, {% url %}
  5. Static files: CSS with {% load static %}
  6. In-class exercise
  7. Q&A + homework

Explanation for the teacher (read this first)

The problem: Writing HTML inside HttpResponse("...") is hard for long pages. A template is an HTML file with "blanks" to be filled with data from the view.

render(request, "jina_la_template.html", context) (template name): it reads the template, fills it with context (a dict), and returns an HttpResponse.

Where templates go: Django looks for a templates/ folder inside each registered app (APP_DIRS: True in settings). We use blog/templates/blog/: yes, blog twice! This is called namespacing: if two apps both have index.html, Django won't mix them up. So the name we use is "blog/index.html".

The template language (Django Template Language, DTL):

  • {{ jina }}: shows the value of a variable (jina = name). {{ post.kichwa }}: an attribute or dict key (kichwa = title).
  • {% ... %}: commands/logic: {% if %}...{% elif %}...{% else %}...{% endif %}, {% for x in list %}...{% empty %}...{% endfor %}.
  • Filters: {{ jina|upper }}, {{ list|length }}, {{ tarehe|date:"d/m/Y" }}, {{ maandishi|truncatewords:20 }}, {{ thamani|default:"Hakuna" }}.
  • {# comment #}: a comment.
  • Inside {% for %}: forloop.counter (1, 2, 3...).
  • Security: Django does "auto-escape": if a user types <script>, it is shown as text and not run. This prevents XSS attacks.

Template inheritance: All pages have the same header, menu and footer. Instead of copying, we write a base.html with "holes" ({% block content %}{% endblock %}), and then each page says {% extends "blog/base.html" %} and fills in only the holes. {% extends %} must be the first line of the template.

{% url "blog:kuhusu" %}: create a link by the URL's name instead of writing /kuhusu/ (about) by hand. If you change the URL in urls.py, all the links change by themselves. With parameters: {% url "blog:post_detail" post.pk %}.

Static files = CSS, JavaScript, images that don't change. Location: blog/static/blog/style.css (the same namespacing). In the template: {% load static %} then <link rel="stylesheet" href="{% static 'blog/style.css' %}">. During development (DEBUG=True), runserver serves them by itself.

Say this to the student

"A template is like a printed birth certificate form: most of the text is already there, and there are blank spaces for 'Name: ____', 'Date: ____'. The view fills in those spaces."

"base.html is like the walls, roof and door of a house. Each room (page) only decorates the inside; it doesn't rebuild the walls."

"{{ }} is for showing, {% %} is for doing. Double braces to show, percent signs to do work."

Type this code live (share screen)

Step 1: The folder structure (create it in VS Code)

text
blog/
├── templates/
│   └── blog/
│       ├── base.html
│       ├── nyumbani.html
│       └── kuhusu.html
└── static/
    └── blog/
        └── style.css

Step 2: blog/templates/blog/base.html

html
{% load static %}
<!DOCTYPE html>
<html lang="sw">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}Blog Yetu{% endblock %}</title>
    <link rel="stylesheet" href="{% static 'blog/style.css' %}">
</head>
<body>
    <header>
        <h1><a href="{% url 'blog:nyumbani' %}">📝 Blog Yetu</a></h1>
        <nav>
            <a href="{% url 'blog:nyumbani' %}">Nyumbani</a>
            <a href="{% url 'blog:kuhusu' %}">Kuhusu</a>
        </nav>
    </header>

    <main>
        {% block content %}
        {% endblock %}
    </main>

    <footer>
        <p>&copy; 2027 Blog Yetu — Imetengenezwa kwa Django</p>
    </footer>
</body>
</html>

Step 3: blog/templates/blog/nyumbani.html (home)

html
{% extends "blog/base.html" %}

{% block title %}Nyumbani | Blog Yetu{% endblock %}

{% block content %}
    <h2>Karibu, {{ mgeni|title }}!</h2>
    <p>Leo ni {{ leo|date:"l, d F Y" }}.</p>

    {% if posts %}
        <p>Tuna posts {{ posts|length }}:</p>
        <ol>
        {% for post in posts %}
            <li>
                <strong>{{ post.kichwa }}</strong> — {{ post.mwandishi }}
                <br><small>{{ post.maudhui|truncatewords:8 }}</small>
            </li>
        {% endfor %}
        </ol>
    {% else %}
        <p>Hakuna posts bado.</p>
    {% endif %}

    <h3>Mada tunazopenda</h3>
    <ul>
    {% for mada in mada_list %}
        <li>{{ forloop.counter }}. {{ mada }}</li>
    {% empty %}
        <li>Hakuna mada.</li>
    {% endfor %}
    </ul>
{% endblock %}

Step 4: blog/templates/blog/kuhusu.html (about)

html
{% extends "blog/base.html" %}

{% block title %}Kuhusu | Blog Yetu{% endblock %}

{% block content %}
    <h2>Kuhusu sisi</h2>
    <p>Sisi ni wanafunzi wa kozi ya Python ya wiki 16.</p>
    <p>Mwalimu: {{ mwalimu|default:"Hajatajwa" }}</p>
{% endblock %}

Step 5: Make the views use render — blog/views.py

python
from datetime import date

from django.http import HttpResponse
from django.shortcuts import render

# Data ya muda (Ijumaa tutaitoa kwenye database)
POSTS = [
    {"kichwa": "Kujifunza Python", "mwandishi": "Asha",
     "maudhui": "Python ni lugha rahisi na yenye nguvu inayotumika kila mahali duniani leo."},
    {"kichwa": "Machine Learning kwa Kiswahili", "mwandishi": "Juma",
     "maudhui": "Wiki 13 tulijifunza kutabiri bei za viwanja kwa scikit-learn na ilikuwa poa sana."},
    {"kichwa": "Django ni nini?", "mwandishi": "Neema",
     "maudhui": "Django ni framework yenye betri zimejumuishwa kwa kutengeneza websites kamili."},
]


def nyumbani(request):
    context = {
        "mgeni": "mwanafunzi hodari",
        "leo": date.today(),
        "posts": POSTS,
        "mada_list": ["Python", "FastAPI", "AI", "Django"],
    }
    return render(request, "blog/nyumbani.html", context)


def kuhusu(request):
    return render(request, "blog/kuhusu.html", {"mwalimu": "Jina la Mwalimu"})


def salamu(request, jina):
    return HttpResponse(f"<h1>Habari {jina}!</h1>")


def post_namba(request, pk):
    return HttpResponse(f"<h1>Hii ni post namba {pk}</h1>")

Change "Jina la Mwalimu" (Teacher's Name) to your own name.

Step 6: CSS — blog/static/blog/style.css

css
body {
    font-family: "Segoe UI", Arial, sans-serif;
    max-width: 800px;
    margin: 0 auto;
    padding: 0 16px;
    background: #f7f7f2;
    color: #222;
}
header {
    background: #1b5e20;
    color: white;
    padding: 12px 16px;
    border-radius: 0 0 8px 8px;
}
header a { color: white; text-decoration: none; margin-right: 12px; }
header h1 { margin: 0 0 8px; }
main { min-height: 60vh; }
footer { text-align: center; color: #777; border-top: 1px solid #ddd; margin-top: 32px; }
powershell
python manage.py runserver

Open http://127.0.0.1:8000 — a page with a green header, 3 posts, and 4 topics. Click "Kuhusu" (About): the same header and footer (inheritance!).

CSS not showing? Press Ctrl + F5 (the browser keeps the old CSS), and make sure you restarted the server after creating a new static folder.

Step 7: Show auto-escape (security) In nyumbani temporarily change: "mgeni": "<script>alert('hack')</script>" (mgeni = guest) → the page shows text, no alert. Say: "Django protects us without us doing anything." Put it back as it was.

Questions to ask the student

  1. Question: The difference between {{ }} and {% %}? Answer: {{ }} shows a value; {% %} is a command/logic (if, for, extends, url, load).
  2. Question: Why blog/templates/blog/ and not blog/templates/? Answer: Namespacing: to prevent template name clashes between apps.
  3. Question: What does {% empty %} do? Answer: It shows something else if the for list is empty.
  4. Question: The advantage of {% url 'blog:kuhusu' %} over /kuhusu/? Answer: If the URL is changed in urls.py, all links are updated by themselves.
  5. Question: Why isn't a user's <script> run? Answer: Django auto-escapes {{ }}, which prevents XSS.

Common mistakes

Mistake Message Fix
Template in the wrong place / wrong name TemplateDoesNotExist at / blog/nyumbani.html Check the path: blog/templates/blog/nyumbani.html; is the app registered? Restart the server
{% extends %} isn't the first line TemplateSyntaxError: <ExtendsNode: extends "blog/base.html"> must be the first tag in the template. Put {% extends %} at the very top
Forgetting {% load static %} TemplateSyntaxError: Invalid block tag on line 7: 'static'. Did you forget to register or load this tag? Put {% load static %} at the top of the template that uses {% static %} (even if the base has loaded it)
Forgetting {% endif %} / {% endfor %} TemplateSyntaxError: Unclosed tag on line 9: 'if' Close every block
URL name doesn't exist NoReverseMatch: Reverse for 'kuhusu' not found Use the namespace: 'blog:kuhusu'; check name= and app_name
Wrong variable name (No error!) Just a blank space Django silently shows nothing: check the spelling of the key in the context
CSS doesn't change (no error) Ctrl+F5; restart runserver

In-class exercise

Exercise 2.1: Change the mawasiliano view (Session 1) to use a template blog/mawasiliano.html that inherits from base.html, and add it to the menu.

Exercise 2.2: Create a page /bei/ (prices) that shows a table (<table>) of products from a list of dicts: [{"jina": "Sukari", "bei": 3200}, ...] (jina = name, Sukari = sugar, bei = price). A product with a price > 3000 should be shown in red (use {% if %}). Show prices with commas (hint: add "django.contrib.humanize" to INSTALLED_APPS, {% load humanize %}, then {{ bei|intcomma }}).

Exercise 2.3: Add a new block {% block hero %}{% endblock %} to base.html above main, and fill it on the home page only with a welcome message.

Answer
html
<!-- blog/templates/blog/mawasiliano.html -->
{% extends "blog/base.html" %}
{% block title %}Mawasiliano{% endblock %}
{% block content %}
    <h2>Mawasiliano</h2>
    <p>Simu: {{ simu }}</p>
    <p>Email: {{ email }}</p>
{% endblock %}
python
# blog/views.py
def mawasiliano(request):
    return render(request, "blog/mawasiliano.html",
                  {"simu": "0712 345 678", "email": "blog@mfano.co.tz"})


BIDHAA = [
    {"jina": "Sukari", "bei": 3200},
    {"jina": "Unga", "bei": 1800},
    {"jina": "Mafuta", "bei": 4500},
    {"jina": "Chumvi", "bei": 600},
]


def bei(request):
    return render(request, "blog/bei.html", {"bidhaa": BIDHAA})
python
# blog/urls.py
    path("bei/", views.bei, name="bei"),
python
# config/settings.py -> INSTALLED_APPS ongeza:
    "django.contrib.humanize",
html
<!-- blog/templates/blog/bei.html -->
{% extends "blog/base.html" %}
{% load humanize %}
{% block title %}Bei za bidhaa{% endblock %}
{% block content %}
<h2>Bei za bidhaa</h2>
<table border="1" cellpadding="6">
    <tr><th>#</th><th>Bidhaa</th><th>Bei (TSh)</th></tr>
    {% for b in bidhaa %}
    <tr>
        <td>{{ forloop.counter }}</td>
        <td>{{ b.jina }}</td>
        <td {% if b.bei > 3000 %}style="color: red;"{% endif %}>{{ b.bei|intcomma }}</td>
    </tr>
    {% endfor %}
</table>
{% endblock %}
html
<!-- 2.3: kwenye base.html, juu ya <main> -->
    {% block hero %}{% endblock %}

<!-- kwenye nyumbani.html, baada ya block title -->
{% block hero %}
    <div style="background:#c8e6c9; padding:16px; border-radius:8px; margin:12px 0;">
        <strong>Karibu!</strong> Soma makala mpya kila wiki.
    </div>
{% endblock %}

In the base.html menu: <a href="{% url 'blog:mawasiliano' %}">Mawasiliano</a> and <a href="{% url 'blog:bei' %}">Bei</a>.


Session 3 — Models, Migrations, Admin and ORM

Session flow

  1. Review of the homework (Session 2)
  2. Models: describing a database table with a Python class
  3. makemigrations and migrate
  4. Django admin: createsuperuser, registering models, ModelAdmin
  5. ORM in the shell: create, filter, get, order_by...
  6. Make views use the database: a list of posts + a single post (get_object_or_404)
  7. In-class exercise
  8. Q&A + the week's homework

Explanation for the teacher (read this first)

Model = a Python class that represents a database table. Each attribute is a column; each object is a row. It is like the SQLModel we used with FastAPI, but Django's own.

Common field types: | Field | Stores | Example | |-------|-----------|-------| | CharField(max_length=200) | short text | title | | TextField() | long text | content | | IntegerField(), DecimalField(max_digits, decimal_places) | numbers | price | | BooleanField(default=True) | true/false | published | | DateTimeField(auto_now_add=True) | creation date (set automatically once) | date | | DateTimeField(auto_now=True) | updated on every save | updated | | ForeignKey(Model, on_delete=...) | a "many to one" relationship | post → author |

on_delete=models.CASCADE = if the author is deleted, their posts are deleted. SET_NULL (together with null=True) = they remain without a category.

__str__: how the object is displayed (in the admin and shell). Without it you see "Post object (1)".

class Meta: ordering = ["-tarehe"]: the default ordering (newest first; - means descending; tarehe = date).

Migrations are the "change history" of the database structure:

  1. python manage.py makemigrations: Django compares the models with the existing migrations, and writes a new file (e.g. blog/migrations/0001_initial.py) describing the changes. It doesn't touch the database yet.
  2. python manage.py migrate: applies the migrations to the real database.

    Every time you change models.py → makemigrations → migrate. The first time, migrate also creates the tables for Django's own apps (users, sessions, admin): those are the "18 unapplied migrations".

User: Django already has a User model (django.contrib.auth). For a ForeignKey we use settings.AUTH_USER_MODEL, the recommended way.

Admin: A ready-made page for managing data at /admin/. You need (1) a superuser, (2) to register models in admin.py. ModelAdmin improves its appearance: list_display (columns), search_fields (a search box), list_filter (filters at the side).

ORM (Object-Relational Mapper): we write Python, Django writes SQL: | Python (ORM) | Meaning | |------|------| | Post.objects.all() | all posts | | Post.objects.filter(imechapishwa=True) | those matching the condition (a QuerySet; may be empty) | | Post.objects.exclude(...) | those not matching | | Post.objects.get(pk=1) | exactly ONE object (error if none or several) | | .order_by("kichwa") / .order_by("-tarehe") | ordering | | .count() | count | | .first() | the first or None | | filter(kichwa__icontains="python") | contains a word (ignoring upper/lower case) | | filter(tarehe__year=2027) | date lookups | | filter(kundi__jina="Teknolojia") | through a relationship (ForeignKey) | | Post.objects.create(...) | create and save | | post.save() / post.delete() | save changes / delete |

A QuerySet is "lazy": it doesn't query the database until you use it (loop, print, count). You can chain: Post.objects.filter(...).exclude(...).order_by(...).

get_object_or_404(Post, pk=pk): like get, but instead of a server error (500) it returns a 404 page, which is correct for a user who typed the URL of a post that doesn't exist.

Say this to the student

"A model is like designing a student registration form: 'Name: 100 characters', 'Date of birth: a date'. The database is a filing cabinet; each filled-in form is one row."

"makemigrations is drawing up the plan for renovating a house. migrate is the builders coming to actually build according to the plan."

"The admin is a ready-made manager's office: Django has built it for you for free. Many companies use just this for their internal staff."

"The ORM is an interpreter: you speak Python, the database speaks SQL. The ORM translates in between."

Type this code live (share screen)

Step 1: Models — blog/models.py

python
from django.conf import settings
from django.db import models
from django.urls import reverse


class Kundi(models.Model):
    jina = models.CharField(max_length=50, unique=True)

    class Meta:
        verbose_name_plural = "Makundi"
        ordering = ["jina"]

    def __str__(self):
        return self.jina


class Post(models.Model):
    kichwa = models.CharField(max_length=200)
    maudhui = models.TextField()
    kundi = models.ForeignKey(Kundi, on_delete=models.SET_NULL, null=True, blank=True,
                              related_name="posts")
    mwandishi = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
                                  related_name="posts")
    imechapishwa = models.BooleanField(default=True)
    tarehe = models.DateTimeField(auto_now_add=True)
    imesasishwa = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["-tarehe"]

    def __str__(self):
        return self.kichwa

    def get_absolute_url(self):
        return reverse("blog:post_detail", args=[self.pk])

Step 2: Migrations

powershell
python manage.py makemigrations
text
Migrations for 'blog':
  blog\migrations\0001_initial.py
    + Create model Kundi
    + Create model Post
powershell
python manage.py migrate
text
Operations to perform:
  Apply all migrations: admin, auth, blog, contenttypes, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  ...
  Applying blog.0001_initial... OK
  Applying sessions.0001_initial... OK

Open blog/migrations/0001_initial.py and show it: "Django has written the plan by itself."

Step 3: Superuser and Admin

powershell
python manage.py createsuperuser

Answer the prompts (name, email can be left blank, password twice; it doesn't show as you type, which is normal).

blog/admin.py:

python
from django.contrib import admin
from .models import Kundi, Post


@admin.register(Kundi)
class KundiAdmin(admin.ModelAdmin):
    list_display = ["jina"]


@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
    list_display = ["kichwa", "mwandishi", "kundi", "imechapishwa", "tarehe"]
    list_filter = ["imechapishwa", "kundi", "tarehe"]
    search_fields = ["kichwa", "maudhui"]
    list_editable = ["imechapishwa"]
powershell
python manage.py runserver

Open http://127.0.0.1:8000/admin/ → log in → add 2 categories ("Teknolojia" (Technology), "Maisha" (Life)) and 3–4 posts (one with imechapishwa (published) = no). Show searching and filters.

Step 4: ORM in the shell

powershell
python manage.py shell
python
>>> from blog.models import Post, Kundi
>>> from django.contrib.auth.models import User
>>> Post.objects.all()
<QuerySet [<Post: ...>, <Post: ...>, ...]>
>>> Post.objects.count()
4
>>> mimi = User.objects.first()
>>> tech = Kundi.objects.get(jina="Teknolojia")
>>> p = Post.objects.create(kichwa="ORM ni rahisi", maudhui="Tunaandika Python, Django inaandika SQL.", kundi=tech, mwandishi=mimi)
>>> p.pk, p.tarehe
(5, datetime.datetime(2027, 2, 19, ...))
>>> Post.objects.filter(imechapishwa=True).count()
4
>>> Post.objects.filter(kichwa__icontains="python")
<QuerySet [...]>
>>> Post.objects.filter(kundi__jina="Teknolojia").order_by("kichwa")
<QuerySet [...]>
>>> tech.posts.all()             # kupitia related_name
<QuerySet [...]>
>>> p.kichwa = "ORM ni rahisi sana"
>>> p.save()
>>> Post.objects.get(pk=999)
Traceback (most recent call last):
  ...
blog.models.Post.DoesNotExist: Post matching query does not exist.
>>> Post.objects.filter(pk=999).first() is None
True
>>> exit()

Say: "get is like calling someone by name: there must be exactly one of them. filter is like asking 'who is here?': there may be no one."

Step 5: Make views use the database — blog/views.py (change nyumbani, remove POSTS and post_namba)

python
from datetime import date

from django.shortcuts import get_object_or_404, render

from .models import Post


def nyumbani(request):
    posts = Post.objects.filter(imechapishwa=True)
    swali = request.GET.get("q", "").strip()      # /?q=python
    if swali:
        posts = posts.filter(kichwa__icontains=swali)
    context = {
        "mgeni": "mwanafunzi hodari",
        "leo": date.today(),
        "posts": posts,
        "swali": swali,
        "mada_list": ["Python", "FastAPI", "AI", "Django"],
    }
    return render(request, "blog/nyumbani.html", context)


def post_detail(request, pk):
    post = get_object_or_404(Post, pk=pk, imechapishwa=True)
    return render(request, "blog/post_detail.html", {"post": post})

# (acha kuhusu, salamu n.k. kama zilivyo)

blog/urls.py: change the post_namba line:

python
    path("post/<int:pk>/", views.post_detail, name="post_detail"),

In nyumbani.html, change the posts section:

html
    <form method="get">
        <input type="text" name="q" value="{{ swali }}" placeholder="Tafuta post...">
        <button type="submit">Tafuta</button>
    </form>

    {% for post in posts %}
        <article>
            <h3><a href="{% url 'blog:post_detail' post.pk %}">{{ post.kichwa }}</a></h3>
            <small>na {{ post.mwandishi.username }} | {{ post.tarehe|date:"d/m/Y H:i" }}
                {% if post.kundi %}| {{ post.kundi }}{% endif %}</small>
            <p>{{ post.maudhui|truncatewords:25 }}</p>
        </article>
    {% empty %}
        <p>Hakuna posts{% if swali %} zinazolingana na "{{ swali }}"{% endif %}.</p>
    {% endfor %}

Create blog/templates/blog/post_detail.html:

html
{% extends "blog/base.html" %}

{% block title %}{{ post.kichwa }} | Blog Yetu{% endblock %}

{% block content %}
    <article>
        <h2>{{ post.kichwa }}</h2>
        <p><small>na {{ post.mwandishi.username }} | {{ post.tarehe|date:"d F Y, H:i" }}
            {% if post.kundi %}| Kundi: {{ post.kundi }}{% endif %}</small></p>
        <div>{{ post.maudhui|linebreaks }}</div>
    </article>
    <p><a href="{% url 'blog:nyumbani' %}">&larr; Rudi kwenye posts zote</a></p>
{% endblock %}

Try it: the home page shows posts from the database; click a title → a single post; /post/999/ → 404; search for "python". Add a post in the admin → refresh → it appears!

Step 6: One Django test — blog/tests.py

python
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse

from .models import Post


class BlogTests(TestCase):
    def setUp(self):
        self.user = User.objects.create_user("asha", password="siri12345")
        self.post = Post.objects.create(kichwa="Habari Django", maudhui="Maudhui", mwandishi=self.user)

    def test_nyumbani_inaonyesha_post(self):
        r = self.client.get(reverse("blog:nyumbani"))
        self.assertEqual(r.status_code, 200)
        self.assertContains(r, "Habari Django")

    def test_post_detail(self):
        r = self.client.get(reverse("blog:post_detail", args=[self.post.pk]))
        self.assertContains(r, "Maudhui")

    def test_post_isiyokuwepo_ni_404(self):
        r = self.client.get(reverse("blog:post_detail", args=[999]))
        self.assertEqual(r.status_code, 404)
powershell
python manage.py test
text
Found 3 test(s).
Creating test database for alias 'default'...
...
Ran 3 tests in 0.05s
OK

Explain: Django creates a temporary database for tests, so your real data isn't touched.

Questions to ask the student

  1. Question: You added a field picha_url (image URL) to Post. Which two steps follow? Answer: makemigrations then migrate.
  2. Question: The difference between get() and filter()? Answer: get returns exactly one object (error if 0 or more than 1); filter returns a QuerySet (may be empty).
  3. Question: auto_now_add vs auto_now? Answer: auto_now_add is set once at creation; auto_now is updated on every save().
  4. Question: Why get_object_or_404 instead of get in a view? Answer: So a user requesting a post that doesn't exist gets a 404, not a server error 500.
  5. Question: What does on_delete=models.CASCADE mean? Answer: If the author is deleted, all their posts are deleted too.

Common mistakes

Mistake Message Fix
Forgetting migrate OperationalError: no such table: blog_post python manage.py makemigrations then migrate
Forgetting makemigrations after changing a model OperationalError: no such column: blog_post.imesasishwa makemigrations + migrate
Adding a field with no default to a table with data Django asks: It is impossible to add a non-nullable field ... Select an option: Choose 1 and enter a default value, or add default=... / null=True to the field
get finds nothing Post.DoesNotExist: Post matching query does not exist. Use filter(...).first() or get_object_or_404
get finds several MultipleObjectsReturned: get() returned more than one Post Use filter, or a unique condition (pk)
ForeignKey without on_delete TypeError: ForeignKey.__init__() missing 1 required positional argument: 'on_delete' Add on_delete=models.CASCADE (or another)
Model doesn't show in admin (no error) Register it in admin.py; restart the server
Forgot the superuser password You can't log in python manage.py changepassword <jina> (jina = username)

In-class exercise

Exercise 3.1: Add a model Maoni (comment): post (ForeignKey to Post, related_name="maoni"), jina (name, CharField 80), ujumbe (message, TextField), tarehe (date, auto_now_add). Do the migrations, register it in admin, add 2 comments to one post.

Exercise 3.2: In post_detail.html, show all of that post's comments and their count (post.maoni.all, post.maoni.count).

Exercise 3.3: In the shell, write queries:

  • a) All published posts in the "Teknolojia" (Technology) category
  • b) The number of posts by each author (hint: loop over User.objects.all() and user.posts.count())
  • c) The oldest post
Answer
python
# blog/models.py (ongeza chini)
class Maoni(models.Model):
    post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="maoni")
    jina = models.CharField(max_length=80)
    ujumbe = models.TextField()
    tarehe = models.DateTimeField(auto_now_add=True)

    class Meta:
        verbose_name_plural = "Maoni"
        ordering = ["tarehe"]

    def __str__(self):
        return f"{self.jina} kuhusu '{self.post}'"
python
# blog/admin.py (ongeza)
from .models import Maoni

@admin.register(Maoni)
class MaoniAdmin(admin.ModelAdmin):
    list_display = ["jina", "post", "tarehe"]
powershell
python manage.py makemigrations
python manage.py migrate
html
<!-- post_detail.html, ndani ya block content baada ya article -->
    <h3>Maoni ({{ post.maoni.count }})</h3>
    {% for m in post.maoni.all %}
        <div style="border-left: 3px solid #1b5e20; padding-left: 8px; margin: 8px 0;">
            <strong>{{ m.jina }}</strong> <small>{{ m.tarehe|date:"d/m/Y" }}</small>
            <p>{{ m.ujumbe }}</p>
        </div>
    {% empty %}
        <p>Hakuna maoni bado. Kuwa wa kwanza!</p>
    {% endfor %}
python
# 3.3 kwenye python manage.py shell
from blog.models import Post
from django.contrib.auth.models import User

# a
Post.objects.filter(kundi__jina="Teknolojia", imechapishwa=True)
# b
for u in User.objects.all():
    print(u.username, u.posts.count())
# c
Post.objects.order_by("tarehe").first()

Homework

(Due: next session)

Project: "Recipe List" (a new app mapishi (recipes) inside the same project)

  1. python manage.py startapp mapishi, register it, route it with path("mapishi/", include("mapishi.urls")).
  2. Model Pishi (recipe): jina (name, CharField 100), viungo (ingredients, TextField), maelekezo (instructions, TextField), muda_dakika (time in minutes, PositiveIntegerField), ni_rahisi (is easy, BooleanField default True), tarehe (date, auto_now_add). __str__ should return the name.
  3. Register it in the admin with list_display, search_fields and list_filter. Add 4 recipes (pilau, ugali, chapati, coconut rice).
  4. A page /mapishi/ listing all the recipes (inheriting from blog/base.html), and /mapishi/<pk>/ for a single recipe.
  5. On the list page, allow filtering with ?rahisi=1 to show only easy recipes, and show the time as "dakika X" (X minutes).
  6. 2 tests: the list returns 200, a recipe that doesn't exist is 404.
Answer
python
# mapishi/models.py
from django.db import models
from django.urls import reverse


class Pishi(models.Model):
    jina = models.CharField(max_length=100)
    viungo = models.TextField()
    maelekezo = models.TextField()
    muda_dakika = models.PositiveIntegerField()
    ni_rahisi = models.BooleanField(default=True)
    tarehe = models.DateTimeField(auto_now_add=True)

    class Meta:
        verbose_name_plural = "Mapishi"
        ordering = ["jina"]

    def __str__(self):
        return self.jina

    def get_absolute_url(self):
        return reverse("mapishi:detail", args=[self.pk])
python
# mapishi/admin.py
from django.contrib import admin
from .models import Pishi


@admin.register(Pishi)
class PishiAdmin(admin.ModelAdmin):
    list_display = ["jina", "muda_dakika", "ni_rahisi", "tarehe"]
    search_fields = ["jina", "viungo"]
    list_filter = ["ni_rahisi"]
python
# mapishi/views.py
from django.shortcuts import get_object_or_404, render
from .models import Pishi


def orodha(request):
    mapishi = Pishi.objects.all()
    rahisi = request.GET.get("rahisi") == "1"
    if rahisi:
        mapishi = mapishi.filter(ni_rahisi=True)
    return render(request, "mapishi/orodha.html", {"mapishi": mapishi, "rahisi": rahisi})


def detail(request, pk):
    pishi = get_object_or_404(Pishi, pk=pk)
    return render(request, "mapishi/detail.html", {"pishi": pishi})
python
# mapishi/urls.py
from django.urls import path
from . import views

app_name = "mapishi"
urlpatterns = [
    path("", views.orodha, name="orodha"),
    path("<int:pk>/", views.detail, name="detail"),
]
python
# config/urls.py
urlpatterns = [
    path("admin/", admin.site.urls),
    path("mapishi/", include("mapishi.urls")),
    path("", include("blog.urls")),
]
# na "mapishi" kwenye INSTALLED_APPS
html
<!-- mapishi/templates/mapishi/orodha.html -->
{% extends "blog/base.html" %}
{% block title %}Mapishi{% endblock %}
{% block content %}
<h2>Mapishi {% if rahisi %}rahisi{% endif %}</h2>
<p>
    <a href="{% url 'mapishi:orodha' %}">Yote</a> |
    <a href="{% url 'mapishi:orodha' %}?rahisi=1">Rahisi tu</a>
</p>
<ul>
{% for p in mapishi %}
    <li><a href="{{ p.get_absolute_url }}">{{ p.jina }}</a> — dakika {{ p.muda_dakika }}
        {% if p.ni_rahisi %}✅{% endif %}</li>
{% empty %}
    <li>Hakuna mapishi.</li>
{% endfor %}
</ul>
{% endblock %}
html
<!-- mapishi/templates/mapishi/detail.html -->
{% extends "blog/base.html" %}
{% block title %}{{ pishi.jina }}{% endblock %}
{% block content %}
<h2>{{ pishi.jina }}</h2>
<p>Muda: dakika {{ pishi.muda_dakika }} | {% if pishi.ni_rahisi %}Rahisi{% else %}Inahitaji ujuzi{% endif %}</p>
<h3>Viungo</h3>
{{ pishi.viungo|linebreaks }}
<h3>Maelekezo</h3>
{{ pishi.maelekezo|linebreaks }}
<a href="{% url 'mapishi:orodha' %}">&larr; Rudi</a>
{% endblock %}
python
# mapishi/tests.py
from django.test import TestCase
from django.urls import reverse
from .models import Pishi


class MapishiTests(TestCase):
    def test_orodha(self):
        Pishi.objects.create(jina="Pilau", viungo="mchele", maelekezo="pika", muda_dakika=60)
        r = self.client.get(reverse("mapishi:orodha"))
        self.assertEqual(r.status_code, 200)
        self.assertContains(r, "Pilau")

    def test_pishi_lisilokuwepo(self):
        r = self.client.get(reverse("mapishi:detail", args=[999]))
        self.assertEqual(r.status_code, 404)

If you are asked a question you don't know

Say: "Good question. Django has better documentation than most frameworks, so let's look together." Search the docs by the thing's name (e.g. "QuerySet API", "built-in template tags"). If you get an error, copy the last line of the error into Google along with the word "django".


End-of-week checklist

  • Explains Django vs FastAPI and knows when to use each
  • Starts a project (startproject config .) and an app, and registers the app
  • Explains the flow: urls → view → model → template
  • Writes URLs with name, app_name and parameters (<int:pk>)
  • Uses render and context; {{ }}, {% if %}, {% for %}, filters
  • Has a base.html and pages that inherit from it ({% extends %}, {% block %})
  • CSS loads with {% load static %}
  • Writes a model, runs makemigrations and migrate
  • Has a superuser, models are registered in admin with list_display/search_fields
  • Uses the ORM in the shell (create, filter, get, order_by, count, __icontains)
  • The list page and single-post page read from the database; 404 works
  • python manage.py test passes