MAD Study Hub

Home / MAD1 / Week 5

Week 5 · Controllers: Flask routes and business logic

Official syllabus line: Controllers - Business logic
About 120 minQuiz 2 material (see exam radar)BSCS2003 (+ project BSCS2003P) · Thejesh G N, Nitin Chandrachoodan
Before you start: MAD1 week 3 (HTML forms and Jinja templates) and week 4 (models and Flask-SQLAlchemy). Python functions and decorators help.

The one-minute story

Think of it like this

A controller is the manager of a restaurant. The customer (browser) sends an order ("a table for 2, please"). The manager (controller) reads the order, checks the rules ("is this table free? has this person paid?"), asks the kitchen (model, the database) for ingredients, and then sends the finished plate through the waiter (view, the template). If the order is nonsense, the manager politely says no and shows a message.

In Flask, the manager is a view function with a @app.route label on top. The label says "I handle orders for this address". This week is all about those managers: how they are named, how they read what the browser sent, how they answer, and where the business rules live.

By the end of this week you can:

  • write routes with variable parts, allowed methods and url_for;
  • predict the output of small Flask apps (trailing slashes, wrong methods, abort, error handlers, redirects);
  • read form data, query strings and JSON from the request object;
  • use flash, session, redirect-after-POST and error handlers;
  • organise a bigger app with blueprints and an application factory, and explain MVC;
  • put validation, duplicate checks, permission checks and delete rules in the controller.

Words you need

Words you need
  • Route - a URL pattern tied to a Python function (@app.route("/hello")).
  • View function / controller - the function that runs when its route is requested and returns the response.
  • Endpoint - the name of a view function (its function name by default); url_for uses it.
  • Request object - Flask's object holding everything the browser sent (request.args, request.form, request.json...).
  • Query string - the part after ? in a URL (/search?q=milk&page=2).
  • HTTP method - GET (read), POST (send data), PUT, DELETE...
  • Redirect - a reply that tells the browser "go to this other URL" (status 302).
  • Flash message - a one-time message stored for the next page.
  • Session - data remembered between requests, kept in a signed cookie.
  • Blueprint - a group of routes that can be plugged into the app.
  • Business logic - the rules of your app: validation, who is allowed, what is not allowed twice.
  • Virtual environment - a private folder of packages for one project.

Idea 1: A Flask app and its folders

Install and run in a virtual environment, so this project's packages do not clash with others:

python -m venv venv
venv\Scripts\activate          # on Windows; on Mac/Linux: source venv/bin/activate
pip install flask flask-sqlalchemy
pip freeze > requirements.txt  # anyone can rebuild with: pip install -r requirements.txt

A typical project:

my_app/
  app.py            controller code (routes) and app setup
  models.py         model classes (db.Model)
  templates/        Jinja HTML files (render_template looks here)
  static/           css, js, images (served at /static/...)
  requirements.txt
  venv/             never commit this

The smallest app:

from flask import Flask
app = Flask(__name__)

@app.route("/")
def index():
    return "Hello"

if __name__ == "__main__":
    app.run(debug=True)       # http://127.0.0.1:5000/

Flask(__name__) tells Flask where this file lives, so it can find templates/ and static/ next to it. The default folder names are templates and static, and you can change them: Flask(__name__, template_folder="my_templates").

Solved example: 1. Which template is found?

Question. Folders: views/index.html, templates/page.html, my_templates/public/home.html, my_templates/private/dashboard.html. The app is Flask(__name__, template_folder="my_templates"). Which of render_template("public/home.html"), "private/dashboard.html", "index.html", "page.html" work?

Solution. Flask now searches only my_templates/ (it does not also search templates/). So the first two work, and index.html and page.html raise TemplateNotFound. This was a real 5-mark question.

Decorators in one minute

@app.route("/") is a decorator: a function that takes your function and returns a wrapped version. Stacked decorators apply from the bottom up.

def logger(func):
    def inner():
        print("start")
        r1 = func(); r2 = func()
        return r1, r2
    return inner

@logger
def get_data():
    return "Data"

print(get_data())

