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:
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:
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...)
- Explain the difference between Django and FastAPI, and when to use each.
- Start a project (
startproject) and an app (startapp), and explain the job of each main file. - Change
settings.py(INSTALLED_APPS, LANGUAGE_CODE, TIME_ZONE, TEMPLATES). - Connect a URL to a view (function-based), including URLs with parameters (
<int:pk>). - Use templates:
{{ variable }},{% if %},{% for %}, filters,{% url %}, and template inheritance (base.html). - Set up static files (CSS) with
{% load static %}. - Write models, and run
makemigrationsandmigrate. - Use the Django admin (
createsuperuser,admin.site.register,ModelAdmin). - Write ORM queries:
create,all,filter,get,exclude,order_by,count,__icontains. - 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
- Review of the Week 14 homework (Recipe Assistant)
- Django vs FastAPI; the map of a request in Django
- Installing,
startproject,runserver, the folder structure settings.py: the important partsstartapp, the first view, URLs, URLs with parameters- In-class exercise
- 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):
- The user opens
http://127.0.0.1:8000/blog/. - Django looks at
config/urls.py→ it sees thatblog/is directed toblog/urls.py. blog/urls.pyshows which view to call.- The view (a function in
views.py) gets data from the Model (database) if needed. - 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.pyis 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
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:
wiki15_django/
├── venv/
├── manage.py
└── config/
├── __init__.py
├── settings.py
├── urls.py
├── asgi.py
└── wsgi.py
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:
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 beFalsein 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
python manage.py startapp blog
Register it in config/settings.py:
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
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:
requestis like the request object; a view must return anHttpResponse(or something that returns one).
Step 5: The app's URLs — create a new file blog/urls.py
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
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 ]
python manage.py runserver
Try in the browser:
- http://127.0.0.1:8000/ → "Karibu kwenye Blog yetu!" (Welcome to our Blog!)
- http://127.0.0.1:8000/kuhusu/
- http://127.0.0.1:8000/salamu/Amina/ → "Habari Amina!" (Hello Amina!)
- http://127.0.0.1:8000/post/5/ → "post namba 5 ... Aina ya pk: int" (post number 5 ... type of pk: int)
- http://127.0.0.1:8000/post/abc/ → Page not found (404), because
<int:pk>only accepts numbers. Show the yellow DEBUG page listing the available URLs: it is a good friend for finding mistakes.
Step 7: Git
.gitignore:
venv/ __pycache__/ db.sqlite3 .env
git init git add . git commit -m "Wiki 15: mradi wa Django na app ya blog"
Questions to ask the student
- 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.
- Question: And a price prediction API (Week 13)? Answer: FastAPI: small, fast, returns JSON.
- 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.
- Question: Why does
/post/abc/give a 404? Answer:<int:pk>only accepts numbers; "abc" doesn't match any pattern. - 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
# 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>")
# 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
- Review of the homework (Session 1)
- Why templates? The problem of HTML inside Python
render(), context,{{ }},{% if %},{% for %}, filters- Template inheritance:
base.html,{% block %},{% extends %},{% url %} - Static files: CSS with
{% load static %} - In-class exercise
- 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.htmlis 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)
blog/
├── templates/
│ └── blog/
│ ├── base.html
│ ├── nyumbani.html
│ └── kuhusu.html
└── static/
└── blog/
└── style.css
Step 2: blog/templates/blog/base.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>© 2027 Blog Yetu — Imetengenezwa kwa Django</p>
</footer>
</body>
</html>
Step 3: blog/templates/blog/nyumbani.html (home)
{% 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)
{% 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
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
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; }
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 newstaticfolder.
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
- Question: The difference between
{{ }}and{% %}? Answer:{{ }}shows a value;{% %}is a command/logic (if, for, extends, url, load). - Question: Why
blog/templates/blog/and notblog/templates/? Answer: Namespacing: to prevent template name clashes between apps. - Question: What does
{% empty %}do? Answer: It shows something else if theforlist is empty. - Question: The advantage of
{% url 'blog:kuhusu' %}over/kuhusu/? Answer: If the URL is changed inurls.py, all links are updated by themselves. - 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
<!-- 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 %}
# 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})
# blog/urls.py path("bei/", views.bei, name="bei"),
# config/settings.py -> INSTALLED_APPS ongeza: "django.contrib.humanize",
<!-- 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 %}
<!-- 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
- Review of the homework (Session 2)
- Models: describing a database table with a Python class
makemigrationsandmigrate- Django admin:
createsuperuser, registering models,ModelAdmin - ORM in the
shell: create, filter, get, order_by... - Make views use the database: a list of posts + a single post (
get_object_or_404) - In-class exercise
- 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:
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.python manage.py migrate: applies the migrations to the real database.Every time you change
models.py→makemigrations→migrate. The first time,migratealso 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
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
python manage.py makemigrations
Migrations for 'blog':
blog\migrations\0001_initial.py
+ Create model Kundi
+ Create model Post
python manage.py migrate
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.pyand show it: "Django has written the plan by itself."
Step 3: Superuser and Admin
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:
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"]
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
python manage.py shell
>>> 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: "
getis like calling someone by name: there must be exactly one of them.filteris 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)
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:
path("post/<int:pk>/", views.post_detail, name="post_detail"),
In nyumbani.html, change the posts section:
<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:
{% 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' %}">← 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
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)
python manage.py test
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
- Question: You added a field
picha_url(image URL) toPost. Which two steps follow? Answer:makemigrationsthenmigrate. - Question: The difference between
get()andfilter()? Answer:getreturns exactly one object (error if 0 or more than 1);filterreturns a QuerySet (may be empty). - Question:
auto_now_addvsauto_now? Answer:auto_now_addis set once at creation;auto_nowis updated on everysave(). - Question: Why
get_object_or_404instead ofgetin a view? Answer: So a user requesting a post that doesn't exist gets a 404, not a server error 500. - Question: What does
on_delete=models.CASCADEmean? 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()anduser.posts.count()) - c) The oldest post
Answer
# 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}'"
# blog/admin.py (ongeza) from .models import Maoni @admin.register(Maoni) class MaoniAdmin(admin.ModelAdmin): list_display = ["jina", "post", "tarehe"]
python manage.py makemigrations python manage.py migrate
<!-- 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 %}
# 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)
python manage.py startapp mapishi, register it, route it withpath("mapishi/", include("mapishi.urls")).- 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. - Register it in the admin with
list_display,search_fieldsandlist_filter. Add 4 recipes (pilau, ugali, chapati, coconut rice). - A page
/mapishi/listing all the recipes (inheriting fromblog/base.html), and/mapishi/<pk>/for a single recipe. - On the list page, allow filtering with
?rahisi=1to show only easy recipes, and show the time as "dakika X" (X minutes). - 2 tests: the list returns 200, a recipe that doesn't exist is 404.
Answer
# 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])
# 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"]
# 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})
# 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"), ]
# config/urls.py urlpatterns = [ path("admin/", admin.site.urls), path("mapishi/", include("mapishi.urls")), path("", include("blog.urls")), ] # na "mapishi" kwenye INSTALLED_APPS
<!-- 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 %}
<!-- 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' %}">← Rudi</a> {% endblock %}
# 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".
- Django docs (5.2): https://docs.djangoproject.com/en/5.2/
- Template tags and filters: https://docs.djangoproject.com/en/5.2/ref/templates/builtins/
- QuerySet API: https://docs.djangoproject.com/en/5.2/ref/models/querysets/
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_nameand parameters (<int:pk>) - Uses
renderand context;{{ }},{% if %},{% for %}, filters - Has a
base.htmland pages that inherit from it ({% extends %},{% block %}) - CSS loads with
{% load static %} - Writes a model, runs
makemigrationsandmigrate - 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 testpasses