learning-website-django1-10 Exercise 3: Reading Progress ========================================================== A logged-in visitor can mark a chapter finished (and undo it) and see, per course, how many chapters they have finished. Save as apps/progress/models.py: from django.conf import settings from django.db import models class PageProgress(models.Model): """A user has finished a page. The page is identified by its PATH, not by a foreign key: the importer may delete and recreate a page, and a foreign key would silently delete the user's progress with it. (If a file is RENAMED, its progress is orphaned: the path no longer exists. That is the price of this choice.)""" user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="progress") path = models.CharField(max_length=500) completed_at = models.DateTimeField(auto_now_add=True) class Meta: constraints = [models.UniqueConstraint(fields=["user", "path"], name="one_progress_row_per_page")] verbose_name_plural = "page progress" def __str__(self): return f"{self.user} finished {self.path}" Why the page is stored as a PATH and not a foreign key: the importer may delete and recreate a page, and a foreign key would delete the user's progress with it. A test deletes a page, recreates it and checks the progress is still there. The cost: if a FILE is renamed, its progress points at a path that no longer exists (the row stays, but it counts for nothing). Save as apps/progress/views.py: from django.contrib.auth.decorators import login_required from django.db.models import Count from django.http import Http404 from django.shortcuts import redirect, render from django.views.decorators.http import require_POST from apps.content.models import Course, Page from .models import PageProgress @login_required @require_POST def mark(request, site): """Mark a page finished (or not finished). The page is looked up on THIS site, and the redirect is built from the page we found, never from anything the visitor sent, so there is nothing to redirect elsewhere.""" page = Page.objects.light().filter(path=request.POST.get("path", ""), site=site).first() if page is None: raise Http404("No such page on this site") if request.POST.get("done") == "1": PageProgress.objects.get_or_create(user=request.user, path=page.path) else: PageProgress.objects.filter(user=request.user, path=page.path).delete() return redirect(page.url_path) @login_required def overview(request, site): """For each course on this site: how many chapters, how many finished.""" courses = list(Course.objects.filter(site=site).annotate(total=Count("pages")).order_by("folder")) paths = list(PageProgress.objects.filter(user=request.user).values_list("path", flat=True)) done = dict(Page.objects.light().filter(site=site, path__in=paths, course__isnull=False) .values_list("course_id").annotate(n=Count("id"))) rows = [{"course": c, "total": c.total, "done": done.get(c.id, 0), "percent": round(100 * done.get(c.id, 0) / c.total) if c.total else 0} for c in courses if c.total] return render(request, "progress/overview.html", { "started": [r for r in rows if r["done"]], "not_started": [r for r in rows if not r["done"]], "finished_total": sum(r["done"] for r in rows), "breadcrumbs": [], "active_folder": None, }) Save as apps/progress/templates/progress/overview.html: {% extends "theme/base.html" %} {% block title %}My progress{% endblock %} {% block content %}

My progress

{{ finished_total }} chapter{{ finished_total|pluralize }} finished on {{ site_title }}.

{% if started %} {% else %}

Nothing finished yet. Use “Mark as finished” at the bottom of a chapter.

{% endif %} {% if not_started %}
{{ not_started|length }} course{{ not_started|length|pluralize }} not started
{% endif %} {% endblock %} The page template shows the button only to a logged-in user (this is the whole change to apps/theme/templates/theme/page.html): {% if user.is_authenticated %}
{% csrf_token %} {% if finished %} ... "Mark as not finished" (done=0) {% else %} "Mark as finished" (done=1) {% endif %}
{% endif %} Security decisions, each with a test: - Marking is POST only, needs a login, and needs the CSRF token (403 without it). - The page is looked up on THIS site only: a page of another site, or a path that does not exist, is a 404. - The redirect after marking is built from the page that was found, never from anything the visitor sent, so there is nothing to redirect to elsewhere. - Deleting a user deletes their progress (the database cascades). - The site's cached menus are not affected: nothing user-specific is cached. Pages that show the button are different for each user, so they must never be cached as a whole. Run on the real database (output from the real run, see Exercise 1 for the admin lines): anon progress: 302 /accounts/login/?next=/progress/ login 302 /hungary/hungarian-basic-3/ Hungarian Basic Conversation 3 12 chapters button shows finished: True | second chapter: False overview 13 ms: 5 chapters finished [('5', '12')] courses on site: 20 /content/page/: 200 130 ms 36 KB 4408 page /content/page/?site=languages&kind=course_chapter: 200 75 ms 41 KB 246 result /content/page/?q=szeretnek: 200 617 ms 11 KB 0 result /content/page/?site=ai&q=decorator: 200 15 ms 11 KB 0 result detail 200 False True post edit: 403 admin on a site host: 200 404 anon admin: 302 cleanup users left: 0 What was not verified: the buttons were not clicked in a real browser (the run uses the test client, which sends the same requests); a screenshot of the login page was checked in headless Chrome. Behaviour with several worker processes was not tested. WHY THIS WORKS AS AN ANSWER --------------------------- It is small, and each risk is a test: wrong site, wrong user, no login, no token, a deleted page, a deleted user.