Week 5 · Controllers: Flask routes and business logic
The one-minute story
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
requestobject; - 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
- 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_foruses 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").
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)
| URL | Result |
|---|---|
/greet/Ann | Hi Ann |
/greet/ | 404 Not Found (the variable cannot be empty) |
/greet | 404 Not Found |
/n/5 | 6 |
/n/x | 404 (not an int) |
/greet/Ann?x=1 | Hi Ann (the query string is not part of the route) |
Converters: string (default), int, float, path (allows slashes), uuid.
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/2001and also/user/id=2001(thenidis the textid=2001), but it does not match/2001or/id=2001. With<int:id>, the textid=2001would no longer match. - A query string never fills a
<var>: for/<uname>and the URL/102?uname=MAD1,unameis102, notMAD1.
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.
| What | Code | Example |
|---|---|---|
| Method | request.method | "GET" or "POST" |
| Query string | request.args.get("page", 1, type=int) | /q?page=3 gives 3 |
| Form fields | request.form.get("name") | from <form method="post"> |
| Uploaded files | request.files["photo"] | needs enctype="multipart/form-data" |
| JSON body | request.json or request.get_json() | needs Content-Type: application/json |
| Headers, cookies | request.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 value | What the browser gets |
|---|---|
| a string | status 200, text/html |
"made", 201 | body plus a status code |
a dict | JSON, 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 |
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).
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.
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.
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 standard recipe for a form route (Post/Redirect/Get):
- GET: show the empty form.
- POST: read
request.form, validate. - If bad:
flasha message and show the form again (or redirect to it). - 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.
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.
- 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
flashwith nosecret_keyfails; asessionwith nosecret_keyfails. - 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.
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.
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.
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.
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
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_foroutput, including extra keyword arguments becoming?x=1.abort(400/404)plus anerrorhandler.- 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_templatewith a changedtemplate_folder,redirect(url_for(...))chains.
/homeand/home/are different routes when both are defined.- A route variable never matches an empty piece or a
/(exceptpath). - 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
elseinside aforloop'sifreturns early.
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
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.
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
After a successful form POST you should...
- render the page directly
- redirect to another page (Post/Redirect/Get)
- call
abort(200) - clear the cookies
@app.route("/n/<n>") returns the string n. What does /n/5 return?
- 5 as an int
- the text
5 - 404
- an error
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
- A Flask app:
app.py,templates/,static/, models; use a virtual environment andrequirements.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 orjsonify,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.configholds settings. - MVC: model = data, view = template, controller = route.
@app.route("/x/<int:id>", methods=["GET", "POST"])request.args.get("q"),request.form.get("f"),request.json,request.methodreturn "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 apppython -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
- MAD2 week 5 - Vue with APIs: fetch, axios and lifecycle: Every Flask route you wrote becomes an API call that your Vue code makes with fetch or axios.
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