Output: start, then ('Data', 'Data'). The name get_data now refers to inner, which prints first and runs the original twice. With two decorators (@modify above @update), update wraps the function first and modify wraps the result. A real paper asked for modify (reverses a list) over update (halves each value) over a function that squares [2,4,1,7,5,9]: squares [4,16,1,49,25,81], then halve [2,8,0.5,24.5,12.5,40.5], then reverse: [40.5, 12.5, 24.5, 0.5, 8.0, 2.0].

Idea 2: Routing and URL rules

@app.route(rule, methods=[...]) binds a URL to a function. Anything in angle brackets is a variable part captured and passed as an argument.

@app.route("/greet/<name>")          # name is a string without slashes
def greet(name): return f"Hi {name}"

@app.route("/n/<int:n>")             # converter: only digits, passed as int
def plus_one(n): return str(n + 1)
URLResult
/greet/AnnHi Ann
/greet/404 Not Found (the variable cannot be empty)
/greet404 Not Found
/n/56
/n/x404 (not an int)
/greet/Ann?x=1Hi Ann (the query string is not part of the route)

Converters: string (default), int, float, path (allows slashes), uuid.

The idea

Reading a route against a URL (this is the exam skill):

  • Match the path only. Part after ? is ignored by routing.
  • Each <var> must match exactly one non-empty path piece. /user/<id> matches /user/2001 and also /user/id=2001 (then id is the text id=2001), but it does not match /2001 or /id=2001. With <int:id>, the text id=2001 would no longer match.
  • A query string never fills a <var>: for /<uname> and the URL /102?uname=MAD1, uname is 102, not MAD1.
Solved example: 2. Path variable versus query string

Question. The route is @app.route("/<uname>") and the function does if uname[0].isdigit(): abort(400, "Bad Request") else returns Good Username. What does the browser show for /MAD1?uname=102 and for /102?uname=MAD1?

Solution. uname always comes from the path. /MAD1?uname=102: uname = "MAD1", first character not a digit, so Good Username. /102?uname=MAD1: uname = "102", starts with a digit, so abort(400) and the page shows Bad Request.

Trailing slash rules

@app.route("/home")
def home1(): return "This is homepage 1"

@app.route("/home/")
def home2(): return "This is homepage 2"

A rule without a trailing slash matches only /home. A rule with the slash matches /home/, and if a user types /home for a rule that ends in a slash, Flask redirects them to /home/. With both rules defined as above, /home gives homepage 1 and /home/ gives homepage 2, so the real answer for /home/ was "This is homepage 2".

Methods

A route answers GET only unless you list other methods.

@app.route("/only", methods=["POST"])
def only(): return "posted"

A browser address bar sends GET, so /only gives 405 Method Not Allowed. If the app has @app.errorhandler(405) returning "Please check the web request", that message is what you see. In one real question the route was /home/<username> with methods=['POST'], the URL typed in the browser was /home/user_4 (a GET), and the code inside (including abort(404)) never ran: the 405 handler's text appeared. Check the method before you trace the body.

url_for builds URLs from endpoint names

with app.test_request_context():
    print(url_for("greet", name="J"))        # /greet/J
    print(url_for("plus_one", n=3, x=1))     # /n/3?x=1   (extra values become the query string)
    print(url_for("static", filename="a.css"))   # /static/a.css

Use url_for instead of typing paths, so renaming a URL does not break your links. In templates: ` {{ url_for('greet', name='J') }} . Missing a required variable raises BuildError. In a real paper url_for('profile', student_name='John', student_id=101) for the route /student/<student_name>/<student_id> printed /student/John/101`, not a query string.

Idea 3: The request object and responses

Import it: from flask import request.

WhatCodeExample
Methodrequest.method"GET" or "POST"
Query stringrequest.args.get("page", 1, type=int)/q?page=3 gives 3
Form fieldsrequest.form.get("name")from <form method="post">
Uploaded filesrequest.files["photo"]needs enctype="multipart/form-data"
JSON bodyrequest.json or request.get_json()needs Content-Type: application/json
Headers, cookiesrequest.headers, request.cookies

request.args["x"] raises a 400 Bad Request if x is missing, while request.args.get("x") returns None. A POST whose body is JSON but with no JSON content type makes request.json fail with 415 Unsupported Media Type (Flask 3).

What a view can return:

