Week 3 · Views: Jinja2 templates, Bootstrap and forms
The one-minute story
A template is a form letter. "Dear NAME, your marks in COURSE are MARKS." You do not write a new letter for every student. You keep one letter with blanks, and a clerk fills in the blanks from a list. The template is the letter, the data is the list, and rendering is the clerk filling the blanks.
A website does the same. The controller (a Flask route) collects the data. The view (a Jinja2 template) is an HTML page with blanks and a few instructions like "repeat this row for every student". Rendering produces plain HTML that the browser shows. When the visitor fills in a form, the browser posts the answers back to the controller.
By the end of this week you can:
- write a string with variables using f-strings and
string.Template, and say how Jinja2 differs; - predict the output of a Jinja2 template with variables, filters, loops, conditions and
set; - build a base layout with
extends,block,super(),includeand a macro; - render a template from Flask with
render_templateand link CSS withurl_for('static', ...); - read form data with
request.formandrequest.argsand validate it on the server; - explain why Jinja2 escapes HTML and what
|safedoes.
Words you need
- Template - a text or HTML file with placeholders and small instructions.
- Rendering - filling a template with data to produce the final text.
- Jinja2 - the template engine Flask uses.
- Expression -
{{ ... }}: print a value. - Statement -
{% ... %}: control flow such asforandif, prints nothing itself. - Filter - a small function applied with a pipe, like
name|upper. - Inheritance - a child template reuses a base layout and fills its
blockslots. - Macro - a template function you define once and call many times.
- Autoescape - Jinja2 turns
<into<in values so user text cannot inject HTML. - Static file - CSS, JavaScript or an image served as it is from the
staticfolder. - request.form / request.args - data from the POST body / from the URL query string.
- Client-side / server-side validation - checking input in the browser / in your Flask code.
- PyHTML - a Python library that builds HTML from nested function calls (it also appears in past papers).
Idea 1: Strings with blanks, the stepping stone
Before templates, Python itself can fill blanks. Compare three ways:
name, price = "Ria", 49.5
print(f"{name:>6}|{price:.2f}") # f-string
print("{} owes {:.1f}".format(name, price)) # str.format
print("%s owes %d" % (name, 3)) # old % style
from string import Template
t = Template("The $color car is in $place.")
print(t.substitute(color="red", place="Goa"))
print(t.safe_substitute(color="red"))
try:
t.substitute(color="red")
except KeyError as e:
print("KeyError", e)
Output:
Ria|49.50
Ria owes 49.5
Ria owes 3
The red car is in Goa.
The red car is in $place.
KeyError 'place'
- f-string:
f"..."with{expression}and a format spec after a colon:.2ftwo decimals,>6right-align in width 6,,thousands separator. string.Template: placeholders are written$name.substituteraises KeyError if a value is missing;safe_substituteleaves the placeholder as it is.- The
Templatefromstringand theTemplatefromjinja2are different tools with different syntax.string.Templateonly substitutes simple$names: no loops, no conditions, no filters.
Question. What does this print?
from string import Template as T1
from jinja2 import Template as T2
temp = "A student needs to complete $c1 level to get to the {{c2}} level."
out = T1(temp).substitute({'c1': 'foundation', 'c2': 'diploma'})
print(out)
print(T2(out).render({'c2': 'diploma'}))
Solution. T1 replaces only $c1. It does not understand {{c2}}, and the extra key c2 in the dictionary is simply ignored. So the first line is A student needs to complete foundation level to get to the {{c2}} level. That string is then fed to Jinja2, which fills {{c2}}, so the second line is A student needs to complete foundation level to get to the diploma level.
If a $ placeholder is left unfilled, substitute raises KeyError: 'place', while safe_substitute keeps $place. I ran both.
substitutefails (KeyError) on a missing key;safe_substitutedoes not.- Extra keys are ignored by both.
- Two engines in a row: each only replaces its own syntax, and the other's syntax passes through untouched.
- In Jinja2 a missing variable prints as an empty string (not an error).
PyHTML is a third way: build HTML with Python calls, for example html(head(title("Quiz 2")), body(h2("Heading"), p("Text"))) and .render() gives the nested HTML in the same order as the calls. Jinja2 is the one used in this course for real pages.
Idea 2: Jinja2 basics: variables, expressions, filters
Jinja2 works without Flask too, which is a good way to test a template:
from jinja2 import Template
t = Template("Hi {{ name }}, 2 x 3 = {{ 2 * 3 }}, you have {{ items|length }} items. {{ missing }}|")
print(t.render(name="Ria", items=[1, 2, 3]))
Output: Hi Ria, 2 x 3 = 6, you have 3 items. |
The missing variable printed nothing.
Three kinds of markers.
{{ expression }}prints a value.{% statement %}is logic (for,if,set,block...) and prints nothing itself.{# comment #}is removed from the output.
Passing data. render(name="Ria") and render({'name': 'Ria'}) are the same. Values may be strings, numbers, lists, dictionaries or objects. Inside the template, d.k and d['k'] both read a dictionary key, l[0] reads a list item, and methods can be called: if loan1.get_loan_details() returns (101, 45900, 5) then {{ l1.get_loan_details() }} prints (101, 45900, 5).
Expressions work like Python: {{ 7 // 2 }} is 3, {{ 7 / 2 }} is 3.5, {{ 'a' ~ 1 }} is a1 (~ joins text), {{ 'ab' * 2 }} is abab, {{ 'yes' if n > 1 else 'no' }}.
Filters go after a pipe and can be chained:
{{ name|upper }}givesRIA,{{ 'ria dev'|title }}givesRia Dev{{ name|default('guest') }}givesguestwhennameis not defined{{ items|length }}is the size (the paper's right answer;len(items)anditems|lenare wrong){{ items|join(' / ') }},{{ items|first }},{{ items|last }},{{ items|sort }}{{ price|round(1) }}:3.14159becomes3.1{{ items|sum }},{{ items|max }},{{ s|trim }},{{ s|replace('a','z') }}
Chain: {{ ['b','a']|sort|reverse|list }} gives ['b', 'a'].
Autoescaping and |safe
If a user types <script> as their name, printing it raw would run their script on every visitor's page (an attack called XSS). So Flask turns autoescape on for .html templates: < becomes <, the browser shows the characters instead of treating them as a tag.
from flask import render_template_string
render_template_string("{{ s }} | {{ s|safe }}", s="<b>hi</b>")
Result: <b>hi</b> | <b>hi</b>. The first shows the literal text <b>hi</b> in the browser; the second makes real bold text.
|safe means "I trust this HTML, do not escape it". Never use it on text a user typed. Note: a plain jinja2.Template(...) used outside Flask does not escape by default (autoescape is off unless you ask), which is why {{ s }} printed <b>hi</b> unchanged in my console test.
Question. Predict the output.
from jinja2 import Template
template = '''
Welcome to {{Degree}} DEGREE
First level to clear is {{level}}
'''
print(Template(template).render(Degree="IITM BS"))
Solution. Only Degree is passed. {{level}} is undefined and prints nothing:
(blank line)
Welcome to IITM BS DEGREE
First level to clear is
Names are case sensitive: {{ Level }} would also be blank even if level had a value.
Idea 3: Control flow: for, if, set, whitespace
{% for c in courses %}
<li>{{ loop.index }}. {{ c }}{% if loop.last %} (last){% endif %}</li>
{% else %}
<li>No courses</li>
{% endfor %}
With courses = ["MAD1", "MAD2", "DBMS"] the items are 1. MAD1, 2. MAD2, 3. DBMS (last). The else of a for runs only when the list is empty.
| Loop variable | Meaning |
|---|---|
loop.index | 1, 2, 3 ... (starts at 1) |
loop.index0 | 0, 1, 2 ... |
loop.first, loop.last | True on the first / last pass |
loop.length | Total passes |
loop.revindex | Passes left, counting down to 1 |
Loop over a dictionary with {% for k, v in d.items() %}{{ k }}={{ v }};{% endfor %}, which prints a=1;b=2; for d = {"a": 1, "b": 2}. Use range: {% for i in range(3) %} runs for 0, 1, 2.
Conditions: {% if n > 10 %}big{% elif n > 5 %}mid{% else %}small{% endif %}; with n = 7 this prints mid. You can use and, or, not, in, and tests such as is defined, is even.
{% set %} and its scope. set makes a variable. A set inside a for loop is local to that loop and is gone afterwards.
from jinja2 import Template
t = Template("""
{% for i in range(3) %}
{% set x = i %}
{% endfor %}
[{{ x }}]
""")
print(t.render())
The output contains []: the x outside the loop is undefined, so it prints nothing (not 0, not 2). A past paper asked exactly this twice, once with range(3) and once with [1, 2, 3] in a Flask template (answer: "Output is:" and nothing after it). To add up values across a loop, use namespace:
{% set ns = namespace(t=0) %}{% for i in [1,2,3] %}{% set ns.t = ns.t + i %}{% endfor %}{{ ns.t }}
This prints 6. A set outside any loop works normally: {% set x = 5 %}[{{ x }}] prints [5].
Whitespace. Jinja2 keeps the newlines around your tags. A minus sign next to the tag brackets trims the whitespace on that side:
A = "<ul>\n{% for x in c %}\n<li>{{ x }}</li>\n{% endfor %}\n</ul>"
B = "<ul>\n{% for x in c -%}\n<li>{{ x }}</li>\n{%- endfor %}\n</ul>"
# c = ["a", "b"]
# A renders to '<ul>\n\n<li>a</li>\n\n<li>b</li>\n\n</ul>'
# B renders to '<ul>\n<li>a</li><li>b</li>\n</ul>'
The extra blank lines in A are harmless in HTML (the browser ignores them), so you will rarely need -. A past paper question puts groupby in a template:
temp = """
{% for vid, members in interfaces|groupby(attribute='lan') %}
Interfaces in lan {{ vid }}: {{ members|map(attribute='name') | join(', ') }}
{% endfor %}
"""
With interfaces Ethernet1 (50), Ethernet2 (40), Ethernet3 (50), Ethernet4 (60) the output is three lines: lan 40: Ethernet2, lan 50: Ethernet1, Ethernet3, lan 60: Ethernet4. groupby sorts by the key and groups equal keys together; map(attribute='name') takes each item's name.
{% for c in ['a','b','c'] %}{{ loop.index }}{{ c }}{% if not loop.last %}-{% endif %}{% endfor %} prints what?
Need a hint?
loop.index starts at 1 and a dash follows each item except the last.
Show the full solution
1a-2b-3c
What does {% set t = 0 %}{% for i in [1,2,3] %}{% set t = t + i %}{% endfor %}{{ t }} print?
Need a hint?
Is the t set inside the loop the same t as outside?
Show the full solution
0. The inner set creates a new local t that disappears after the loop, so the outer t is still 0. Use namespace to get 6.
Idea 4: Templates in Flask
myapp/
app.py
templates/
base.html
index.html
form.html
static/
style.css
from flask import Flask, render_template
app = Flask(__name__)
@app.route("/")
def index():
return render_template("index.html", courses=["MAD1", "MAD2"], user="Ria")
render_templatelooks in a folder namedtemplatesnext toapp.py, and extra arguments become variables. If you create the app withFlask(__name__, template_folder='my_templates')Flask looks only inmy_templates, so a file intemplates/fails withTemplateNotFound. (This was the 5-mark question in a recent paper: routes using paths insidemy_templateswork, those asking forindex.htmlorpage.htmlthat live elsewhere raise TemplateNotFound.) Subfolders are named in the call:render_template('public/home.html').- Static files (CSS, images, JS) go in
static/. Link them withurl_for:
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<img src="{{ url_for('static', filename='logo.png') }}" alt="logo">
which renders as /static/style.css.
url_for(function_name, **values) builds a URL from the name of the view function, so changing the route later does not break your links:
@app.route("/home")
def index(): ...
@app.route("/student/<student_name>/<int:student_id>")
def profile(student_name, student_id): ...
with app.test_request_context():
print(url_for("index")) # /home
print(url_for("profile", student_name="John", student_id=101))# /student/John/101
print(url_for("index", page=2)) # /home?page=2
Values that are part of the route fill the path; extra values become the query string. The argument is the function name profile, not the path. redirect(url_for("index")) sends the browser a 302 and a Location header, and the browser then makes a new GET (as in the 2022 paper's index2 route).
Idea 5: Reuse: inheritance, include, macros
Inheritance. Put the common page frame in one base file and fill the gaps in each page.
<!-- templates/base.html -->
<!doctype html>
<html>
<head>
<title>{% block title %}Study Hub{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
{% block content %}{% endblock %}
{% block foot %}(c) Hub{% endblock %}
</body>
</html>
<!-- templates/page.html -->
{% extends "base.html" %}
{% block title %}Home - {{ super() }}{% endblock %}
{% block content %}<h1>Hello</h1>{% endblock %}
Rendering page.html: the title becomes Home - Study Hub (super() pulls in the parent's block text), the content is <h1>Hello</h1>, and foot is not overridden so the parent's (c) Hub appears. I rendered a small version of this.
Rules of extends. {% extends "base.html" %} must be the first tag in the child. In a child, only things inside {% block name %}...{% endblock %} are shown; anything outside blocks is ignored. A block is replaced by the child's version; {{ super() }} keeps the parent's text too. The right syntax is {% extends "file" %} and {% block name %} (a block needs a name).
Include inserts another file at that spot, as is: {% include "navbar.html" %}. It sees the same variables. Use it for small repeated pieces (navbar, footer).
Macros are functions in a template:
{% macro box(text, kind='info') %}
<div class="alert alert-{{ kind }}">{{ text }}</div>
{% endmacro %}
{{ box('Saved') }}
{{ box('Failed', 'danger') }}
Output: <div class="alert alert-info">Saved</div> and <div class="alert alert-danger">Failed</div> (default argument used for the first). In the 2022 paper a macro with a for loop printed the courses as page text, one after another, App1 App2 python Data science Database machine learning, not as a Python list with brackets and quotes. A macro must be called ({{ display(list) }}) or nothing shows.
base.html has {% block content %}{% endblock %}. A child contains {% extends "base.html" %}{% block content %}<p>Hi</p>{% endblock %} plus an extra line <h1>Outside</h1> before the block. What shows?
Need a hint?
What happens to content outside blocks in a child?
Show the full solution
Only <p>Hi</p> appears (inside the base layout). The line outside a block is ignored.
Idea 6: Forms: GET and POST into Flask
Template form.html:
{% extends "base.html" %}
{% block content %}
<form action="{{ url_for('signup') }}" method="post">
<input type="text" name="uname" required>
<input type="number" name="age" min="5">
<button type="submit">Send</button>
</form>
{% if error %}<p class="text-danger">{{ error }}</p>{% endif %}
{% endblock %}
Route:
from flask import Flask, render_template, request
app = Flask(__name__)
@app.route("/signup", methods=["GET", "POST"])
def signup():
if request.method == "POST":
name = request.form.get("uname", "").strip()
age = request.form.get("age", "")
if not name:
return render_template("form.html", error="Name is required"), 400
if not age.isdigit():
return render_template("form.html", error="Age must be a number"), 400
return f"Welcome {name}, age {int(age)}"
return render_template("form.html")
Test results (I posted these with Flask's test client): uname=Ria, age=20 returns Welcome Ria, age 20; an empty name returns the form again with the error and status 400; age=x returns the second error. A plain GET to /signup shows the form.
request.form | request.args | request.method | |
|---|---|---|---|
| Holds | POST body fields | URL query string | "GET" or "POST" |
| Used with | method="post" | method="get" or a link with ?k=v | choosing what to do |
- A route accepts only GET unless you list others in
methods=[...]. Forgetting"POST"makes the form submit fail with 405. request.form["k"]raises 400 Bad Request if the key is missing;request.form.get("k")returnsNone.- A form with
method="get"puts the fields in the query string, so you read them withrequest.args. Readingrequest.formfor a GET form gives nothing. request.args.getlist("tag")returns all values for a repeated key:?tag=a&tag=bgives['a', 'b'].- Good practice: after a successful POST, do
return redirect(url_for(...))so a page refresh does not submit the form again.
Client-side versus server-side validation
- Client-side: HTML5 attributes (
required,type="email",min,pattern) in the browser. Fast and friendly, but anyone can bypass it (edit the HTML, usecurl). - Server-side: your Python checks. Required for security and correctness. The form above has both: the browser blocks an empty name, and
signupre-checks.
Flask-WTF (with WTForms) is an extension that defines forms as Python classes and adds validators and CSRF protection; know its name and purpose, but plain request.form is what the course shows first.
request.formis for POST form data;request.argsis for URL parameters. Match them to the form'smethod.- Missing
methods=["GET", "POST"]gives 405 when the form posts. - HTML validation alone is not safe; always validate on the server.
- For
request.args.get(...)a missing key isNone; forrequest.args["..."]it is a 400 error. The paper's login checkargs["uid"]fails with 400 whenuidis absent.
Idea 7: Bootstrap inside templates
Put the CDN link once in base.html, and every page that extends it gets the styles. A loop turns data into a styled table:
<table class="table table-striped">
<thead><tr><th>#</th><th>Course</th></tr></thead>
<tbody>
{% for c in courses %}
<tr><td>{{ loop.index }}</td><td>{{ c }}</td></tr>
{% endfor %}
</tbody>
</table>
With two courses this produces two tr rows, numbered 1 and 2, inside a Bootstrap-striped table. Other useful pieces: a form field is <input class="form-control" ...> in a <div class="mb-3">, a submit button is class="btn btn-primary", messages are alert alert-danger, and a top bar is a navbar (put it in its own file and include it).
Server-side rendering (what you do this week) sends finished HTML: fast first load and easy for search engines. In client-side rendering (MAD2) the server sends JSON and JavaScript builds the page. Neither removes the need for requests after the page loads.
Question. The user opens /hello/Ria. Describe all steps and give the HTML, given:
@app.route("/hello/<name>")
def hello(name):
return render_template("hello.html", n=name, tags=["a", "b"])
<p>Hi {{ n|upper }}!</p>
<ul>{% for t in tags %}<li>{{ t }}</li>{% endfor %}</ul>
Solution. Flask matches /hello/<name> and calls hello("Ria"). render_template finds templates/hello.html and renders it with n="Ria" and tags. The browser receives status 200 and the body <p>Hi RIA!</p> and <ul><li>a</li><li>b</li></ul>. The browser then parses and displays the HTML. That is the whole controller to view path.
Make one base.html with your Bootstrap link, navbar and {% block content %}, and let every page extend it. Never build HTML strings in Python; always use render_template. In the viva you may be asked to add a field to a form: be ready to change the HTML input, the route (request.form), validation, and the template that shows it.
A form has method="get" and an input name="q". In the route, which expression reads the value: request.form['q'] or request.args['q']?
Need a hint?
GET forms put the data in the URL.
Show the full solution
request.args['q'] (and request.args.get('q') if it may be missing). request.form would be empty.
render_template_string("{{ s }}", s="<i>x</i>") in Flask: what does the page source contain? And with {{ s|safe }}?
Need a hint?
Flask autoescapes by default.
Show the full solution
<i>x</i> (the user sees the literal text <i>x</i>). With |safe the source has <i>x</i>, which the browser shows as italic x.
Exam radar
This week mixes templates with Flask routing. The keyword counts are about 140 of 692 in Quiz 1, 45 of 624 in Quiz 2 and 252 of 1600 in the End Term (loose, as the match also catches forms and routes). In the real questions:
- "Predict the output of this Jinja2 /
string.Templatecode" appears again and again in Quiz 1:substituteversussafe_substitute(KeyError),{{ }}versus$, a missing variable printing blank, a method call in{{ }},groupby, passing a dictionary torender. {% set %}inside a loop (about two to four questions in recent Quiz 2 papers): the value is not visible after the loop.- Template inheritance syntax (
extends, a namedblock), and macros (a loop printing text). - Syntax questions:
{{ courses|length }}for the count; matching PyHTML, Jinja2, template rendering and string template. - Flask view questions:
url_foroutput,redirect,template_folderandTemplateNotFound,abort(404)versusrender_template(...), 404,first_or_404(). - Statement questions: why server-side Jinja2 gives a faster first load and good SEO; front-end and back-end validation of a form.
{{ x }}after a loop that didset xinside prints nothing.- Autoescape is on in Flask
.htmltemplates, off in barejinja2.Template. url_fortakes the function name, not the URL; extra values go to the query string.- Returning
render_template("404.html")alone gives status 200; to send a 404 you need, 404orabort(404). - In the child template, text outside blocks is ignored;
extendsgoes first. - Case matters in variable names:
{{ Level }}is not{{ level }}.
Quick quiz
Which code throws a KeyError? t = Template("The $color car is at the $building.") (string.Template)
t.substitute({'color': 'red'})t.safe_substitute({'color': 'green'})t.substitute({'color': 'blue', 'building': 'office', 'place': 'Chennai'})t.safe_substitute({})
substitute needs every placeholder; the building key is missing. Extra keys are ignored and safe_substitute never raises.What is printed? Template("{% for i in range(3) %}{% set x = i %}{% endfor %}[{{ x }}]").render()
[0][2][]- an error
set inside a for loop is local to the loop, so x is undefined outside and prints as nothing.Which is the correct way to show how many courses are in a list in Jinja2?
{{ len(courses) }}{{ courses|len }}{{ courses|length }}{{ courses.size }}
length is the Jinja2 filter; len is not available as a function or a filter.Select all TRUE statements. (select all that apply)
{% extends "base.html" %}must come first in the child template.{{ super() }}inside a block includes the parent's block content.- Text outside blocks in a child template is shown.
request.formreads the URL query string.
request.args (not form) reads the query string.A Flask app is created with Flask(__name__, template_folder='my_templates'). A route renders 'index.html', which exists only in templates/. What happens?
- It renders normally
- Flask falls back to
templates/ - TemplateNotFound error (a 500 page)
- 404 Not Found
render_template_string("{{ s }}", s="<b>x</b>") in Flask returns...
<b>x</b>as real bold text<b>x</b>which the browser shows as the characters- an error
- an empty string
|safe.Viva: say it out loud
What is a template engine and why use one?
It lets me write the HTML once with placeholders and loops, and the server fills it with data at request time. This keeps Python logic and page layout separate (the controller and the view), and avoids building HTML in strings.
Explain extends, block and include in your project.
My base.html has the Bootstrap link, navbar and an empty content block. Each page uses extends to inherit that frame and fills the content block. I use include for small repeated parts like a footer.
What is autoescaping and why does it matter?
Jinja2 converts characters like < into < so text typed by a user is shown as text and cannot inject script. It stops cross-site scripting. I use |safe only for HTML I wrote myself.
How does form data reach your Flask code?
The browser sends name=value pairs. For POST they are in the body and I read request.form; for GET they are in the URL and I read request.args. The route must list the method in methods.
Why validate on the server when the HTML already has required?
Client-side checks only help honest users: anyone can bypass them with developer tools or curl. The server must check again before saving anything, and show the form with an error message.
What does url_for('static', filename='style.css') do?
It builds the URL of a file in the static folder, /static/style.css. Using url_for means links stay correct even if I move the app or change a route.
What is the difference between a macro and an include?
An include just pastes another template at that spot. A macro is like a function with parameters that I call many times with different arguments, for example one alert box that takes a message and a colour.
Recap
f"...",formatandstring.Templatefill blanks in Python;substituteraises KeyError for a missing key,safe_substitutedoes not.- Jinja2:
{{ }}prints,{% %}is logic,{# #}is a comment. Filters:length,upper,default,join,round. - A missing variable is blank;
setin a loop does not leak out;forhasloop.index,loop.first,loop.lastand anelsefor empty lists. - Flask:
templates/folder,static/folder,render_template,url_for(function names),redirect. - Reuse with
extends+block+super(),include, and macros. - Forms:
methods=["GET", "POST"],request.formfor POST,request.argsfor GET. Validate on the server; HTML validation is only a convenience. Flask-WTF is the extension that formalises this. - Autoescape protects against injected HTML;
|safeswitches it off for trusted text.
- Render:
render_template("page.html", name=value) - Static link:
href="{{ url_for('static', filename='style.css') }}" - Loop:
{% for x in xs %}...{{ loop.index }}...{% else %}empty{% endfor %} - If:
{% if a %}..{% elif b %}..{% else %}..{% endif %} - Inheritance:
{% extends "base.html" %}and{% block content %}...{{ super() }}{% endblock %} - Macro:
{% macro name(arg, opt='x') %}...{% endmacro %}then{{ name(1) }} - Whitespace trim:
{%- ... -%}; comment:{# ... #} - Form data:
request.form.get("k"),request.args.get("k"),request.method - Count:
{{ items|length }}; default:{{ x|default('n/a') }} string.Template:$name,substitute()(KeyError) /safe_substitute()
- MAD2 week 4 - Vue basics: instances, directives and reactivity: Jinja2 fills templates on the server; Vue fills them in the browser. Same idea, different place.
- MAD2 week 6 - Advanced Vue: components, props, events and Vue Router: Jinja2 template inheritance and macros become Vue components, props and slots.
Practise with real exam questions
These come straight from past papers (with the real figures and the official answer key). Open 60 real questions on this week All Quiz 1 papers