22 min read

> "The best internal tool is the one people actually use." — Marcus Webb, Acme Corp IT

Chapter 37: Building Simple Business Applications with Flask

"The best internal tool is the one people actually use." — Marcus Webb, Acme Corp IT


What You Will Learn

By the end of this chapter, you will be able to:

  • Explain what a web framework is and why Flask is the right choice for internal business tools
  • Create a Flask application from scratch, define routes, and run the development server
  • Use URL routing, URL parameters, and handle both GET and POST HTTP requests
  • Render HTML pages using Jinja2 templates with template inheritance
  • Build forms that accept user input and process it on the server
  • Connect a Flask app to CSV data and a SQLite database
  • Serve static files (CSS, JavaScript, images)
  • Understand the critical difference between Flask's development server and a production server
  • Build a complete internal business dashboard with a form and rudimentary access control
  • Articulate what Flask cannot do out of the box and when to use it versus alternatives

Opening Scenario: Priya's Thursday Morning Ritual

Every Thursday at 8:47 a.m., Priya Okonkwo ran the same four-step process. She opened the acme_sales_2023.csv file, copied the week's numbers into a summary spreadsheet, formatted the cells, exported it as a PDF, attached it to an email, and sent it to Sandra Chen. Sandra would reply within the hour — sometimes with a question that required Priya to repeat steps one through four with slightly different parameters.

Priya had already automated the data processing part with pandas (Chapter 13) and the email delivery (Chapter 19). But Sandra still had to wait until Thursday morning. If she was on a call Monday afternoon and needed to know how the Northeast region was tracking against quota, she had to either ask Priya to interrupt what she was doing or wait until Thursday.

"What if Sandra could just look it up herself?" Priya thought one Tuesday afternoon. Not a dashboard in Tableau, not a Power BI report — something Priya could build, control, and deploy on the company intranet without waiting for IT procurement to approve a new SaaS license.

That thought is exactly where Flask comes in.


37.1 What Is a Web Framework — and Why Flask?

The 10,000-Foot View

When you open a browser and navigate to any web page, you are sending a request to a server. The server processes that request, does whatever work is needed (query a database, read a file, run a calculation), and sends back a response — typically an HTML page. A web framework is a library that handles the plumbing between requests and responses so you can focus on the business logic.

Python has several web frameworks. Understanding the landscape helps you choose the right tool.

Django is the full-featured, batteries-included option. It comes with a built-in admin interface, an ORM (Object-Relational Mapper) for database access, user authentication, form handling, and a rigid project structure that enforces good practices. Django is excellent for large, complex applications. It is also significantly more complex to learn and harder to adapt when you just need something simple and internal.

FastAPI is a modern framework optimized for building APIs — JSON-returning endpoints that power mobile apps and other services. It is exceptionally fast and has excellent support for type hints and automatic documentation. FastAPI is the right choice when your primary output is JSON, not HTML pages.

Flask occupies a deliberately different position. It is described as a microframework — not because it is limited, but because it starts small and lets you add only what you need. A minimal Flask application is five lines of Python. It does not assume a particular database, a particular template engine, or a particular project structure. You are in control.

For business professionals building internal tools, this is a feature, not a limitation. You already know your data, your workflow, and your constraints. Flask gets out of your way and lets you build the tool rather than spending weeks learning the framework.

Why Flask Specifically

Flask is right for your situation when:

  • You need an internal web tool — a dashboard, a data entry form, a status page — that runs on your company network or a cheap hosting plan
  • You want something Python programmers can read, modify, and understand without a framework-specific knowledge base
  • Your tool's complexity is bounded: a handful of pages, a few forms, data from CSV or a small database
  • You need to ship something functional in days, not weeks

Flask is probably not the right choice when:

  • You are building a public-facing application with user registration, OAuth login, and a complex permission system (use Django)
  • Your primary output is a JSON API for a mobile app (use FastAPI)
  • You need built-in admin interfaces, role-based access control, or a content management system (use Django)
  • You need real-time features like live chat or push notifications (use Django Channels or a dedicated solution)

The honest framing: Flask is a power tool for building focused, functional, internal applications. It will not build your application for you. But if you know what you want to build, Flask will stay out of your way while you build it.