Return valueWhat the browser gets
a stringstatus 200, text/html
"made", 201body plus a status code
a dictJSON, application/json (Flask converts it)
jsonify(...)JSON, application/json; also works for lists
render_template("x.html", a=1)HTML from a template
redirect(url_for("index"))status 302 and a Location header
abort(404)stops now and shows the 404 error page
Solved example: 3. Reading the browser's data

Question. What does this print for the URL /signin?uid=professor&pwd=54321?

valid_users = [{"uid": "student", "pwd": "12345"}, {"uid": "professor", "pwd": "54321"}]

@app.route("/signin")
def login():
    args = request.args
    for vu in valid_users:
        if vu["uid"] == args["uid"] and vu["pwd"] == args["pwd"]:
            return "You are authorized!!"
        else:
            return "You are unauthorized!!"

Solution. The else belongs to the if, inside the loop. The loop checks only the first user (student), finds a mismatch and returns unauthorized straight away; professor is never examined. Output: You are unauthorized!! (Correct code would put return "unauthorized" after the loop.) I ran this to confirm. If uid was missing from the URL, args["uid"] would give 400 Bad Request.

Dict versus jsonify. For the route that returns {"Restaurant 1": "Hotel Alpine", ...} the content type is application/json. jsonify({"The list of gadgets": Gadgets}) gives an object with one key whose value is the whole list of dicts (not a merged dictionary). For jsonify(courses) where courses is a list of two dicts, status is 200 and mimetype application/json.

Idea 4: Redirects, errors, flash messages and sessions

Redirect. redirect(url_for("index")) sends 302 and the browser requests the new URL itself. In a real question, /first_index/XYZ redirected to index1's URL (/XYZ) and /first_index/ABC redirected to /second_index, which rendered a page with a "Go back" link.

Errors. abort(404) raises an HTTP error. Customise the page with a handler:

@app.errorhandler(404)
def not_found(e):
    return "Course not available!", 404

If you do not give the status after the text, the page still has status 200: fine for the browser output, wrong for tests. Common codes: 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 405 Method Not Allowed, 500 Server Error. In recent Flask, a handler for an unknown code such as 407 will not even register, so such questions always hinge on which error happens first (here the 405).

Solved example: 4. Which handler runs?

Question. Two routes use the path converter, which allows slashes in the variable: /course/<path:subject> shows "Welcome to Python course!" if "Python" is in subject, else calls abort(404); /learn/<path:subject> redirects to the course route if "Python" is in subject, else abort(404). A 404 handler returns "Course not available!". What shows for /course/ML/Python?topic=NLP, /learn/web/Python?level=advanced and /course/Java/React?type=Framework?

Solution. The query string is ignored by routing. First URL: subject = "ML/Python", which contains "Python": Welcome to Python course!. Second: subject = "web/Python", so /learn redirects to /course/web/Python and the browser shows Welcome to Python course! (it is not "Course not available"). Third: subject = "Java/React", no "Python", so abort(404) and the handler shows Course not available!. I ran this to confirm. With a plain <subject> (no path), a URL with two pieces like /one/ML/Python would match nothing and give 404 directly. Always check which converter the rule uses.

Flash messages show a one-time note on the next page.

from flask import flash, redirect, url_for, request

@app.route("/add", methods=["GET", "POST"])
def add():
    if request.method == "POST":
        name = request.form.get("name", "").strip()
        if not name:
            flash("Name required")
            return redirect(url_for("add"))
        flash("Added")
        return redirect(url_for("index"))
    return render_template("add.html")

In the template: ` {% for m in get_flashed_messages() %}<p>{{ m }}</p>{% endfor %} . Flash needs a secret_key`, because messages ride in the session.

Sessions remember a user between requests. The data is stored in a cookie in the browser, signed with secret_key: the user can read it (it is only base64) but cannot change it without Flask noticing.

app.secret_key = "change-me"      # or app.config["SECRET_KEY"]

session["uid"] = 7                # log in
uid = session.get("uid")          # read; None if absent
session.pop("uid", None)          # log out

With no secret key, using session raises RuntimeError: The session is unavailable because no secret key was set. Do not store passwords or big objects in the session, and never commit a real secret key to a public repository.

Your turn: 1. Predict the status

For the app with @app.route("/home", methods=["GET"]) returning a string and no other routes, what status do /home and /home/ give?

Need a hint?

One has a trailing slash and the rule does not.

