Week 16: Django Part 2 — Forms, CRUD, Class-Based Views, Authentication and the Final Project
Month 4 | Main goal: The student adds the ability to write data through forms (CRUD), uses class-based views, sets up login/logout/registration, then builds a final project that brings everything together, and closes the course.
Teacher Preparation (read before the week starts)
- Make sure the Week 15 project (
wiki15_django) works for you and for the student: theblogapp withPost,Kundi(category), admin,base.html, the list and the single-post page. This week we continue with that same project. - Read:
- Tutorial part 4 (forms and generic views): https://docs.djangoproject.com/en/5.2/intro/tutorial04/
- ModelForms: https://docs.djangoproject.com/en/5.2/topics/forms/modelforms/
- Class-based views (introduction): https://docs.djangoproject.com/en/5.2/topics/class-based-views/
- Authentication: https://docs.djangoproject.com/en/5.2/topics/auth/default/
- Messages: https://docs.djangoproject.com/en/5.2/ref/contrib/messages/
- Deployment checklist: https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
- PythonAnywhere + Django: https://help.pythonanywhere.com/pages/DeployExistingDjangoProject/
- Something new in Django 5 you should know: Logout must be POST (a form with a button), not an ordinary link (GET). A
<a href="/accounts/logout/">link will return 405 Method Not Allowed. We will show the correct way. - Prepare in advance for Session 3: Open a free PythonAnywhere account (https://www.pythonanywhere.com — "Beginner" account) so you can show the dashboard. Prepare the course completion certificate and the assessment form (the rubric is below).
Week Goals (by the end of the week the student will be able to...)
- Create a
ModelFormand validate data withclean_<field>. - Write CRUD (Create, Read, Update, Delete) views with function-based views, including
{% csrf_token %}. - Show messages to the user with the
messagesframework. - Convert views to class-based views:
ListView,DetailView,CreateView,UpdateView,DeleteView. - Set up login, logout (POST), and registration (signup) with
UserCreationForm. - Protect pages with
@login_required/LoginRequiredMixinand allow only the author to edit their post. - Build a complete Django project (Shop/Blog) independently.
- Explain the main steps of deploying Django online (PythonAnywhere/Render).
- Know the ways to keep learning after the course.
Session 1 — ModelForms, CRUD and Messages
Session flow
- Review of the Week 15 homework (Recipes)
- HTML forms, GET vs POST, CSRF: why?
ModelFormand validation- Create and Update views (the GET/POST pattern)
- Delete and the confirmation page; messages
- In-class exercise
- Q&A + homework
Explanation for the teacher (read this first)
GET vs POST:
- GET = requesting/reading a page (opening a link, searching with
?q=). It should not change data. - POST = sending data that changes something (adding, editing, deleting).
CSRF (Cross-Site Request Forgery) is an attack where a malicious website makes your browser send a POST to a site you are logged into (e.g. "delete my posts"). Django prevents it with a secret token placed in every form: {% csrf_token %}. If you forget it → 403 Forbidden.
ModelForm is a form generated directly from the model. You state the model and fields; Django creates the inputs, validates the data types (e.g. a title of no more than 200 characters), and form.save() saves to the database. Extra validation: a clean_<jina_la_field> method (jina_la_field = field name) that returns the clean value or raises ValidationError.
The form view pattern (memorise it; it's the same everywhere):
Kama request ni POST:
form = Form(request.POST) # jaza form kwa data iliyotumwa
kama form ni sahihi (is_valid):
hifadhi -> ujumbe -> redirect # "Post/Redirect/Get" — refresh haitatuma tena
(vinginevyo: tunaanguka chini na kuonyesha form pamoja na makosa)
Vinginevyo (GET):
form = Form() # form tupu
render template pamoja na form
Why redirect after POST? If the user presses F5 after a POST without a redirect, the browser will send the POST again → two identical posts. A redirect prevents that.
form.save(commit=False): creates the object without saving it yet, so we can add things that aren't in the form (e.g. mwandishi = request.user, mwandishi = author), and then save().
Update = the same thing, but PostForm(request.POST, instance=post): the form is filled with the existing post's data and save() updates instead of creating a new one.
Delete: we ask "Are you sure?" with GET (a confirmation page), and we delete only with POST.
Messages framework: a one-time message ("Post saved!") shown on the next page, which then disappears. messages.success(request, "..."), and then in base.html we show {% for message in messages %}.
The post's author: A post needs a mwandishi (author). For today we will log in through the admin page (the same session works across the whole site), and use @login_required. For now we will set LOGIN_URL = "admin:login"; in Session 2 we will create our own login page.
Say this to the student
"GET is like reading a notice on a board. POST is like taking a letter to an office to request that something be changed. You don't want someone to change something just by reading."
"A CSRF token is like an office stamp on a letter. A letter without our stamp, even if it looks legitimate, gets rejected."
"A ModelForm is like a bank form printed according to the bank's system: it won't let you write letters in the account number space."
"Redirect after POST is like being given a receipt and told 'go to the other window'. You don't stay there pressing 'send' again and again."
Type this code live (share screen)
Step 1: The form — create blog/forms.py
from django import forms from .models import Post class PostForm(forms.ModelForm): class Meta: model = Post fields = ["kichwa", "kundi", "maudhui", "imechapishwa"] labels = { "kichwa": "Kichwa cha post", "kundi": "Kundi", "maudhui": "Maudhui", "imechapishwa": "Chapisha sasa?", } widgets = { "maudhui": forms.Textarea(attrs={"rows": 8}), } def clean_kichwa(self): kichwa = self.cleaned_data["kichwa"].strip() if len(kichwa) < 5: raise forms.ValidationError("Kichwa kiwe na herufi angalau 5.") return kichwa
Step 2: Settings (temporary) — config/settings.py (at the very bottom)
LOGIN_URL = "admin:login" # kwa leo tu; Jumatano tutabadilisha
Step 3: CRUD views — blog/views.py (add/adjust)
from datetime import date from django.contrib import messages from django.contrib.auth.decorators import login_required from django.http import Http404 from django.shortcuts import get_object_or_404, redirect, render from .forms import PostForm from .models import Post def post_detail(request, pk): post = get_object_or_404(Post, pk=pk) # Rasimu (haijachapishwa) inaonekana kwa mwandishi wake tu if not post.imechapishwa and post.mwandishi != request.user: raise Http404("Post haipo") return render(request, "blog/post_detail.html", {"post": post}) @login_required def post_create(request): if request.method == "POST": form = PostForm(request.POST) if form.is_valid(): post = form.save(commit=False) post.mwandishi = request.user post.save() messages.success(request, f'Post "{post.kichwa}" imeongezwa!') return redirect(post) # inatumia post.get_absolute_url() else: form = PostForm() return render(request, "blog/post_form.html", {"form": form}) @login_required def post_update(request, pk): # Mwandishi tu ndiye anapata post yake; wengine 404 post = get_object_or_404(Post, pk=pk, mwandishi=request.user) if request.method == "POST": form = PostForm(request.POST, instance=post) if form.is_valid(): form.save() messages.success(request, "Post imesasishwa.") return redirect(post) else: form = PostForm(instance=post) return render(request, "blog/post_form.html", {"form": form, "post": post}) @login_required def post_delete(request, pk): post = get_object_or_404(Post, pk=pk, mwandishi=request.user) if request.method == "POST": kichwa = post.kichwa post.delete() messages.success(request, f'Post "{kichwa}" imefutwa.') return redirect("blog:nyumbani") return render(request, "blog/post_confirm_delete.html", {"post": post})
Step 4: URLs — blog/urls.py
from django.urls import path from . import views app_name = "blog" urlpatterns = [ path("", views.nyumbani, name="nyumbani"), path("kuhusu/", views.kuhusu, name="kuhusu"), path("post/mpya/", views.post_create, name="post_create"), path("post/<int:pk>/", views.post_detail, name="post_detail"), path("post/<int:pk>/hariri/", views.post_update, name="post_update"), path("post/<int:pk>/futa/", views.post_delete, name="post_delete"), ]
Step 5: Templates
blog/templates/blog/post_form.html (one template for both adding AND editing):
{% extends "blog/base.html" %}
{% block title %}{% if form.instance.pk %}Hariri post{% else %}Post mpya{% endif %}{% endblock %}
{% block content %}
<h2>{% if form.instance.pk %}Hariri: {{ form.instance.kichwa }}{% else %}Andika post mpya{% endif %}</h2>
<form method="post" novalidate>
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Hifadhi</button>
<a href="{% url 'blog:nyumbani' %}">Ghairi</a>
</form>
{% endblock %}
novalidateturns off the browser's checks so we can see Django's validation in class. In a real project you can remove it.
blog/templates/blog/post_confirm_delete.html:
{% extends "blog/base.html" %}
{% block title %}Futa post{% endblock %}
{% block content %}
<h2>Una uhakika unataka kufuta "{{ post.kichwa }}"?</h2>
<p>Kitendo hiki hakiwezi kurudishwa.</p>
<form method="post">
{% csrf_token %}
<button type="submit" style="background:#c62828; color:white;">Ndiyo, futa</button>
<a href="{{ post.get_absolute_url }}">Hapana, rudi</a>
</form>
{% endblock %}
In post_detail.html, add below </article>:
{% if user == post.mwandishi %}
<p>
<a href="{% url 'blog:post_update' post.pk %}">✏️ Hariri</a> |
<a href="{% url 'blog:post_delete' post.pk %}">🗑️ Futa</a>
</p>
{% endif %}
In base.html, add a new-post link to the <nav>, and above {% block content %} show the messages:
<nav> <a href="{% url 'blog:nyumbani' %}">Nyumbani</a> <a href="{% url 'blog:kuhusu' %}">Kuhusu</a> <a href="{% url 'blog:post_create' %}">+ Post mpya</a> </nav>
<main> {% if messages %} {% for message in messages %} <div class="ujumbe ujumbe-{{ message.tags }}">{{ message }}</div> {% endfor %} {% endif %} {% block content %} {% endblock %} </main>
Add to style.css:
.ujumbe { padding: 10px; border-radius: 6px; margin: 12px 0; } .ujumbe-success { background: #c8e6c9; } .ujumbe-error { background: #ffcdd2; } .errorlist { color: #c62828; }
Step 6: Try it
python manage.py runserver, open http://127.0.0.1:8000/post/mpya/ while not logged in → you are sent to the admin login → log in → you are returned to the form (?next=).- Enter the title "Hi" → "Kichwa kiwe na herufi angalau 5." (The title must have at least 5 characters; our validation.)
- Enter a valid post → a green message "Post ... imeongezwa!" (Post ... has been added!) on the post page.
- Edit → delete → message.
- Refresh after saving → no duplicate post (thanks to the redirect).
- On purpose, remove
{% csrf_token %}→ submit → 403 Forbidden (CSRF verification failed). Put it back.
Questions to ask the student
- Question: Why do we delete with POST and not with a GET link? Answer: GET should not change data; a link can be opened by accident or by a robot; POST is protected by CSRF.
- Question: When is
commit=Falseused? Answer: When we need to add data that doesn't come from the form (e.g. the author) before saving. - Question: The difference between
PostForm(request.POST)andPostForm(request.POST, instance=post)? Answer: The first creates a new post; the second updates an existing post. - Question: Why
get_object_or_404(Post, pk=pk, mwandishi=request.user)? Answer: So that nobody can edit/delete someone else's post by changing the number in the URL. - Question: How many times does a
messagesmessage appear? Answer: Only once: on the next page, and then it disappears.
Common mistakes
| Mistake | Message | Fix |
|---|---|---|
Forgetting {% csrf_token %} |
403 Forbidden — CSRF verification failed. Request aborted. |
Put {% csrf_token %} inside every <form method="post"> |
form.save() without an author |
IntegrityError: NOT NULL constraint failed: blog_post.mwandishi_id |
save(commit=False), set mwandishi, then save() |
Forgetting method="post" on the form |
The data goes into the URL (?kichwa=...), nothing is saved |
<form method="post"> |
Returning render instead of redirect after saving |
Refresh creates a duplicate | return redirect(...) |
| Not logged in | You are sent to login / ?next= |
Log in through /admin/ |
post/mpya/ placed AFTER post/<int:pk>/? |
(No problem here because "mpya" (new) isn't an int), but with <str:...> they would clash |
Put specific paths before those with general parameters |
| Messages don't show | (no error) | Add the messages loop to base.html |
In-class exercise
Exercise 1.1: Add validation to PostForm: the content must have at least 10 words (hint: len(maudhui.split()), maudhui = content).
Exercise 1.2: Create a KundiForm and a view kundi_create (/kundi/mpya/, login required) for adding a new category. A category name must not be repeated: see what happens when you try an existing name (unique).
Exercise 1.3: On the single-post page, add a form for adding comments (the Maoni model from Week 15): anyone (even without logging in) can leave a comment.
Answer
# blog/forms.py (ongeza) from .models import Kundi, Maoni class PostForm(forms.ModelForm): # ... Meta na clean_kichwa kama juu ... def clean_maudhui(self): # 1.1 maudhui = self.cleaned_data["maudhui"] if len(maudhui.split()) < 10: raise forms.ValidationError("Maudhui yawe na angalau maneno 10.") return maudhui class KundiForm(forms.ModelForm): # 1.2 class Meta: model = Kundi fields = ["jina"] class MaoniForm(forms.ModelForm): # 1.3 class Meta: model = Maoni fields = ["jina", "ujumbe"] widgets = {"ujumbe": forms.Textarea(attrs={"rows": 3})}
# blog/views.py from .forms import KundiForm, MaoniForm, PostForm @login_required def kundi_create(request): form = KundiForm(request.POST or None) if request.method == "POST" and form.is_valid(): kundi = form.save() messages.success(request, f'Kundi "{kundi}" limeongezwa.') return redirect("blog:nyumbani") return render(request, "blog/kundi_form.html", {"form": form}) # Jina lililopo -> form inaonyesha "Kundi with this Jina already exists." (au kwa Kiswahili) — unique inathibitishwa na ModelForm yenyewe. def post_detail(request, pk): post = get_object_or_404(Post, pk=pk) if not post.imechapishwa and post.mwandishi != request.user: raise Http404("Post haipo") if request.method == "POST": maoni_form = MaoniForm(request.POST) if maoni_form.is_valid(): maoni = maoni_form.save(commit=False) maoni.post = post maoni.save() messages.success(request, "Asante kwa maoni yako!") return redirect(post) else: maoni_form = MaoniForm() return render(request, "blog/post_detail.html", {"post": post, "maoni_form": maoni_form})
# blog/urls.py path("kundi/mpya/", views.kundi_create, name="kundi_create"),
<!-- blog/templates/blog/kundi_form.html --> {% extends "blog/base.html" %} {% block content %} <h2>Kundi jipya</h2> <form method="post">{% csrf_token %}{{ form.as_p }}<button type="submit">Hifadhi</button></form> {% endblock %} <!-- post_detail.html — chini ya orodha ya maoni --> <h4>Toa maoni</h4> <form method="post"> {% csrf_token %} {{ maoni_form.as_p }} <button type="submit">Tuma</button> </form>
Session 2 — Class-Based Views and Authentication
Session flow
- Review of the homework (Session 1)
- Class-based views: ListView, DetailView, CreateView, UpdateView, DeleteView
- Mixins: LoginRequiredMixin, UserPassesTestMixin, SuccessMessageMixin
- Auth: login, logout (POST!), registration (signup)
- In-class exercise
- Q&A + homework
Explanation for the teacher (read this first)
Class-based views (CBVs). As you saw in Session 1, CRUD views repeat a lot: "get the object or 404, show the form, if POST and valid save and redirect". Django has already written these views as classes: we inherit from them (inheritance, the same one from the OOP we learned!) and change only the pieces we need.
| CBV | Job | Default template | Context |
|---|---|---|---|
ListView |
a list | blog/post_list.html |
object_list, post_list (+ page_obj if you set paginate_by) |
DetailView |
one object (by pk) |
blog/post_detail.html |
object, post |
CreateView |
an add form | blog/post_form.html |
form |
UpdateView |
an edit form | blog/post_form.html |
form, object, post |
DeleteView |
confirm + delete | blog/post_confirm_delete.html |
object, post |
Good news: the templates we made in Session 1 have those same names, so they will work without changes!
Methods we often override:
get_queryset(): changes the data fetched (e.g. only published ones, search).get_context_data(): adds things to the context.form_valid(form): does something before saving (e.g. sets the author).success_url: where to go after success. If absent, CreateView/UpdateView use the model'sget_absolute_url(). For DeleteView you must set it; we usereverse_lazy(notreverse) because the URLs haven't been loaded yet when the class is read.- URLs:
views.PostListView.as_view():as_view()turns the class into a function that Django can call.
Mixins are small pieces of behaviour we add by inheriting from more than one class (multiple inheritance). They should go to the left of the main View:
LoginRequiredMixin: like@login_requiredfor CBVs.UserPassesTestMixin: we writetest_func(); if it returnsFalse→ 403 Forbidden.SuccessMessageMixin:success_message = "..."adds a messages message by itself.
Django's authentication is ready-made (django.contrib.auth):
path("accounts/", include("django.contrib.auth.urls"))brings in the URLs:login,logout,password_change,password_reset... (the URL names arelogin,logout, with no namespace).- We only need the template:
registration/login.html(Django looks for it by that name). - Settings:
LOGIN_URL = "login",LOGIN_REDIRECT_URL(after logging in),LOGOUT_REDIRECT_URL(after logging out). - Django 5: logout is POST only. We add a small form with a "Toka" (Log out) button and
{% csrf_token %}. - Registration: Django has no signup view, but it has
UserCreationForm(username + password twice, validating password strength). We use it withCreateView, and then log the user in withlogin(request, user). - In templates,
useris available everywhere:{% if user.is_authenticated %}Habari {{ user.username }}{% endif %}(Habari= Hello). - Passwords are stored as a hash (not plain text); Django does this by itself.
Project-wide templates: We will put registration/ in the project's templates/ folder (not inside an app), and tell Django through TEMPLATES[0]["DIRS"] = [BASE_DIR / "templates"].
Say this to the student
"Function-based views are like cooking a meal from scratch every day. Class-based views are like buying ready-made chapatis to warm up: they're already made, you just add your own sauce (you change the methods you need)."
"A mixin is like adding 'extras' to an order: ListView + LoginRequired + SuccessMessage. Each one adds one ability."
"Login is like showing your ID at the gate. After that you are given a badge (session cookie), and you aren't asked again at each door inside."
"About logout being POST: if logout were an ordinary link, a malicious website could include an image with that link and log you out every time you opened it. Django 5 has closed that door."
Type this code live (share screen)
Step 1: Settings — config/settings.py
TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [BASE_DIR / "templates"], # <- ongeza hii "APP_DIRS": True, "OPTIONS": { "context_processors": [ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", ], }, }, ] # Chini kabisa — badilisha LOGIN_URL ya Jumatatu LOGIN_URL = "login" LOGIN_REDIRECT_URL = "blog:nyumbani" LOGOUT_REDIRECT_URL = "blog:nyumbani"
Step 2: Auth URLs — config/urls.py
from django.contrib import admin from django.urls import include, path urlpatterns = [ path("admin/", admin.site.urls), path("accounts/", include("django.contrib.auth.urls")), path("", include("blog.urls")), ]
Step 3: CBVs — rewrite blog/views.py
from django.contrib.auth import login from django.contrib.auth.forms import UserCreationForm from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.contrib.messages.views import SuccessMessageMixin from django.db.models import Q from django.shortcuts import render from django.urls import reverse_lazy from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView from .forms import PostForm from .models import Post class PostListView(ListView): model = Post template_name = "blog/post_list.html" context_object_name = "posts" paginate_by = 5 def get_queryset(self): qs = Post.objects.filter(imechapishwa=True).select_related("mwandishi", "kundi") swali = self.request.GET.get("q", "").strip() if swali: qs = qs.filter(Q(kichwa__icontains=swali) | Q(maudhui__icontains=swali)) return qs def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["swali"] = self.request.GET.get("q", "") return context class PostDetailView(DetailView): model = Post def get_queryset(self): # Zilizochapishwa kwa wote; rasimu kwa mwandishi wake tu if self.request.user.is_authenticated: return Post.objects.filter(Q(imechapishwa=True) | Q(mwandishi=self.request.user)) return Post.objects.filter(imechapishwa=True) class PostCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): model = Post form_class = PostForm success_message = 'Post "%(kichwa)s" imeongezwa!' def form_valid(self, form): form.instance.mwandishi = self.request.user return super().form_valid(form) class MwandishiPekeeMixin(UserPassesTestMixin): """Ruhusu mwandishi wa post tu.""" def test_func(self): return self.get_object().mwandishi == self.request.user class PostUpdateView(LoginRequiredMixin, MwandishiPekeeMixin, SuccessMessageMixin, UpdateView): model = Post form_class = PostForm success_message = "Post imesasishwa." class PostDeleteView(LoginRequiredMixin, MwandishiPekeeMixin, SuccessMessageMixin, DeleteView): model = Post success_url = reverse_lazy("blog:nyumbani") success_message = "Post imefutwa." class JisajiliView(CreateView): form_class = UserCreationForm template_name = "registration/jisajili.html" success_url = reverse_lazy("blog:nyumbani") def form_valid(self, form): response = super().form_valid(form) # inahifadhi user -> self.object login(self.request, self.object) # mwingize moja kwa moja return response def kuhusu(request): return render(request, "blog/kuhusu.html", {"mwalimu": "Jina la Mwalimu"})
Explain: "Look how
PostCreateViewis only 6 lines; that function in Session 1 was 13."
Step 4: URLs — blog/urls.py
from django.urls import path from . import views app_name = "blog" urlpatterns = [ path("", views.PostListView.as_view(), name="nyumbani"), path("kuhusu/", views.kuhusu, name="kuhusu"), path("jisajili/", views.JisajiliView.as_view(), name="jisajili"), path("post/mpya/", views.PostCreateView.as_view(), name="post_create"), path("post/<int:pk>/", views.PostDetailView.as_view(), name="post_detail"), path("post/<int:pk>/hariri/", views.PostUpdateView.as_view(), name="post_update"), path("post/<int:pk>/futa/", views.PostDeleteView.as_view(), name="post_delete"), ]
Step 5: A list template with pagination — blog/templates/blog/post_list.html
{% extends "blog/base.html" %}
{% block title %}Posts | Blog Yetu{% endblock %}
{% block content %}
<form method="get">
<input type="text" name="q" value="{{ swali }}" placeholder="Tafuta...">
<button type="submit">Tafuta</button>
</form>
{% for post in posts %}
<article>
<h3><a href="{{ post.get_absolute_url }}">{{ post.kichwa }}</a></h3>
<small>na {{ post.mwandishi.username }} | {{ post.tarehe|date:"d/m/Y" }}
{% if post.kundi %}| {{ post.kundi }}{% endif %}</small>
<p>{{ post.maudhui|truncatewords:25 }}</p>
</article>
{% empty %}
<p>Hakuna posts.</p>
{% endfor %}
{% if is_paginated %}
<p>
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}{% if swali %}&q={{ swali|urlencode }}{% endif %}">« Nyuma</a>
{% endif %}
Ukurasa {{ page_obj.number }} kati ya {{ page_obj.paginator.num_pages }}
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}{% if swali %}&q={{ swali|urlencode }}{% endif %}">Mbele »</a>
{% endif %}
</p>
{% endif %}
{% endblock %}
Step 6: Auth templates — create a templates/registration/ folder at the project root (next to manage.py).
templates/registration/login.html:
{% extends "blog/base.html" %}
{% block title %}Ingia{% endblock %}
{% block content %}
<h2>Ingia</h2>
{% if form.errors %}
<p class="errorlist">Jina au nenosiri si sahihi. Jaribu tena.</p>
{% endif %}
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<input type="hidden" name="next" value="{{ next }}">
<button type="submit">Ingia</button>
</form>
<p>Huna akaunti? <a href="{% url 'blog:jisajili' %}">Jisajili hapa</a></p>
{% endblock %}
templates/registration/jisajili.html (sign up):
{% extends "blog/base.html" %}
{% block title %}Jisajili{% endblock %}
{% block content %}
<h2>Fungua akaunti</h2>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Jisajili</button>
</form>
{% endblock %}
Step 7: A menu that knows the user — base.html, change the <nav>:
<nav> <a href="{% url 'blog:nyumbani' %}">Nyumbani</a> <a href="{% url 'blog:kuhusu' %}">Kuhusu</a> {% if user.is_authenticated %} <a href="{% url 'blog:post_create' %}">+ Post mpya</a> <span>Habari, {{ user.username }}!</span> <form method="post" action="{% url 'logout' %}" style="display:inline;"> {% csrf_token %} <button type="submit">Toka</button> </form> {% else %} <a href="{% url 'login' %}">Ingia</a> <a href="{% url 'blog:jisajili' %}">Jisajili</a> {% endif %} </nav>
Step 8: Try everything
- Log out → "Ingia" (Log in) and "Jisajili" (Sign up) appear.
- Sign up as
juma(with a password likePython2027!) → you are logged in straight away. - Create a post as
juma→ a green message. - Log out, log in as superuser, open
/post/<pk ya post ya juma>/hariri/(the pk of juma's post;hariri= edit) → 403 Forbidden (MwandishiPekeeMixin, the author-only mixin, is working). - While logged out, open
/post/mpya/→ you are sent to/accounts/login/?next=/post/mpya/→ when you log in you are returned to the form. - Add 6+ posts → pagination appears.
Step 9: Auth tests — blog/tests.py (add)
from django.contrib.auth.models import User from django.test import TestCase from django.urls import reverse from .models import Post class AuthTests(TestCase): def setUp(self): self.asha = User.objects.create_user("asha", password="Siri-12345") self.juma = User.objects.create_user("juma", password="Siri-12345") self.post = Post.objects.create(kichwa="Post ya Asha", maudhui="maudhui", mwandishi=self.asha) def test_post_mpya_inahitaji_login(self): r = self.client.get(reverse("blog:post_create")) self.assertRedirects(r, f"{reverse('login')}?next={reverse('blog:post_create')}") def test_kuunda_post_ukiwa_umeingia(self): self.client.login(username="asha", password="Siri-12345") r = self.client.post(reverse("blog:post_create"), { "kichwa": "Post mpya kabisa", "maudhui": "Haya ni maudhui", "imechapishwa": True, }) self.assertEqual(r.status_code, 302) self.assertTrue(Post.objects.filter(kichwa="Post mpya kabisa", mwandishi=self.asha).exists()) def test_mtu_mwingine_hawezi_kuhariri(self): self.client.login(username="juma", password="Siri-12345") r = self.client.get(reverse("blog:post_update", args=[self.post.pk])) self.assertEqual(r.status_code, 403) def test_logout_ni_post(self): self.client.login(username="asha", password="Siri-12345") self.assertEqual(self.client.get(reverse("logout")).status_code, 405) self.assertEqual(self.client.post(reverse("logout")).status_code, 302)
python manage.py test blog
Questions to ask the student
- Question: Pros and cons of CBVs? Answer: Pros: short code, reuses common patterns. Cons: hidden "magic" that is hard to understand at first; if you need something unusual, a function view may be easier.
- Question: Why
reverse_lazyinsuccess_urland notreverse? Answer: The class is read while the URLs haven't been loaded yet;reverse_lazywaits until it is needed. - Question: Why should
LoginRequiredMixinbe on the far left? Answer: Mixins are executed from the left; we want the login check to happen first. - Question: If Juma tries to edit Asha's post, what does he get, and why? Answer: 403: the
test_funcofMwandishiPekeeMixinreturnsFalse. - Question: Why is logout POST in Django 5? Answer: To stop another site from logging you out through a link/image (CSRF); POST requires a token.
- Question: Does Django store passwords as they are? Answer: No, it stores a hash; even an admin can't see the real password.
Common mistakes
| Mistake | Message | Fix |
|---|---|---|
Logout with an <a> link |
405 Method Not Allowed (and in the terminal: Method Not Allowed (GET): /accounts/logout/) |
Use <form method="post" action="{% url 'logout' %}"> and {% csrf_token %} |
| Login template not found | TemplateDoesNotExist: registration/login.html |
Create templates/registration/login.html and set "DIRS": [BASE_DIR / "templates"] |
Forgetting .as_view() |
TypeError: View.__init__() takes 1 positional argument but 2 were given |
views.PostListView.as_view() |
reverse instead of reverse_lazy in a class |
ImproperlyConfigured: The included URLconf ... does not appear to have any patterns (circular import) |
Use reverse_lazy |
DeleteView without success_url |
ImproperlyConfigured: No URL to redirect to. Provide a success_url. |
Set success_url = reverse_lazy(...) |
CreateView with neither get_absolute_url nor success_url |
ImproperlyConfigured: No URL to redirect to. Either provide a url or define a get_absolute_url method on the Model. |
Add get_absolute_url to the model (we have it) |
| Mixins at the end instead of the start | The login check doesn't happen | class X(LoginRequiredMixin, ..., CreateView) |
Using {% url 'blog:login' %} |
NoReverseMatch |
Auth URLs have no namespace: {% url 'login' %} |
In-class exercise
Exercise 2.1: Create a page /posts-zangu/ (my posts; PostZanguView, ListView, login required) showing all posts of the logged-in user, including drafts, with the mark "(rasimu)" (draft) on unpublished ones.
Exercise 2.2: Create /mwandishi/<str:username>/ (author) showing that author's published posts (404 if the author doesn't exist; hint: get_object_or_404(User, username=...) inside get_queryset).
Exercise 2.3: Change JisajiliView to add a messages message "Karibu,
Answer
# blog/views.py (ongeza) from django.contrib import messages from django.contrib.auth.models import User from django.shortcuts import get_object_or_404 class PostZanguView(LoginRequiredMixin, ListView): # 2.1 template_name = "blog/posts_zangu.html" context_object_name = "posts" def get_queryset(self): return Post.objects.filter(mwandishi=self.request.user) class PostZaMwandishiView(ListView): # 2.2 template_name = "blog/post_list.html" context_object_name = "posts" paginate_by = 5 def get_queryset(self): self.mwandishi = get_object_or_404(User, username=self.kwargs["username"]) return Post.objects.filter(mwandishi=self.mwandishi, imechapishwa=True) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["mwandishi"] = self.mwandishi return context class JisajiliView(CreateView): # 2.3 form_class = UserCreationForm template_name = "registration/jisajili.html" success_url = reverse_lazy("blog:nyumbani") def form_valid(self, form): response = super().form_valid(form) login(self.request, self.object) messages.success(self.request, f"Karibu, {self.object.username}! Akaunti yako imefunguliwa.") return response
# blog/urls.py path("posts-zangu/", views.PostZanguView.as_view(), name="posts_zangu"), path("mwandishi/<str:username>/", views.PostZaMwandishiView.as_view(), name="mwandishi"),
<!-- blog/templates/blog/posts_zangu.html --> {% extends "blog/base.html" %} {% block title %}Posts zangu{% endblock %} {% block content %} <h2>Posts zangu ({{ posts|length }})</h2> <ul> {% for post in posts %} <li><a href="{{ post.get_absolute_url }}">{{ post.kichwa }}</a> {% if not post.imechapishwa %}<em>(rasimu)</em>{% endif %} — <a href="{% url 'blog:post_update' post.pk %}">hariri</a></li> {% empty %} <li>Bado hujaandika post. <a href="{% url 'blog:post_create' %}">Anza sasa</a></li> {% endfor %} </ul> {% endblock %}
In post_list.html you can add at the top: {% if mwandishi %}<h2>Posts za {{ mwandishi.username }}</h2>{% endif %}, and on each post: <a href="{% url 'blog:mwandishi' post.mwandishi.username %}">{{ post.mwandishi.username }}</a>.
Session 3 — Final Project, Deployment and Closing the Course
Session flow
- Review of the homework (Session 2)
- Explanation of the Final Project: "Duka Letu" (Our Shop), or extending the Blog
- Building the project: the student writes, the teacher guides
- Deployment: production settings + PythonAnywhere steps (and Render briefly)
- Summary of the whole course + what to learn next
- Final assessment (rubric) and the certificate checklist
- Thanks, final questions, project submission date
Explanation for the teacher (read this first)
This session is different. The goal isn't to teach many new concepts, but for the student to show they can combine what they have learned. You are a coach: ask "what is the next step?" before telling them. One session isn't enough to finish the whole project: they will finish at home and submit it on the day you agree on.
Final Project — "Duka Letu" (a new app duka (shop) inside wiki15_django, or a new project):
Minimum requirements (compulsory):
- Model
Bidhaa(product):jina(name),maelezo(description),bei(price, DecimalField),idadi(quantity, stock),aina(type; choices: chakula/vinywaji/vifaa/nguo, i.e. food/drinks/equipment/clothes),muuzaji(seller, FK user),tarehe(date). - Admin with
list_display,search_fields,list_filter. - A product list (ListView) with search
?q=and filtering by type?aina=, pagination. - A single-product page (DetailView); "Imeisha stoo" (Out of stock) if
idadi == 0. - Adding/editing/deleting products (CreateView/UpdateView/DeleteView): logged-in sellers only, and only the product's owner can edit/delete.
- Login/logout/sign-up (the existing ones from the blog are used).
- Messages after every action.
- Template inheritance + CSS.
- At least 4 tests.
- README.md + code on GitHub (without
db.sqlite3,venv/,.env).
Extras (for top marks; choose 1 or more):
- A simple cart (session) or an
Agizo(order) model. - A "Generate description with AI" button that uses the Claude API (Week 14) to write the product's
maelezo. - A JSON endpoint (
JsonResponse) of all products. - Putting it online (PythonAnywhere).
Deployment in brief — key concepts:
runserveris for development only. Online we use a WSGI server (PythonAnywhere handles it; Render usesgunicorn).DEBUG = Falsein production; otherwise people see your code and secrets on the error pages.SECRET_KEYshould be read from an environment variable, not from the code.ALLOWED_HOSTS: the allowed domain names (e.g.jinalako.pythonanywhere.com, wherejinalako= your name).- Static files: With
DEBUG=False, Django doesn't serve CSS by itself. We setSTATIC_ROOT, runpython manage.py collectstatic(it gathers all the CSS into one folder), and then the online server serves them. python manage.py check --deploy: Django tells you the security items you forgot.
Say this to the student
"Throughout this course you have been learning to use the tools: hammer, saw, plane. Today you build your own chair. I'm here beside you as the master craftsman, but the hands are yours."
"Putting a website online is like moving a shop from home (localhost) to the Kariakoo market. You need to lock the doors (DEBUG=False), hide the keys (SECRET_KEY), and put up the shop's name (ALLOWED_HOSTS)."
"Finishing the course isn't the end: it's a licence to start learning on your own. Good programmers learn something new every week."
Type this code live (share screen)
Technique: Give the student control: they share their screen and type; you guide with questions. If time is tight, you write
models.pyandviews.pyquickly, and they do the templates and tests at home. The complete solution is below.
Step 1: App and model
python manage.py startapp duka
Add "duka" to INSTALLED_APPS, and in config/urls.py: path("duka/", include("duka.urls")), (above path("", include("blog.urls"))).
duka/models.py:
from django.conf import settings from django.db import models from django.urls import reverse class Bidhaa(models.Model): class Aina(models.TextChoices): CHAKULA = "chakula", "Chakula" VINYWAJI = "vinywaji", "Vinywaji" VIFAA = "vifaa", "Vifaa vya nyumbani" NGUO = "nguo", "Nguo" jina = models.CharField(max_length=120) maelezo = models.TextField(blank=True) bei = models.DecimalField(max_digits=10, decimal_places=2) idadi = models.PositiveIntegerField(default=0) aina = models.CharField(max_length=20, choices=Aina.choices, default=Aina.CHAKULA) muuzaji = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="bidhaa") tarehe = models.DateTimeField(auto_now_add=True) class Meta: verbose_name_plural = "Bidhaa" ordering = ["-tarehe"] def __str__(self): return self.jina def get_absolute_url(self): return reverse("duka:detail", args=[self.pk]) @property def ipo_stoo(self): return self.idadi > 0
python manage.py makemigrations duka python manage.py migrate
Step 2: Deployment: production settings (show live) — config/settings.py
import os from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-tu-badilisha-kwenye-production") DEBUG = os.environ.get("DJANGO_DEBUG", "1") == "1" ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "127.0.0.1,localhost").split(",") # ... (sehemu nyingine kama zilivyo) ... STATIC_URL = "static/" STATIC_ROOT = BASE_DIR / "staticfiles" # collectstatic inakusanya hapa
Generate a new SECRET_KEY and test:
python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())" $env:DJANGO_DEBUG = "0" python manage.py check --deploy python manage.py collectstatic $env:DJANGO_DEBUG = "1"
check --deploywill give several warnings about HTTPS (SECURE_SSL_REDIRECT,SESSION_COOKIE_SECURE...). Explain: "These are security recommendations. For a learning project it's fine just to read them; for a real client project we act on them."
Addstaticfiles/to.gitignore.
Step 3: PythonAnywhere (explain by showing the dashboard; don't spend long on it)
- Put the code on GitHub:
pip freeze > requirements.txt,git push. -
PythonAnywhere → open a free account → Consoles → Bash:
git clone https://github.com/jinalako/wiki15_django.git cd wiki15_django python3.12 -m venv venv source venv/bin/activate pip install -r requirements.txt python manage.py migrate python manage.py createsuperuser python manage.py collectstatic
Note: on Linux it is
source venv/bin/activate(not Windows'.\venv\Scripts\Activate.ps1). -
Web tab → Add a new web app → Manual configuration → Python 3.12.
- Set the Virtualenv:
/home/jinalako/wiki15_django/venv. - Edit the WSGI configuration file: set the project path and
DJANGO_SETTINGS_MODULE = "config.settings", along with the environment variables (DJANGO_SECRET_KEY,DJANGO_DEBUG=0,DJANGO_ALLOWED_HOSTS=jinalako.pythonanywhere.com). - Static files: URL
/static/→ Directory/home/jinalako/wiki15_django/staticfiles. - Press Reload → open
https://jinalako.pythonanywhere.com. 🎉
Render (briefly): it connects directly to GitHub; you need gunicorn (gunicorn config.wsgi), whitenoise for static files, and usually PostgreSQL instead of SQLite (because Render's free disk is wiped on every deploy). It is the next step after PythonAnywhere.
Course summary (say this)
"We have come a long way! Let's look at our journey:"
| Course stage | What we learned |
|---|---|
| Weeks 1–12 | Core Python, OOP, Git; FastAPI, Pydantic, SQLModel/SQLite, JWT auth, pytest; NumPy, pandas, matplotlib, Jupyter |
| Week 13 | Machine Learning (scikit-learn) and serving a model through FastAPI |
| Week 14 | Generative AI (Claude API), a Swahili chatbot, an AI endpoint |
| Weeks 15–16 | Django: models, templates, admin, forms, auth, the Final Project |
What to learn next (choose one path; don't try them all at once):
- Web Developer path: More HTML/CSS/JavaScript → Django REST Framework → htmx or React → PostgreSQL → Docker → real deployment (Render, Railway, VPS).
- Data/ML path: Statistics → Kaggle competitions → feature engineering → deep learning (PyTorch).
- AI Engineer path: More prompt engineering → RAG (querying your own documents) → AI agents and tool use → model evaluation.
- Every path: Build a portfolio: 3 projects on GitHub with good READMEs; contribute to open source; write a blog about what you are learning (you can use your Django blog!).
- Resources: Django Girls Tutorial (https://tutorial.djangogirls.org), Real Python (https://realpython.com), Kaggle Learn (https://www.kaggle.com/learn), the official Python docs (https://docs.python.org/3/).
Final assessment (Final Project rubric — 100 marks)
| Item | Marks | Criterion |
|---|---|---|
| Models and migrations | 10 | Correct Bidhaa, __str__, get_absolute_url, migrations exist |
| Admin | 5 | Registered with list_display, search_fields, list_filter |
| List + search + filtering + pagination | 15 | Works without errors |
| CRUD (create/update/delete) | 20 | Forms with validation, CSRF, redirect, messages |
| Auth and permissions | 15 | Login required; only the product's owner can edit/delete (403 for others) |
| Templates and appearance | 10 | Inheritance, menu knows the user, CSS |
| Tests | 10 | At least 4 pass (python manage.py test) |
| Git + README | 5 | Meaningful commits; correct .gitignore; README explains how to run it |
| Presentation | 10 | Explains their code and answers questions |
| Extras (AI, cart, deployment, JSON API) | up to +10 |
Recommendation: Marks ≥ 60 = passed the course. ≥ 85 = "with distinction".
Questions to ask the student
- Question: Why
DEBUG = Falsein production? Answer: DEBUG error pages show code, settings and secret information to anyone. - Question: What does
collectstaticdo? Answer: It gathers all static files (from all apps and the admin) intoSTATIC_ROOTso that the online server can serve them. - Question: Why take
SECRET_KEYfrom an environment variable? Answer: So it isn't in the code/GitHub; each environment can have its own. - Question: In the shop project, where do we put the check that only the product's owner can edit it? Answer:
UserPassesTestMixin.test_func(or aget_querysetfiltered bymuuzaji=request.user). - Question: Which concept from this course will you use most? (A reflection question: there is no single correct answer.)
Common mistakes
| Mistake | Message | Fix |
|---|---|---|
DEBUG=False without ALLOWED_HOSTS |
CommandError: You must set settings.ALLOWED_HOSTS if DEBUG is False. |
Set ALLOWED_HOSTS |
| CSS doesn't show online | Unstyled page | STATIC_ROOT + collectstatic + the /static/ mapping in the Web tab |
collectstatic without STATIC_ROOT |
ImproperlyConfigured: You're using the staticfiles app without having set the STATIC_ROOT setting to a filesystem path. |
Set STATIC_ROOT = BASE_DIR / "staticfiles" |
| PythonAnywhere "Something went wrong" | Check the error log in the Web tab | Usually: wrong virtualenv or WSGI path; DisallowedHost → ALLOWED_HOSTS |
DecimalField without max_digits |
SystemCheckError ... DecimalFields must define a 'max_digits' attribute. |
DecimalField(max_digits=10, decimal_places=2) |
| Forgetting migrations on the server | no such table: duka_bidhaa |
python manage.py migrate in the PythonAnywhere Bash console |
db.sqlite3 was committed |
Test data/passwords leak | Add it to .gitignore, git rm --cached db.sqlite3 |
In-class exercise
Exercise 3.1 (Final Project, the in-class part): Complete the Bidhaa model, admin, BidhaaListView (search + type), BidhaaDetailView, BidhaaCreateView and their templates. (Update/Delete, tests and README: at home.)
Answer (complete Final Project solution)
# duka/admin.py from django.contrib import admin from .models import Bidhaa @admin.register(Bidhaa) class BidhaaAdmin(admin.ModelAdmin): list_display = ["jina", "aina", "bei", "idadi", "muuzaji", "tarehe"] list_filter = ["aina", "tarehe"] search_fields = ["jina", "maelezo"] list_editable = ["bei", "idadi"]
# duka/forms.py from django import forms from .models import Bidhaa class BidhaaForm(forms.ModelForm): class Meta: model = Bidhaa fields = ["jina", "aina", "bei", "idadi", "maelezo"] widgets = {"maelezo": forms.Textarea(attrs={"rows": 4})} def clean_bei(self): bei = self.cleaned_data["bei"] if bei <= 0: raise forms.ValidationError("Bei lazima iwe zaidi ya sifuri.") return bei
# duka/views.py from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.contrib.messages.views import SuccessMessageMixin from django.db.models import Q from django.urls import reverse_lazy from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView from .forms import BidhaaForm from .models import Bidhaa class BidhaaListView(ListView): model = Bidhaa template_name = "duka/bidhaa_list.html" context_object_name = "bidhaa_zote" paginate_by = 6 def get_queryset(self): qs = Bidhaa.objects.select_related("muuzaji") q = self.request.GET.get("q", "").strip() aina = self.request.GET.get("aina", "") if q: qs = qs.filter(Q(jina__icontains=q) | Q(maelezo__icontains=q)) if aina: qs = qs.filter(aina=aina) return qs def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx["q"] = self.request.GET.get("q", "") ctx["aina_iliyochaguliwa"] = self.request.GET.get("aina", "") ctx["aina_zote"] = Bidhaa.Aina.choices return ctx class BidhaaDetailView(DetailView): model = Bidhaa template_name = "duka/bidhaa_detail.html" context_object_name = "bidhaa" class BidhaaCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): model = Bidhaa form_class = BidhaaForm template_name = "duka/bidhaa_form.html" success_message = 'Bidhaa "%(jina)s" imeongezwa!' def form_valid(self, form): form.instance.muuzaji = self.request.user return super().form_valid(form) class MuuzajiPekeeMixin(UserPassesTestMixin): def test_func(self): return self.get_object().muuzaji == self.request.user class BidhaaUpdateView(LoginRequiredMixin, MuuzajiPekeeMixin, SuccessMessageMixin, UpdateView): model = Bidhaa form_class = BidhaaForm template_name = "duka/bidhaa_form.html" success_message = "Bidhaa imesasishwa." class BidhaaDeleteView(LoginRequiredMixin, MuuzajiPekeeMixin, SuccessMessageMixin, DeleteView): model = Bidhaa template_name = "duka/bidhaa_confirm_delete.html" context_object_name = "bidhaa" success_url = reverse_lazy("duka:orodha") success_message = "Bidhaa imefutwa."
# duka/urls.py from django.urls import path from . import views app_name = "duka" urlpatterns = [ path("", views.BidhaaListView.as_view(), name="orodha"), path("mpya/", views.BidhaaCreateView.as_view(), name="ongeza"), path("<int:pk>/", views.BidhaaDetailView.as_view(), name="detail"), path("<int:pk>/hariri/", views.BidhaaUpdateView.as_view(), name="hariri"), path("<int:pk>/futa/", views.BidhaaDeleteView.as_view(), name="futa"), ]
<!-- duka/templates/duka/bidhaa_list.html --> {% extends "blog/base.html" %} {% load humanize %} {% block title %}Duka Letu{% endblock %} {% block content %} <h2>🛒 Duka Letu</h2> <form method="get"> <input type="text" name="q" value="{{ q }}" placeholder="Tafuta bidhaa..."> <select name="aina"> <option value="">Aina zote</option> {% for thamani, jina in aina_zote %} <option value="{{ thamani }}" {% if thamani == aina_iliyochaguliwa %}selected{% endif %}>{{ jina }}</option> {% endfor %} </select> <button type="submit">Chuja</button> </form> {% if user.is_authenticated %}<p><a href="{% url 'duka:ongeza' %}">+ Ongeza bidhaa</a></p>{% endif %} {% for b in bidhaa_zote %} <div style="border:1px solid #ddd; border-radius:8px; padding:10px; margin:8px 0;"> <h3><a href="{{ b.get_absolute_url }}">{{ b.jina }}</a></h3> <p>TSh {{ b.bei|floatformat:0|intcomma }} | {{ b.get_aina_display }} | {% if b.ipo_stoo %}Stoo: {{ b.idadi }}{% else %}<strong style="color:#c62828;">Imeisha stoo</strong>{% endif %}</p> </div> {% empty %} <p>Hakuna bidhaa zinazolingana.</p> {% endfor %} {% if is_paginated %} <p> {% if page_obj.has_previous %}<a href="?page={{ page_obj.previous_page_number }}&q={{ q|urlencode }}&aina={{ aina_iliyochaguliwa }}">« Nyuma</a>{% endif %} Ukurasa {{ page_obj.number }}/{{ page_obj.paginator.num_pages }} {% if page_obj.has_next %}<a href="?page={{ page_obj.next_page_number }}&q={{ q|urlencode }}&aina={{ aina_iliyochaguliwa }}">Mbele »</a>{% endif %} </p> {% endif %} {% endblock %}
<!-- duka/templates/duka/bidhaa_detail.html --> {% extends "blog/base.html" %} {% load humanize %} {% block title %}{{ bidhaa.jina }}{% endblock %} {% block content %} <h2>{{ bidhaa.jina }}</h2> <p><strong>Bei:</strong> TSh {{ bidhaa.bei|floatformat:0|intcomma }}</p> <p><strong>Aina:</strong> {{ bidhaa.get_aina_display }}</p> <p><strong>Stoo:</strong> {% if bidhaa.ipo_stoo %}{{ bidhaa.idadi }}{% else %}Imeisha stoo{% endif %}</p> <p><strong>Muuzaji:</strong> {{ bidhaa.muuzaji.username }}</p> <div>{{ bidhaa.maelezo|default:"Hakuna maelezo."|linebreaks }}</div> {% if user == bidhaa.muuzaji %} <p><a href="{% url 'duka:hariri' bidhaa.pk %}">✏️ Hariri</a> | <a href="{% url 'duka:futa' bidhaa.pk %}">🗑️ Futa</a></p> {% endif %} <p><a href="{% url 'duka:orodha' %}">← Rudi dukani</a></p> {% endblock %}
<!-- duka/templates/duka/bidhaa_form.html --> {% extends "blog/base.html" %} {% block title %}{% if form.instance.pk %}Hariri bidhaa{% else %}Bidhaa mpya{% endif %}{% endblock %} {% block content %} <h2>{% if form.instance.pk %}Hariri: {{ form.instance.jina }}{% else %}Ongeza bidhaa{% endif %}</h2> <form method="post"> {% csrf_token %} {{ form.as_p }} <button type="submit">Hifadhi</button> <a href="{% url 'duka:orodha' %}">Ghairi</a> </form> {% endblock %}
<!-- duka/templates/duka/bidhaa_confirm_delete.html --> {% extends "blog/base.html" %} {% block content %} <h2>Futa "{{ bidhaa.jina }}"?</h2> <form method="post"> {% csrf_token %} <button type="submit">Ndiyo, futa</button> <a href="{{ bidhaa.get_absolute_url }}">Hapana</a> </form> {% endblock %}
# duka/tests.py from django.contrib.auth.models import User from django.test import TestCase from django.urls import reverse from .models import Bidhaa class DukaTests(TestCase): def setUp(self): self.neema = User.objects.create_user("neema", password="Siri-12345") self.baraka = User.objects.create_user("baraka", password="Siri-12345") self.sukari = Bidhaa.objects.create(jina="Sukari", bei=3200, idadi=10, aina="chakula", muuzaji=self.neema) self.soda = Bidhaa.objects.create(jina="Soda", bei=1000, idadi=0, aina="vinywaji", muuzaji=self.neema) def test_orodha_na_utafutaji(self): r = self.client.get(reverse("duka:orodha"), {"q": "suk"}) self.assertContains(r, "Sukari") self.assertNotContains(r, "Soda") def test_kuchuja_kwa_aina(self): r = self.client.get(reverse("duka:orodha"), {"aina": "vinywaji"}) self.assertContains(r, "Soda") self.assertNotContains(r, "Sukari") def test_imeisha_stoo(self): r = self.client.get(reverse("duka:detail", args=[self.soda.pk])) self.assertContains(r, "Imeisha stoo") def test_kuongeza_kunahitaji_login(self): r = self.client.get(reverse("duka:ongeza")) self.assertEqual(r.status_code, 302) def test_kuongeza_bidhaa(self): self.client.login(username="baraka", password="Siri-12345") r = self.client.post(reverse("duka:ongeza"), { "jina": "Mchele", "aina": "chakula", "bei": "2800", "idadi": 5, "maelezo": "", }) self.assertEqual(r.status_code, 302) self.assertEqual(Bidhaa.objects.get(jina="Mchele").muuzaji, self.baraka) def test_bei_hasi_inakataliwa(self): self.client.login(username="baraka", password="Siri-12345") r = self.client.post(reverse("duka:ongeza"), { "jina": "Kitu", "aina": "vifaa", "bei": "-5", "idadi": 1, }) self.assertEqual(r.status_code, 200) # form inaonyeshwa tena na kosa self.assertFalse(Bidhaa.objects.filter(jina="Kitu").exists()) def test_mwingine_hawezi_kufuta(self): self.client.login(username="baraka", password="Siri-12345") r = self.client.post(reverse("duka:futa", args=[self.sukari.pk])) self.assertEqual(r.status_code, 403) self.assertTrue(Bidhaa.objects.filter(pk=self.sukari.pk).exists())
Remember:
{% load humanize %}needs"django.contrib.humanize"inINSTALLED_APPS(we added it in Week 15). Add a "Duka" (Shop) link to thebase.htmlmenu:<a href="{% url 'duka:orodha' %}">Duka</a>.
Homework
(Due: the day the final project is presented)
- Complete the "Duka Letu" Final Project (all 10 compulsory requirements above).
- Choose at least one extra: (a) an AI button that generates product descriptions with Claude, (b) a JSON endpoint
/duka/api/bidhaa/, (c) putting it online on PythonAnywhere. - Write a
README.md(what the project is, how to run it, 1–2 screenshots). - Prepare a short presentation: show the site working, explain one part of the code you are proud of, and one challenge you overcame.
Answer (extras a and b)
# (b) duka/views.py — JSON endpoint from django.http import JsonResponse def bidhaa_json(request): data = [ {"id": b.pk, "jina": b.jina, "bei": float(b.bei), "idadi": b.idadi, "aina": b.aina, "muuzaji": b.muuzaji.username} for b in Bidhaa.objects.select_related("muuzaji") ] return JsonResponse({"bidhaa": data}) # duka/urls.py # path("api/bidhaa/", views.bidhaa_json, name="api_bidhaa"),
# (a) duka/ai.py — tumia Claude kuandika maelezo (pip install anthropic python-dotenv) import anthropic from dotenv import load_dotenv load_dotenv() # ANTHROPIC_API_KEY kutoka .env (pembeni ya manage.py) _client = anthropic.Anthropic() def tengeneza_maelezo(jina: str, aina: str) -> str: r = _client.messages.create( model="claude-haiku-5-5", max_tokens=1024, output_config={"effort": "low"}, system="Wewe ni mwandishi wa matangazo ya duka la Tanzania. Andika kwa Kiswahili, sentensi 2-3, bila kubuni bei.", messages=[{"role": "user", "content": f"Andika maelezo mafupi ya kuvutia ya bidhaa: {jina} (aina: {aina})."}], ) return "".join(b.text for b in r.content if b.type == "text").strip()
# duka/views.py — view ya kitufe (POST tu, muuzaji tu) import anthropic from django.contrib import messages from django.contrib.auth.decorators import login_required from django.shortcuts import get_object_or_404, redirect from django.views.decorators.http import require_POST from .ai import tengeneza_maelezo @login_required @require_POST def maelezo_ai(request, pk): bidhaa = get_object_or_404(Bidhaa, pk=pk, muuzaji=request.user) try: bidhaa.maelezo = tengeneza_maelezo(bidhaa.jina, bidhaa.get_aina_display()) bidhaa.save() messages.success(request, "Maelezo yametengenezwa na AI. Yasome na uyahariri kama inahitajika.") except anthropic.APIError: messages.error(request, "Huduma ya AI haipatikani kwa sasa. Jaribu baadaye.") return redirect(bidhaa) # duka/urls.py # path("<int:pk>/maelezo-ai/", views.maelezo_ai, name="maelezo_ai"),
<!-- bidhaa_detail.html, ndani ya {% if user == bidhaa.muuzaji %} --> <form method="post" action="{% url 'duka:maelezo_ai' bidhaa.pk %}"> {% csrf_token %} <button type="submit">✨ Tengeneza maelezo kwa AI</button> </form>
Remember:
.envis in.gitignore; on PythonAnywhere putANTHROPIC_API_KEYin the WSGI file or the server's.env.
If you are asked a question you don't know
Say: "I don't know the full answer right now. Let's write it down, and I'll look it up and answer you next session (or by message)." For Django, search "django
- Django docs: https://docs.djangoproject.com/en/5.2/
- Classy CBV: https://ccbv.co.uk/
- PythonAnywhere help: https://help.pythonanywhere.com/pages/DeployExistingDjangoProject/
End-of-week checklist
- Creates a
ModelFormwith extra validation (clean_<field>) - Writes create/update/delete views (GET/POST pattern + redirect) and explains CSRF
- Uses
messagesand they appear inbase.html - Converts views to ListView/DetailView/CreateView/UpdateView/DeleteView
- Uses
LoginRequiredMixin,UserPassesTestMixin,SuccessMessageMixin - Login, logout (POST) and sign-up work; the menu changes according to the user
- Only the owner of a post/product can edit/delete it (403 for others)
- Explains
DEBUG,SECRET_KEY,ALLOWED_HOSTS,collectstaticfor deployment - The Final Project has been started and the presentation date agreed
Certificate checklist (end of course)
These criteria are a suggestion: change them according to your school's rules.
- Attended at least 80% of sessions (39 of 48 sessions)
- Submitted homework for at least 13 of 16 weeks
- Has a GitHub with projects: FastAPI, ML/AI API (Weeks 13–14), Django (Weeks 15–16)
- The Final Project scored ≥ 60 on the rubric
- Presented the project orally and answered questions
- Filled in the course feedback form
- Certificate prepared (correct full name, marks/distinction)