37.2 Setting Up Flask

Installation

Flask requires its own virtual environment, just like any other Python project. From your project directory:

# Create a virtual environment
python -m venv venv

# Activate it (Windows)
venv\Scripts\activate

# Activate it (macOS/Linux)
source venv/bin/activate

# Install Flask
pip install flask python-dotenv

# Freeze your requirements
pip freeze > requirements.txt

The python-dotenv package reads environment variables from a .env file, which you will use to manage configuration without hardcoding sensitive values. This is the same pattern introduced in Chapter 24.

Your First Flask Application

Create a file called app.py in your project directory:

from flask import Flask

app = Flask(__name__)


@app.route("/")
def index():
    return "Acme Corp Internal Tools — Welcome"


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

Run it:

python app.py

Flask will print output like:

 * Running on http://127.0.0.1:5000
 * Debug mode: on

Open your browser and navigate to http://127.0.0.1:5000. You will see the string you returned from the index() function rendered as plain text in the browser.

That is the complete minimal Flask application. Let's understand each line.

Anatomy of a Flask Application

from flask import Flask — imports the Flask class from the flask package.

app = Flask(__name__) — creates an instance of the Flask application. The __name__ argument tells Flask where to find templates and static files relative to this file. When this file is run directly, __name__ equals "__main__". When it is imported as a module, __name__ is the module name. Flask uses this to resolve file paths correctly.

@app.route("/") — this is a decorator, a Python feature that wraps a function with additional behavior. Here, it registers the function below it as the handler for the URL path /. When Flask receives a request for /, it calls index() and returns whatever that function returns as the HTTP response.

def index(): return "..." — a standard Python function that returns a string. Flask sends that string back as the response body. Returning a string causes Flask to set the Content-Type header to text/html, so the browser renders it as HTML (or plain text, depending on the content).

if __name__ == "__main__": app.run(debug=True) — runs the development server when this file is executed directly. The debug=True parameter enables two important features: automatic reloading when you save file changes (no need to restart the server), and the interactive debugger in the browser if your code raises an exception.


37.3 URL Routing

Routing is the process of mapping URL paths to Python functions. Flask's @app.route() decorator is the primary way to define routes.

Basic Routes

from flask import Flask

app = Flask(__name__)


@app.route("/")
def index():
    return "<h1>Acme Corp Internal Tools</h1>"


@app.route("/dashboard")
def dashboard():
    return "<h1>Sales Dashboard</h1>"


@app.route("/expenses")
def expenses():
    return "<h1>Expense Submission</h1>"

Each route maps a URL path to a function. The function returns the HTTP response body — in this case, raw HTML strings, though you will see shortly why template files are far better than inline HTML strings.

URL Parameters

Routes can include variable parts, captured as parameters in the function:

@app.route("/client/<client_id>")
def client_detail(client_id):
    return f"<h1>Details for client: {client_id}</h1>"


@app.route("/report/<int:year>/<int:month>")
def monthly_report(year, month):
    return f"<h1>Report for {year}-{month:02d}</h1>"

The angle bracket syntax <variable_name> captures that segment of the URL as a string. The <int:variable_name> syntax uses a converter to automatically cast the segment to an integer — if the value cannot be converted, Flask returns a 404 error automatically.

Flask provides these built-in converters:

Converter Description
string Default — accepts any text without a slash
int Positive integers
float Positive floating point values
path Like string, but accepts slashes
uuid UUID strings

HTTP Methods

Every HTTP request uses a method (also called a verb) that indicates the intended action. The two you will use most frequently:

GET — retrieves data. Used when a user navigates to a URL. No side effects expected — the server should return the same data every time (given the same inputs). This is the default method for all Flask routes.

POST — submits data to the server. Used when a user submits a form. The submitted data travels in the request body, not the URL. POST requests typically change server state — inserting a database record, sending an email, writing a file.

A single route can handle multiple methods:

from flask import Flask, request

app = Flask(__name__)


@app.route("/expense", methods=["GET", "POST"])
def expense_form():
    if request.method == "POST":
        # Form was submitted — process the data
        amount = request.form.get("amount")
        description = request.form.get("description")
        return f"<p>Received expense: {description} — ${amount}</p>"
    else:
        # GET request — show the empty form
        return """
        <form method="POST">
            <input name="description" placeholder="Description">
            <input name="amount" type="number" placeholder="Amount">
            <button type="submit">Submit</button>
        </form>
        """