Show the full solution

/home gives 200. /home/ gives 404, because a rule without a trailing slash does not match a URL with one.

Your turn: 2. Spot the error

A student writes @app.route("/user/<id>") and tests http://127.0.0.1:5000/2001. Which URLs from /user/2001, /user/id=2001, /id=2001, /2001 give Not Found?

Need a hint?

The text /user/ must be present.

Show the full solution

/id=2001 and /2001 give Not Found (no /user/ in front). /user/2001 and /user/id=2001 both match, with id equal to 2001 and id=2001.

Idea 5: Post/Redirect/Get, validation and business logic

Business logic is the part that says what your app allows. It belongs in the controller (or a service function it calls), never only in the HTML.

The idea

The standard recipe for a form route (Post/Redirect/Get):

  1. GET: show the empty form.
  2. POST: read request.form, validate.
  3. If bad: flash a message and show the form again (or redirect to it).
  4. If good: change the database, commit, then redirect (do not render directly), so a browser refresh does not re-submit the form.

There are two layers of validation. Frontend validation happens in the browser (required, type="email", min, max, pattern in the HTML) and is easy to bypass. Backend validation happens in the Python view and is the one you must trust. A real MSQ listed a form that had required attributes and a Python check if name == "": return redirect('/add') as having both, and a form whose HTML is built by the Python function as dynamically generated. One more subtlety from that paper: a CSS rule input:valid {border: 2px solid red} colours the border when the input is valid, not when it is blank.

Solved example: 5. A complete controller with all the usual checks

Question. Write a to-do app with users and tasks where: you must be logged in, a title cannot be blank or duplicated, and you can delete only your own tasks.

Solution. Models (week 4 style):

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(50), unique=True, nullable=False)
    tasks = db.relationship("Task", backref="owner", cascade="all, delete-orphan")

class Task(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(100), nullable=False)
    user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)

Controllers:

@app.route("/tasks/add", methods=["GET", "POST"])
def add_task():
    uid = session.get("uid")
    if uid is None:
        abort(401)                                   # not logged in
    if request.method == "POST":
        title = request.form.get("title", "").strip()
        if not title:
            flash("Title is required")               # validation
            return redirect(url_for("add_task"))
        if Task.query.filter_by(user_id=uid, title=title).first():
            flash("Duplicate task")                  # duplicate check
            return redirect(url_for("add_task"))
        db.session.add(Task(title=title, user_id=uid))
        db.session.commit()
        flash("Task added")
        return redirect(url_for("list_tasks"))       # PRG
    return render_template("add.html")

@app.route("/tasks/<int:task_id>/delete", methods=["POST"])
def delete_task(task_id):
    t = db.session.get(Task, task_id)
    if t is None:
        abort(404)
    if t.user_id != session.get("uid"):
        abort(403)                                   # authorisation
    db.session.delete(t)
    db.session.commit()
    return redirect(url_for("list_tasks"))

I ran a version of this with Flask's test client. Results: before login /tasks/ redirects to the login route; a POST adding "Buy milk" redirects (302) to the list, which then shows the task and the flash "Task added"; adding "Buy milk" again or an empty title redirects back to the form with a flash message; a POST to delete task 1 redirects (302) and removes it; deleting a task that does not exist is 404; and a GET to the delete URL is 405, because the route allows POST only. Deleting should be POST (or DELETE), never a plain link, because links can be triggered by browsers and crawlers.

Cascade and delete rules. If a user owns tasks and you delete the user, the tasks would be left pointing at nothing. Decide a rule: cascade="all, delete-orphan" deletes the children too; or refuse deletion while children exist (check and flash("Remove tasks first")). The viva loves this question.

Watch out - real exam trap
  • A POST-only route typed in the address bar gives 405, whatever the code inside says.
  • After a successful POST, redirect; do not render_template (the refresh re-posts).
  • A flash with no secret_key fails; a session with no secret_key fails.
  • Checking permissions only by hiding a button is not security; the controller must check too.
  • request.form["x"] for a missing field gives 400; use .get.
In your project

Put at least these in your controller and be ready to show them: a login check on every private route, a validation message for blank and duplicate input, a 404 page for a missing record, and a delete that refuses or cascades. In the viva, the examiner will submit a blank form or delete something twice to see what happens.