The request object (imported from flask) provides access to all data in the incoming HTTP request: form fields, URL parameters, headers, cookies, and the raw request body.


37.4 Templates with Jinja2

Returning HTML strings directly from Python functions works for simple cases but becomes unmanageable quickly. Imagine maintaining a 500-line HTML page inline in your Python code — the logic and the presentation become hopelessly entangled.

The proper solution is templates: HTML files with special placeholder syntax that Flask fills in with data from your Python code. Flask uses the Jinja2 template engine, which you actually met in Chapter 36 when generating HTML reports. The same syntax applies here, but now Flask serves these templates as live web pages rather than saved files.

Project Structure for Templates

Flask looks for templates in a subdirectory named templates/ relative to your app.py file. Static files (CSS, JavaScript, images) go in static/.

your_project/
├── app.py
├── .env
├── requirements.txt
├── templates/
│   ├── base.html
│   ├── index.html
│   └── dashboard.html
├── static/
│   ├── css/
│   │   └── style.css
│   └── js/
│       └── main.js
└── data/
    └── acme_sales.csv

Rendering a Template

from flask import Flask, render_template

app = Flask(__name__)


@app.route("/dashboard")
def dashboard():
    metrics = {
        "total_revenue": 1_245_780.50,
        "deals_closed": 47,
        "quota_attainment": 92.3,
    }
    return render_template("dashboard.html", metrics=metrics)

The render_template() function reads the named template file from the templates/ directory, processes all Jinja2 expressions using the keyword arguments you pass, and returns the resulting HTML string as the response.

Jinja2 Syntax Essentials

You already know Jinja2 from Chapter 36. Here is a consolidated reference for the patterns you will use in Flask templates:

Variable output{{ variable_name }} — renders the value of a variable:

<h2>Total Revenue: ${{ "{:,.2f}".format(metrics.total_revenue) }}</h2>
<p>Deals Closed: {{ metrics.deals_closed }}</p>

Control structures{% ... %} — logic blocks that do not produce output themselves:

{% if metrics.quota_attainment >= 100 %}
    <span class="badge success">On Target</span>
{% elif metrics.quota_attainment >= 80 %}
    <span class="badge warning">At Risk</span>
{% else %}
    <span class="badge danger">Behind</span>
{% endif %}

Loops:

<table>
{% for row in sales_data %}
    <tr>
        <td>{{ row.region }}</td>
        <td>${{ "{:,.2f}".format(row.revenue) }}</td>
    </tr>
{% endfor %}
</table>

Filters — built-in transformations applied with the pipe character:

{{ client_name | upper }}
{{ description | truncate(50) }}
{{ timestamp | default("N/A") }}

Template Inheritance: The base.html Pattern

Every page in your application shares common structure: the HTML document skeleton, navigation, header, footer, CSS links, and JavaScript includes. Duplicating this structure in every template creates a maintenance nightmare — change the navigation and you need to edit every file.

Template inheritance solves this elegantly. You create one base.html that defines the shared structure, with designated blocks that child templates can override.

templates/base.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}Acme Corp Internal Tools{% endblock %}</title>
    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
    <nav class="navbar">
        <div class="nav-brand">Acme Corp</div>
        <ul class="nav-links">
            <li><a href="{{ url_for('index') }}">Home</a></li>
            <li><a href="{{ url_for('dashboard') }}">Dashboard</a></li>
            <li><a href="{{ url_for('expense_form') }}">Expenses</a></li>
        </ul>
    </nav>

    <main class="content">
        {% block content %}{% endblock %}
    </main>

    <footer>
        <p>Acme Corp Internal Tools &copy; 2024</p>
    </footer>

    <script src="{{ url_for('static', filename='js/main.js') }}"></script>
    {% block scripts %}{% endblock %}
</body>
</html>

A child template extending this base:

{% extends "base.html" %}

{% block title %}Sales Dashboard — Acme Corp{% endblock %}

{% block content %}
<div class="dashboard-header">
    <h1>Sales Dashboard</h1>
    <p class="subtitle">Live metrics — updated on page load</p>
</div>

<div class="metrics-grid">
    <div class="metric-card">
        <span class="metric-label">Total Revenue</span>
        <span class="metric-value">${{ "{:,.0f}".format(metrics.total_revenue) }}</span>
    </div>
</div>
{% endblock %}

The {% extends "base.html" %} tag tells Jinja2 that this template inherits from base.html. The {% block content %}...{% endblock %} replaces the corresponding block in the base template. Any block left undefined in a child template falls back to the base template's default content.

The url_for() Function

Notice the url_for() calls in the templates above. This is a critical Flask function that generates URLs for routes and static files. Instead of hardcoding /dashboard in your links, you write url_for('dashboard') — using the name of the view function. This means if you ever change a route's URL, all links throughout your templates update automatically.

For static files: url_for('static', filename='css/style.css') generates the correct URL for a file in your static/ directory, handling any path prefix your application might have.


37.5 Forms and User Input

Forms are how users send data to your Flask application. The flow is straightforward:

  1. User requests a page with a form (GET request)
  2. Flask renders the form template
  3. User fills out the form and clicks Submit
  4. Browser sends a POST request with the form data
  5. Flask processes the data and returns a response (either a confirmation page or the form again with error messages)

Reading Form Data

Flask's request object provides access to submitted form data through request.form, which behaves like a dictionary:

from flask import Flask, request, render_template, redirect, url_for

app = Flask(__name__)


@app.route("/expense", methods=["GET", "POST"])
def expense_form():
    if request.method == "POST":
        description = request.form.get("description", "").strip()
        amount_str = request.form.get("amount", "0").strip()
        category = request.form.get("category", "")

        # Process the submission
        # (save to database, send email, etc.)
        return redirect(url_for("expense_success"))

    return render_template("expense_form.html")

request.form.get("field_name") returns None if the field does not exist, unlike request.form["field_name"] which raises a KeyError. For user-submitted data, always use .get().

Basic Validation

Flask does not include a built-in form validation library (that is what WTForms is for, which you can add as an extension). For simple internal tools, manual validation works fine:

@app.route("/expense", methods=["GET", "POST"])
def expense_form():
    errors = {}

    if request.method == "POST":
        description = request.form.get("description", "").strip()
        amount_str = request.form.get("amount", "0").strip()

        # Validate description
        if not description:
            errors["description"] = "Description is required."
        elif len(description) > 200:
            errors["description"] = "Description must be under 200 characters."

        # Validate amount
        try:
            amount = float(amount_str)
            if amount <= 0:
                errors["amount"] = "Amount must be greater than zero."
        except ValueError:
            errors["amount"] = "Please enter a valid dollar amount."

        # If no errors, process and redirect
        if not errors:
            # Save to database or CSV, send notification, etc.
            return redirect(url_for("expense_success"))

    # GET request OR POST with validation errors
    return render_template(
        "expense_form.html",
        errors=errors,
        form_data=request.form,  # repopulate fields on validation failure
    )

Re-sending form_data=request.form back to the template lets you repopulate the form fields so the user does not have to retype everything when fixing a validation error. In the template:

<input
    type="text"
    name="description"
    value="{{ form_data.get('description', '') }}"
    class="{{ 'input-error' if errors.description else '' }}"
>
{% if errors.description %}
    <span class="error-message">{{ errors.description }}</span>
{% endif %}

Redirecting After Form Submission

The pattern return redirect(url_for("success_page")) after a successful POST is called the Post/Redirect/Get (PRG) pattern. It is a fundamental web development best practice.

Without PRG: after a successful POST, if the user refreshes the page, the browser re-submits the form, potentially creating duplicate records.

With PRG: after the POST is processed, you redirect to a GET request for a confirmation page. Refreshing the confirmation page re-loads the GET, not the POST. No duplicates.


37.6 Connecting Flask to Data

The real power of Flask for business use comes from its ability to display live data from your existing data sources.

Reading from CSV with pandas

import pandas as pd
from flask import Flask, render_template

app = Flask(__name__)