Idea 6: Blueprints, configuration and the MVC mapping

MVC. Model = db.Model classes and the database. View = Jinja templates (what the user sees). Controller = the route functions: they read the request, apply business rules, call models and pick a view. A good app keeps SQL out of templates and HTML out of models.

Blueprints split a big app into parts (auth, tasks, admin). Each blueprint has its own routes, and the app registers it with a URL prefix.

from flask import Blueprint
tasks_bp = Blueprint("tasks", __name__, url_prefix="/tasks")

@tasks_bp.route("/")
def list_tasks(): ...

app.register_blueprint(tasks_bp)

The endpoint name now has the blueprint's name in front: url_for("tasks.list_tasks") gives /tasks/. Inside the same blueprint you can write url_for(".list_tasks").

Configuration is a dictionary on the app:

app.config["SECRET_KEY"] = "change-me"
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///app.sqlite3"
app.config.update(DEBUG=True)         # many keys at once
app.config.from_object("config.Dev")  # or from a class or file

Application factory. Instead of a global app, write a function that builds the app. This lets tests create a fresh app each time and avoids import loops.

db = SQLAlchemy()                     # created without an app

def create_app(config=None):
    app = Flask(__name__)
    app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
    app.config["SECRET_KEY"] = "change-me"
    app.config.update(config or {})
    db.init_app(app)                  # connect db to this app
    app.register_blueprint(tasks_bp)
    with app.app_context():
        db.create_all()
    return app

if __name__ == "__main__":
    create_app().run(debug=True)

Whether your course project needs a factory depends on its size: a single app.py is acceptable. Know the idea.

Your turn: 3. Name the endpoint

A blueprint is created with Blueprint("shop", __name__, url_prefix="/shop") and has a route @bp.route("/items") on a function items. What does url_for("shop.items") return, and what URL does the browser use?

Need a hint?

Prefix plus the route.

Show the full solution

Both are /shop/items. Without the blueprint name, url_for("items") raises a BuildError.

Your turn: 4. Where does it belong?

Sort these into model, view, controller: (a) check a username is not taken, (b) the HTML table of tasks, (c) class Task(db.Model), (d) deciding to redirect to login.

Need a hint?

Models describe data; views show it; controllers decide.

Show the full solution

(a) controller (it uses a model query), (b) view, (c) model, (d) controller.

Your turn: 5. Order of decorators

In @a above @b above def f(), which wrapper runs first when f() is called?

Need a hint?

Decorators apply bottom up, run top down.

Show the full solution

b wraps f first, then a wraps that result. The call enters a's wrapper first (outer), which calls b's wrapper (inner), which calls f.

Exam radar

Exam radar

The evidence counts questions whose text matches week 5 topics (keyword match, approximate): about 109 of 692 in Quiz 1, about 119 of 624 in Quiz 2 and about 232 of 1600 in End Term; 119 distinct questions in the sample. Most of these are predict what the browser shows questions.

Typical questions in the sample:

  • A small app with several routes, then "what will the browser render for this URL?" (trailing slash, missing variable gives Not Found, query string versus path variable).
  • A POST-only route requested from the browser (405 and its handler text).
  • url_for output, including extra keyword arguments becoming ?x=1.
  • abort(400/404) plus an errorhandler.
  • Which URL must be used for /text/size/<size>/<color> (the pieces go in route order).
  • Frontend versus backend validation, dynamic form generation.
  • Decorator output order, render_template with a changed template_folder, redirect(url_for(...)) chains.
Watch out - real exam trap
  • /home and /home/ are different routes when both are defined.
  • A route variable never matches an empty piece or a / (except path).
  • URL pieces must follow the route order: /text/size/20/red, not /text/size/red/20.
  • A query string is not a path variable.
  • A view function with the wrong method list never runs its body.
  • The else inside a for loop's if returns early.

Quick quiz

Quick quiz

A route is @app.route("/user/<int:id>"). Which URL gives 404?

  • /user/2001
  • /user/abc
  • /user/7?x=1
  • /user/0
int accepts only digits (even 0); letters do not match, so Flask returns 404.

A route allows only POST. What does the browser show when you open its URL?

  • the function's output
  • 405 Method Not Allowed (or the 405 handler's text)
  • 404 Not Found
  • 500 Internal Server Error
The address bar sends GET, which is not allowed for that route.

Which statements about session are TRUE? (select all that apply)

  • It needs secret_key.
  • The data is stored in a signed cookie in the browser.
  • It is stored in the database by default.
  • The user can edit it freely and Flask will accept it.
The signature stops tampering; the data is not in the database unless you build that.

url_for("plus_one", n=3, x=1) for the route /n/<int:n> returns...

  • /n/3/1
  • /n/1/3
  • /n/3?x=1
  • an error
Values that match the route are in the path; extras become the query string.

After a successful form POST you should...

  • render the page directly
  • redirect to another page (Post/Redirect/Get)
  • call abort(200)
  • clear the cookies
Redirecting means a refresh repeats a harmless GET, not a second POST.

@app.route("/n/<n>") returns the string n. What does /n/5 return?

  • 5 as an int
  • the text 5
  • 404
  • an error
Without a converter the variable is a string.

Viva: say it out loud

Viva: say it out loud
Explain how a request travels through your Flask app.

The browser sends a URL and a method. Flask matches it to a route and calls the view function. The function reads request, applies my rules, talks to the models, and returns either HTML from render_template or a redirect. Flask turns that into the HTTP response.

What is the difference between request.args and request.form?

args holds the query string after the question mark and is usually used with GET. form holds the fields from a submitted HTML form in the body of a POST. For a JSON API I would use request.json.

Why do you redirect after a POST?

Otherwise, refreshing the page would send the form again and might create a duplicate record. Redirect after POST means the browser ends up on a normal GET page.

How does session work and why do you need secret_key?

Flask stores the session data in a cookie in the browser and signs it with the secret key. The signature makes tampering detectable. Without a secret key, Flask cannot sign, so it refuses to use sessions or flash.

What is a blueprint and why did you use one?

A blueprint is a group of routes registered on the app with a prefix. It keeps a big app tidy, for example separate blueprints for authentication and tasks. Endpoint names then have the prefix, such as tasks.list_tasks.

Where is your business logic and give an example.

It is in the controller. For example, before adding a task I check that the title is not blank, that this user does not already have it, and that the user is logged in; before deletion I check the task belongs to the user (otherwise 403).

Why url_for instead of typing the path?

If I change a route later, every link built with url_for updates by itself, and it fills in variables correctly. Hard-coded strings silently break.

What does a virtual environment and requirements.txt give you?

The environment keeps this project's packages separate. pip freeze > requirements.txt records the versions so the examiner can run pip install -r requirements.txt and get the same setup.

Recap

Remember this
  • A Flask app: app.py, templates/, static/, models; use a virtual environment and requirements.txt.
  • Routing: @app.route(rule, methods=[...]); <int:id> variables; only the path is matched; trailing slash rules; default method is GET.
  • request: args, form, files, json, method. Responses: string, tuple with status, dict or jsonify, render_template, redirect, abort.
  • Errors: abort(code) plus @app.errorhandler(code). A wrong method gives 405; a missing variable piece gives 404.
  • flash and session need secret_key; session is a signed cookie.
  • Post/Redirect/Get; validation, duplicates, authorisation and delete rules are business logic in the controller.
  • Blueprints group routes (url_for("bp.func")); an application factory builds the app; app.config holds settings.
  • MVC: model = data, view = template, controller = route.
Code and facts to keep
  • @app.route("/x/<int:id>", methods=["GET", "POST"])
  • request.args.get("q"), request.form.get("f"), request.json, request.method
  • return "text", 201; return {"a": 1}; return jsonify(list)
  • redirect(url_for("endpoint", arg=1)); abort(404)
  • @app.errorhandler(404); flash("msg"); get_flashed_messages()
  • app.secret_key = "..."; session["k"] = v; session.get("k"); session.pop("k", None)
  • Blueprint("name", __name__, url_prefix="/p"); app.register_blueprint(bp)
  • db.init_app(app); def create_app(): ...; return app
  • python -m venv venv; pip install -r requirements.txt; pip freeze > requirements.txt
  • Status codes: 200 OK, 302 redirect, 400 bad request, 401 unauthorised, 403 forbidden, 404 not found, 405 wrong method, 415 wrong media type, 500 server error
Connected weeks (learn them together)

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 2 papers