def load_sales_data():
    """Load and aggregate Acme sales data from CSV.

    Returns aggregated metrics suitable for the dashboard.
    Uses a fresh read on each call so the dashboard always
    shows current data without restarting the server.
    """
    sales_df = pd.read_csv("data/acme_sales.csv", parse_dates=["sale_date"])

    metrics = {
        "total_revenue": float(sales_df["revenue"].sum()),
        "deals_closed": int(sales_df["deal_id"].nunique()),
        "avg_deal_size": float(sales_df["revenue"].mean()),
        "by_region": (
            sales_df.groupby("region")["revenue"]
            .sum()
            .reset_index()
            .rename(columns={"revenue": "total_revenue"})
            .to_dict("records")
        ),
    }
    return metrics


@app.route("/dashboard")
def dashboard():
    metrics = load_sales_data()
    return render_template("dashboard.html", metrics=metrics)

Notice that load_sales_data() is called inside the route function, not at module level. This means every time someone loads the dashboard, the CSV is re-read. For small files, this is perfectly acceptable and ensures the data is always fresh. For larger files, you would add caching.

Connecting to SQLite

The pattern for SQLite (introduced in Chapter 23) integrates cleanly with Flask:

import sqlite3
from flask import Flask, render_template, g

app = Flask(__name__)
DATABASE = "data/acme_inventory.db"


def get_db():
    """Get a database connection for the current request context.

    Flask's 'g' object is a per-request store that is reset
    between requests. This ensures each request gets its own
    connection and the connection is closed when the request ends.
    """
    if "db" not in g:
        g.db = sqlite3.connect(DATABASE)
        g.db.row_factory = sqlite3.Row  # enables column access by name
    return g.db


@app.teardown_appcontext
def close_db(error):
    """Close the database connection after each request."""
    db = g.pop("db", None)
    if db is not None:
        db.close()


@app.route("/inventory")
def inventory():
    db = get_db()
    products = db.execute(
        "SELECT product_name, quantity, reorder_point "
        "FROM inventory ORDER BY quantity ASC"
    ).fetchall()

    return render_template("inventory.html", products=products)

The g object is a Flask concept: a request-scoped namespace. Anything you store in g is available throughout the current request and automatically discarded afterward. Using it for database connections ensures you open one connection per request, not one per function call — a common beginner mistake that causes connection pool exhaustion under load.


37.7 Static Files

CSS, JavaScript, images, and other assets that do not change dynamically are called static files. Flask serves them from the static/ directory.

In your templates, always reference static files with url_for('static', filename='...'):

<!-- CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">

<!-- JavaScript -->
<script src="{{ url_for('static', filename='js/charts.js') }}"></script>

<!-- Image -->
<img src="{{ url_for('static', filename='images/acme-logo.png') }}" alt="Acme Corp">

Never use hardcoded paths like /static/css/style.css in templates. If you deploy your application at a URL prefix (e.g., https://intranet.acme.com/tools/), hardcoded paths will break. url_for() handles prefixes automatically.

For a simple but professional internal tool UI, Bootstrap CSS is an excellent choice — it provides a clean, responsive design system without requiring you to write much custom CSS. You can either download it to your static/css/ directory or link to it from a CDN in your base template.


37.8 Development Server vs. Production Server

This distinction is one of the most important — and most frequently ignored — aspects of Flask development.

Flask's Development Server

When you run python app.py with debug=True, Flask starts a built-in development server. This server:

  • Serves only one request at a time (single-threaded by default)
  • Is not designed to handle concurrent users
  • Has security features intentionally disabled for developer convenience
  • Includes an interactive debugger that can execute arbitrary Python code — anyone with access to the debugger URL can run any Python code on your machine
  • Reloads automatically when you change your code

Flask's own documentation is explicit: "Do not use the development server in a production deployment. It is intended for use only during local development."

For a truly internal tool running on a company intranet with controlled access and a handful of users, the development server's limitations are less critical. But it is still best practice to use a proper production server even for internal deployments.

Production Servers: Gunicorn and Waitress

Gunicorn (Green Unicorn) is the standard production WSGI server for Flask applications on Linux/macOS. It is a pre-fork worker model — it spawns multiple worker processes, each of which can handle one request at a time. Running four workers (the typical default) means your application can handle four simultaneous requests.

# Install Gunicorn
pip install gunicorn

# Run the Flask app with 4 worker processes
gunicorn --workers 4 --bind 0.0.0.0:8000 "app:app"

The "app:app" syntax means: look in the file app.py for an object named app. (If your Flask instance variable is named differently, adjust accordingly.)

Waitress is the Windows-compatible alternative. Gunicorn does not run on Windows, which is fine for production deployments that target Linux servers.

pip install waitress

# Run with Waitress
waitress-serve --port=8000 app:app

The Critical Difference in Practice

For the purposes of building internal business tools in this book, you will run the development server locally and understand that deploying to a real server (Chapter 38) means switching to Gunicorn. The key workflow rule: debug=True in development, debug=False (or removed entirely) before any deployment — even internal.

A .env file manages this cleanly:

# .env (development)
FLASK_DEBUG=true
SECRET_KEY=dev-only-not-for-production
# app.py
import os
from flask import Flask
from dotenv import load_dotenv

load_dotenv()

app = Flask(__name__)
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "fallback-change-me")
app.config["DEBUG"] = os.environ.get("FLASK_DEBUG", "false").lower() == "true"

37.9 Building a Complete Internal Business Tool

Priya's plan comes together in this section. The goal: a simple internal dashboard that Sandra can open in her browser to see live sales metrics, and a form for submitting expenses that routes to Priya for approval.

The complete application code is in code/app.py. Here we walk through the architecture and key design decisions.

Application Architecture

The complete application has these components:

chapter-37-flask/code/
├── app.py                   # Main Flask application
├── .env.example             # Template for environment variables
├── requirements.txt         # Package dependencies
├── data/
│   └── acme_sales.csv       # Sample sales data (generated by app.py)
├── templates/
│   ├── base.html            # Shared layout
│   ├── index.html           # Home page
│   ├── dashboard.html       # Sales dashboard
│   ├── expense_form.html    # Expense submission form
│   └── expense_success.html # Confirmation page
└── static/
    └── css/
        └── style.css        # Application styles

Route Map

URL Method Function Description
/ GET index Home page with navigation
/dashboard GET dashboard Live sales metrics dashboard
/expenses GET, POST expense_form Expense submission form
/expenses/success GET expense_success Confirmation after submission
/api/metrics GET api_metrics JSON endpoint for chart data

Simple Access Control

Real user authentication is beyond what Flask provides out of the box — that is a job for Flask-Login or a full framework like Django. However, for an internal tool where you simply want to prevent accidental access, a simple password check provides a baseline layer of protection.

The mechanism uses Flask's session — a dictionary stored on the client as a signed, encrypted cookie. As long as your SECRET_KEY is kept secret, the session data cannot be tampered with.

from flask import Flask, session, request, redirect, url_for, render_template
import os
from dotenv import load_dotenv

load_dotenv()

app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY", "change-this-in-production")

# The password from the environment (NOT hardcoded in source code)
DASHBOARD_PASSWORD = os.environ.get("DASHBOARD_PASSWORD", "acme2024")


def login_required(view_func):
    """Decorator that redirects to login if user is not authenticated.

    This is a very basic implementation — not suitable for
    production applications with sensitive data.
    """
    from functools import wraps

    @wraps(view_func)
    def wrapper(*args, **kwargs):
        if not session.get("authenticated"):
            return redirect(url_for("login"))
        return view_func(*args, **kwargs)

    return wrapper


@app.route("/login", methods=["GET", "POST"])
def login():
    error = None
    if request.method == "POST":
        password = request.form.get("password", "")
        if password == DASHBOARD_PASSWORD:
            session["authenticated"] = True
            return redirect(url_for("dashboard"))
        else:
            error = "Incorrect password. Please try again."

    return render_template("login.html", error=error)


@app.route("/logout")
def logout():
    session.pop("authenticated", None)
    return redirect(url_for("index"))


@app.route("/dashboard")
@login_required
def dashboard():
    metrics = load_sales_data()
    return render_template("dashboard.html", metrics=metrics)

Important caveat: This is functional for an internal intranet tool where you control access to the network. It is not appropriate for a public-facing application, a tool handling sensitive personal data, or any system where real security is required. For those situations, use Flask-Login with properly hashed passwords, or deploy behind your company's single sign-on (SSO) system. Chapter 39 and the further reading section discuss this more.


37.10 What Flask Cannot Do Out of the Box

Being honest about limitations is as important as demonstrating capabilities. Flask's microframework philosophy means you get a routing engine and template system — and not much else. Here is what you will need to add separately:

User management — Flask has no concept of user accounts, roles, or permissions. Flask-Login is the standard extension for session-based authentication with real user accounts.

Database migrations — Flask does not manage database schema changes. If you add a column to a database table, you handle that SQL yourself. Flask-Migrate (wrapping Alembic) handles this for SQLAlchemy-based apps.

Form validation — Manual validation as shown earlier works for simple cases. WTForms with Flask-WTF provides robust validation, CSRF protection, and reusable form classes.

CSRF protection — Cross-Site Request Forgery attacks can affect any application that processes POST requests. Flask does not add CSRF tokens automatically. Flask-WTF handles this.

Real authentication security — The session-based password check above is simple but limited. It lacks password hashing, account lockout after failed attempts, session expiration, and many other features of a proper auth system.

Background tasks — If a form submission triggers a long-running operation (generating a PDF, calling a slow API), Flask will hold the HTTP connection open until the operation completes. Celery or RQ are task queue solutions for this.

None of these are reasons not to use Flask. They are reasons to understand your tool's appropriate scope. For an internal dashboard on the company intranet with five users who share a password? Flask with simple session auth is absolutely appropriate. For a customer-facing application? Add the right extensions.


37.11 Maya's Client-Facing Project Status App

Maya has been working toward this moment since Chapter 1. Let's trace her arc:

  • Chapter 9: She started tracking project data in CSV files
  • Chapter 16: She automated invoice generation from her project data
  • Chapter 22: She set up scheduled weekly summaries
  • Chapter 23: She migrated her data to SQLite for more robust querying
  • Chapter 36: She built automated PDF status reports
  • Chapter 37 (now): She wraps it all in a client-facing web application

The application Maya builds allows each of her clients to visit a URL, enter their project code, and see a live status page showing:

  • Project name, status, and phase
  • Hours logged versus hours budgeted
  • Budget consumed versus total budget
  • Key milestones and their completion status
  • Next scheduled check-in date

From the client's perspective, it looks like a professional client portal. From Maya's perspective, it is about 200 lines of Python and a handful of templates, all backed by the SQLite database she has been building since Chapter 23.

The complete implementation is in Case Study 2. The architecture:

maya_client_portal/
├── app.py
├── .env
├── data/
│   └── maya_projects.db     # SQLite database from Chapter 23
├── templates/
│   ├── base.html
│   ├── login.html           # Project code entry
│   ├── project_status.html  # The client-facing status page
│   └── 404.html             # Custom error page
└── static/
    └── css/
        └── portal.css

The login mechanism uses project codes rather than passwords — each client has a unique code (like ACME-2024-001) that identifies their project and authenticates them simultaneously. This is appropriate for status pages where the data is not sensitive enough to require full authentication infrastructure.

@app.route("/", methods=["GET", "POST"])
def project_login():
    error = None
    if request.method == "POST":
        project_code = request.form.get("project_code", "").strip().upper()
        project = get_project_by_code(project_code)

        if project:
            session["project_code"] = project_code
            session["project_id"] = project["id"]
            return redirect(url_for("project_status"))
        else:
            error = "Project code not found. Please check with your consultant."

    return render_template("login.html", error=error)


@app.route("/status")
def project_status():
    if "project_code" not in session:
        return redirect(url_for("project_login"))

    project_id = session["project_id"]
    project = get_project_detail(project_id)
    milestones = get_project_milestones(project_id)
    time_entries = get_time_summary(project_id)

    return render_template(
        "project_status.html",
        project=project,
        milestones=milestones,
        time_entries=time_entries,
    )

The full implementation is in case-study-02.md.


37.12 When to Use Flask vs. Alternatives

Making the right tool choice is a professional skill. Here is a decision framework:

Use Flask When

  • You need a custom internal tool and no existing SaaS exactly fits
  • Your team has Python skills but not JavaScript/React skills
  • You want full control over your data and logic without vendor lock-in
  • The tool is bounded in scope (a dashboard, a form, a status page)
  • You need to integrate tightly with your existing Python data processing pipeline
  • You want to understand every layer of what you have built

Use a No-Code/Low-Code Tool When

  • You need basic forms, databases, and automation without writing code
  • Time to deliver is the primary constraint and Python skills are limited
  • The tool maps well to an existing template (survey form, simple CRUD app, kanban board)
  • Consider: Airtable, Notion, Retool, Google AppSheet, Zapier

Use Django When

  • You need user registration, role-based permissions, or a content management system
  • Multiple developers will work on the application simultaneously
  • You need a structured, opinionated project layout
  • The application will grow significantly in scope

Use FastAPI When

  • You are building an API that will be consumed by a mobile app, another service, or a JavaScript frontend
  • You need automatic API documentation
  • Performance under high load is a requirement

The Honest Assessment

For business professionals who are Python-competent but not software engineers, Flask represents the practical ceiling for self-built web tools. You can build genuinely useful, professional-quality internal applications. You will hit limits when you need real user management, complex permissions, or high-scale deployment. When you hit those limits, the right answer is usually to hand off to a professional developer, purchase a purpose-built SaaS tool, or expand your skills in the next phase of your Python journey.

Flask is not the destination. It is the point at which you stop being someone who analyzes data and start being someone who builds tools.


37.13 The Complete Application Code Walkthrough

The full application in code/app.py includes:

  1. Home page (/) — introductory page with navigation to dashboard and expense form
  2. Dashboard (/dashboard) — reads from acme_sales.csv, computes metrics with pandas, renders the dashboard template. Protected by the simple session-based login.
  3. Login (/login) — password form that sets the session variable
  4. Expense form (/expenses) — two-step (GET/POST) form with validation that saves submissions to a CSV log and returns a confirmation
  5. API endpoint (/api/metrics) — returns JSON-formatted metrics for potential use by chart libraries. Demonstrates how Flask can serve both HTML pages and JSON data from the same application.

A sample data generator is included at the bottom of app.py — running python app.py --generate-data creates a realistic acme_sales.csv if one does not exist, so you can run the application without needing the real Acme Corp dataset.


Chapter Summary

Flask is a Python microframework that lets you build web applications using the Python skills you already have. A Flask application maps URL routes to Python functions, which return responses — either HTML rendered from Jinja2 templates or raw data like JSON.

The building blocks you have learned:

  • Routes (@app.route()) — map URLs to Python functions
  • Templates — HTML files with Jinja2 expressions, rendered with render_template()
  • Template inheritancebase.html defines shared structure; child templates extend it
  • Forms — HTML forms send POST requests; request.form gives you access to the data
  • Static files — CSS, JavaScript, and images served from the static/ directory
  • Sessions — per-user state stored in signed cookies
  • Data integration — pandas and SQLite connect naturally to Flask routes

Flask is honest about what it does not provide: real authentication, database migrations, form validation, and background task queuing all require additional extensions. For bounded internal tools, these limitations are rarely constraints. For public-facing applications with security requirements, plan accordingly.


Key Terms

Web framework — A library that handles the mechanics of HTTP request/response cycles so you can focus on application logic.

Microframework — A framework that provides minimal built-in functionality and leaves architectural choices to the developer.

Route — A mapping between a URL path and a Python function that handles requests to that path.

Template — An HTML file containing Jinja2 expressions that are filled in with data at render time.

Template inheritance — A Jinja2 pattern where a base template defines common structure and child templates override specific blocks.

WSGI — Web Server Gateway Interface. The standard interface between Python web applications and web servers. Flask implements WSGI; Gunicorn is a WSGI server.

Session — Per-user state stored on the client as a signed, encrypted cookie. Cleared when the browser session ends.

PRG pattern — Post/Redirect/Get. After a successful form submission, redirect to a GET request to prevent duplicate submissions on page refresh.

Static files — Assets that do not change with each request: CSS, JavaScript, images. Served directly by Flask in development, by a web server like Nginx in production.


Next: Chapter 38 — Deploying Python to the Cloud. You have built the application. Now let's make it accessible to everyone who needs it, from anywhere, with professional reliability.