Tutorial-like documentation of Calisthenics Progression App

learningpythondevelopmentdocs

Calisthenics Progression — Beginner's Tutorial

A complete walkthrough of the project: what it does, how it's built, and why each piece works the way it does.


Table of Contents

  1. What is this project?
  2. Tech stack at a glance
  3. Project structure
  4. How Flask works: routes, blueprints, and the app factory
  5. The database layer: SQLAlchemy models
  6. Authentication system
  7. Email confirmation and tokens
  8. The check_confirmed decorator
  9. Core features: workouts and exercises
  10. Social features: follow, explore, messages, notifications
  11. Forms with Flask-WTF
  12. Error handling
  13. Configuration and environment variables
  14. Logging
  15. Deployment: passenger_wsgi and production setup
  16. How to replicate this project from scratch

1. What is this project?

Calisthenics Progression is a full-stack web application for tracking bodyweight workouts. Users can define their own exercises with progression levels (e.g. "knee push-up → incline push-up → full push-up"), log workout sessions, save reusable workout templates, browse other users' workouts, follow friends, and send messages — all from a browser.

This is a learning-oriented project that touches nearly every layer of a real production Flask app: user authentication (with email confirmation), database relationships, blueprints, custom decorators, form validation, async email, pagination, and deployment via a WSGI entry point. It's written in German (UI labels and flash messages), but the code itself is in English.


2. Tech stack at a glance

TechnologyWhat it does in this projectLearn more
FlaskThe web framework — handles HTTP requests and routes them to Python functions📖 Flask docs
SQLAlchemy / Flask-SQLAlchemyMaps Python classes to database tables (ORM)📖 SQLAlchemy docs
Flask-Migrate / AlembicGenerates and applies database schema migrations📖 Flask-Migrate
Flask-LoginManages user sessions (who is logged in?)📖 Flask-Login docs
Flask-WTF / WTFormsRenders and validates HTML forms, with CSRF protection built in📖 Flask-WTF docs
Flask-MailSends emails (account confirmation, password reset)📖 Flask-Mail docs
itsdangerousCreates and verifies cryptographically signed tokens for email confirmation📖 itsdangerous docs
PillowResizes profile pictures before saving them to disk📖 Pillow docs
Bootstrap-Flask / Bootstrap 5Provides ready-made CSS components (buttons, cards, navbars)📖 Bootstrap-Flask docs
Flask-MomentFormats timestamps in the user's local timezone via moment.js📖 Flask-Moment
python-dotenvLoads .env files into environment variables for local development📖 python-dotenv
GunicornProduction-grade WSGI server (used alongside Passenger on shared hosting)📖 Gunicorn docs
pytest / pytest-flaskAutomated testing framework📖 pytest docs

3. Project structure

calisthenics-progression/
├── calisthenics_progression.py   # Entry point: creates the Flask app
├── passenger_wsgi.py             # Deployment entry point (shared hosting / cPanel)
├── requirements.txt              # All Python dependencies
├── requirements-dev.txt          # Dev-only tools (linters, pre-commit)
├── pytest.ini                    # pytest configuration
├── mypy.ini                      # Type-checking configuration
├── pyrightconfig.json            # Alternative type checker config
│
├── project/                      # The main application package
│   ├── __init__.py               # App factory + extension initialization
│   ├── config.py                 # All configuration (reads from environment)
│   ├── models.py                 # Database models (User, Workout, Exercise, ...)
│   ├── decorators.py             # Custom route decorators (e.g. check_confirmed)
│   ├── email.py                  # Async email sending helper
│   ├── token.py                  # Email confirmation token generation/validation
│   │
│   ├── auth/                     # Blueprint: login, register, profile, password
│   │   ├── __init__.py
│   │   ├── routes.py
│   │   └── forms.py
│   │
│   ├── main/                     # Blueprint: workouts, exercises, social features
│   │   ├── __init__.py
│   │   ├── routes.py
│   │   └── forms.py
│   │
│   ├── errors/                   # Blueprint: 404 / 403 / 500 error pages
│   │   ├── __init__.py
│   │   └── handlers.py
│   │
│   ├── templates/                # Jinja2 HTML templates
│   │   ├── base.html             # Layout inherited by every page
│   │   ├── auth/                 # Login, register, forgot-password templates
│   │   ├── email/                # Email HTML templates
│   │   └── errors/               # Error page templates
│   │
│   └── static/                   # CSS, JS, fonts, profile pictures
│       ├── css/
│       ├── js/
│       ├── fonts/
│       └── profile_pics/
│
├── migrations/                   # Alembic database migration files
│   └── versions/                 # Each file is one schema change
│
├── tests/
│   ├── conftest.py               # Shared pytest fixtures
│   ├── unit/                     # Tests for individual functions/classes
│   └── integration/              # Tests for full HTTP request/response cycles
│
└── logs/                         # Log files (created automatically in production)

Think of the project/ folder as the city, and the blueprints (auth/, main/, errors/) as districts. Each district manages its own streets (routes) and buildings (templates). The __init__.py at the city level is the city hall — it wires everything together.

📖 Flask applicationpackages


4. How Flask works: routes, blueprints, and the app factory

The application factory pattern

The most important file is project/__init__.py. It defines a function called create_app() rather than creating the app at module-level. This is called the application factory pattern, and it solves a key problem: you often need multiple app instances (one for testing, one for development, one for production) with different configurations.

# project/__init__.py

# Extensions are created OUTSIDE the factory — they have no app yet
db = SQLAlchemy(model_class=Base)
login = LoginManager()
mail = Mail()

def create_app(config_class: Type[Config] = Config) -> Flask:
    app = Flask(__name__)
    app.config.from_object(config_class)

    # Now we BIND the extensions to this specific app instance
    db.init_app(app)
    login.init_app(app)
    mail.init_app(app)

    # Register blueprints (feature modules)
    from project.auth import bp as auth_bp
    app.register_blueprint(auth_bp, url_prefix="/auth")

    from project.main import bp as main_bp
    app.register_blueprint(main_bp)

    return app

The analogy: the extensions (db, login, mail) are like power sockets that exist before a house is built. init_app() is the act of wiring them into a specific house (the app).

📖 ApplicationFactories

Blueprints

A Blueprint is a mini-application that groups related routes together. When you register it with url_prefix="/auth", every route inside the auth blueprint automatically gets /auth prepended to its URL.

# project/auth/__init__.py
from flask import Blueprint
bp = Blueprint("auth", __name__)

# project/auth/routes.py
from project.auth import bp

@bp.route("/login", methods=["GET", "POST"])
def login():
    ...

So @bp.route("/login") inside the auth blueprint becomes the URL /auth/login in the running app. The blueprint name ("auth") is also used when generating URLs: url_for("auth.login") instead of just url_for("login").

📖 Flask Blueprints

How a request flows through the app

When a user visits /auth/login:

  1. Flask matches the URL to the login view function in the auth blueprint.
  2. On a GET request, the function creates a LoginForm and passes it to render_template("auth/login.html", form=form).
  3. On a POST request (form submission), form.validate_on_submit() checks the data. If valid, Flask-Login's login_user() saves the user to the session cookie.
  4. The user is redirected to the home page.

5. The database layer: SQLAlchemy models

All database tables are defined in project/models.py as Python classes that inherit from Base (a SQLAlchemy DeclarativeBase). Each class maps to a table, and each class attribute maps to a column.

The data model at a glance

The data hierarchy goes like this:

  • A User has many Workouts.
  • A Workout has many Exercises (instances).
  • An Exercise (instance) is based on an ExerciseDefinition (the blueprint/template for that exercise).
  • An Exercise has many Sets.
  • An ExerciseDefinition has many ProgressionLevels and belongs to many ExerciseCategories.
  • Users can follow other Users (many-to-many).
  • Users can send Messages to each other.
  • Users have Notifications.

Association tables (many-to-many)

Two tables exist purely to join other tables together:

# A user can follow many users; a user can be followed by many users
followers = db.Table(
    "followers",
    db.Column("follower_id", db.Integer, db.ForeignKey("user.id")),
    db.Column("followed_id", db.Integer, db.ForeignKey("user.id")),
)

# An exercise can belong to many categories; a category can have many exercises
exercise_categories = db.Table(
    "exercise_categories",
    db.Column("exercise_definition_id", ...),
    db.Column("category_id", ...),
)

These are called association tables (or join tables). They have no class of their own — they just hold pairs of foreign keys. SQLAlchemy handles the join automatically via the secondary= argument on relationships.

📖 Many-to-Many relationships inSQLAlchemy

The User model in detail

class User(UserMixin, Base):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(64), index=True, unique=True)
    email = db.Column(db.String(120), index=True, unique=True)
    password_hash = db.Column(db.String(512))  # never store plaintext passwords!
    confirmed = db.Column(db.Boolean, nullable=False, default=False)

    def set_password(self, password: str) -> None:
        self.password_hash = generate_password_hash(password)

    def check_password(self, password: str) -> bool:
        return check_password_hash(self.password_hash, password)

UserMixin is inherited from Flask-Login — it adds the properties Flask-Login needs (is_authenticated, is_active, get_id()). The password is never stored directly; it's run through Werkzeug's generate_password_hash(), which applies bcrypt hashing. When checking a login, check_password_hash() verifies the attempt without ever decrypting the stored hash.

The Workout / Exercise / Set hierarchy

Think of it like a gym logbook:

  • A Workout is a page in the logbook (e.g. "Monday session").
  • An Exercise (instance) is a section on that page (e.g. "Push-ups").
  • A Set is a single row in that section (e.g. "Set 1: 10 reps, full push-up").

The Workout model doubles as a template via the is_template boolean flag. This avoids creating a separate table for what is structurally identical data. The is_done flag lets users mark workouts as completed.

Soft deletion (archiving)

When a user "deletes" an exercise definition, the app doesn't actually delete the database row. Instead, it sets archived = True:

# main/routes.py
exercise.archived = True
db.session.commit()

This is called soft deletion. The reason: if a workout log references an exercise definition (via a foreign key), deleting the definition would either cause an error or leave dangling references. By archiving, old workout data remains intact and readable even after the exercise is "removed" from the library.

The followed_workouts query

This is the most complex query in the app — it builds the social feed (your workouts + workouts of people you follow):

def followed_workouts(self):
    # Workouts from people I follow
    followed = (
        db.select(Workout)
        .join(followers, followers.c.followed_id == Workout.user_id)
        .filter(followers.c.follower_id == self.id, Workout.is_template == False)
    )
    # My own workouts
    own = db.select(Workout).filter_by(user_id=self.id, is_template=False)

    # Merge both queries and sort by newest first
    union_stmt = followed.union(own).order_by(Workout.timestamp.desc())
    return db.select(Workout).from_statement(union_stmt)

The UNION SQL operator is like stacking two lists of results and removing duplicates. The join on the followers table is the bridge that connects "which users does self follow?" to "which workouts did those users log?".

📖 SQLAlchemy ORMrelationships


6. Authentication system

The auth blueprint handles everything identity-related. Here's the full lifecycle:

Register → user fills in username/email/password → password is hashed → user is saved to DB with confirmed=False → a confirmation email is sent → user is logged in immediately but restricted until confirmed.

Login → username + password submitted → password hash checked → if valid, login_user(user, remember=...) saves the user ID into a signed session cookie → Flask-Login automatically makes current_user available in all templates and routes.

Safe redirect after login — this is a security detail worth understanding:

def _get_safe_next_url() -> str | None:
    next_page = request.args.get("next", "")
    next_page = next_page.replace("\\", "").strip()
    parsed = urlparse(next_page)
    if parsed.scheme or parsed.netloc or not parsed.path.startswith("/"):
        return None  # reject external URLs like "http://evil.com"
    ...

When Flask-Login redirects an unauthenticated user to the login page, it appends ?next=/original-url. After login, the app redirects back to that URL. But without validation, an attacker could craft a link like ?next=http://evil.com to send users to a phishing site. This is called an open redirect vulnerability. The guard clause above rejects any URL with a scheme (http://) or a host (evil.com), only allowing plain internal paths like /workouts.

Profile picture uploadsave_picture() in auth/routes.py uses the secrets module to generate a random filename (avoiding collisions and predictable URLs), then uses Pillow to resize the image to 125×125 px before saving:

def save_picture(form_picture: FileStorage) -> str:
    random_hex = secrets.token_hex(8)           # e.g. "a3f2b1c0d4e5f6a7"
    _, f_ext = os.path.splitext(form_picture.filename)
    picture_fn = random_hex + f_ext             # e.g. "a3f2b1c0d4e5f6a7.jpg"

    i = Image.open(form_picture)
    i.thumbnail((125, 125))                     # shrink in-place, preserving aspect ratio
    i.save(picture_path)
    return picture_fn

Account deletion is handled carefully. Because of foreign-key constraints (a workout references a user; a set references an exercise; etc.), rows must be deleted in the right order:

  1. Unlink follower relationships (association table rows)
  2. Delete notifications and messages (no children)
  3. Delete workouts via the ORM so the cascade to Exercise → Set fires
  4. flush() to push those deletes to the DB before the next step
  5. Delete exercise definitions
  6. Delete the user itself

📖 Flask-Login documentation


7. Email confirmation and tokens

When a user registers, the app needs to verify they own the email address they provided. This is done with a signed token sent by email.

How tokens work

project/token.py uses itsdangerous.URLSafeTimedSerializer:

def generate_confirmation_token(email: str) -> str:
    serializer = URLSafeTimedSerializer(current_app.config["SECRET_KEY"])
    return serializer.dumps(email, salt=current_app.config["SECURITY_PASSWORD_SALT"])

def confirm_token(token: str, expiration: int = 3600) -> str | bool:
    serializer = URLSafeTimedSerializer(current_app.config["SECRET_KEY"])
    try:
        email = serializer.loads(token, salt=..., max_age=expiration)
    except (SignatureExpired, BadSignature):
        return False
    return str(email)

Think of the token like a sealed envelope with a timestamp glued on. dumps() seals it; loads() opens it and checks: (1) has the seal been tampered with? (2) is it within the time limit (1 hour)? If either check fails, the token is rejected.

The email address is embedded inside the token itself, so the server doesn't need to store anything extra — the token is self-contained.

📖 itsdangerousserializers

Async email sending

project/email.py sends emails in a background thread so the HTTP response isn't delayed:

def send_email(to, subject, template, sender=None):
    msg = Message(subject=subject, recipients=[to], html=template)
    app = current_app._get_current_object()  # get the real app, not a proxy
    Thread(target=send_async_email, args=(app, msg), daemon=True).start()

The key detail: current_app inside Flask is a thread-local proxy — it only works inside a request context. The new thread has no request context, so we pass the actual app object (obtained via _get_current_object()) into the thread. Inside the thread, with app.app_context(): recreates the context needed to use mail.send().

daemon=True means the thread won't prevent the process from shutting down.

📖 Flask applicationcontext


8. The check_confirmed decorator

project/decorators.py defines a custom route decorator that blocks unconfirmed users from accessing features:

def check_confirmed(func):
    @wraps(func)
    def decorated_function(*args, **kwargs):
        if current_user.confirmed is False:
            flash("Bitte bestätige dein Konto", "warning")
            return redirect(url_for("auth.unconfirmed"))
        return func(*args, **kwargs)
    return decorated_function

A decorator is a function that wraps another function. @check_confirmed on a route means: "before running this route's code, run the confirmation check first." @wraps(func) copies the original function's name and docstring onto the wrapper, which is important for Flask's internal routing logic.

Usage:

@bp.route("/add_workout", ...)
@login_required         # first: are you logged in?
@check_confirmed        # second: is your email confirmed?
def add_workout():
    ...

Decorators are applied bottom-up, so the order here means: Flask-Login checks authentication first, then check_confirmed runs, and only then the actual view function executes.

📖 Python decorators


9. Core features: workouts and exercises

Exercise library

An ExerciseDefinition is the "blueprint" for an exercise — its name, description, how sets are counted (reps or duration), what progression levels it has, and what categories it belongs to. Each user has their own exercise library, but they can also browse and copy exercises from other users (the copy_exercise route).

The counting_type field is a good example of a simple feature toggle:

# When saving sets, the form parses either reps or durations:
if exercise_def.counting_type == "duration":
    durations = request.form.getlist("duration" + str(exercise_num))
    for progression, dur in zip(progressions, durations):
        parts = dur.split(":")
        total_seconds = int(parts[0]) * 60 + int(parts[1])  # "02:30" → 150 seconds
        work_set = Set(set_order=..., exercise_id=..., duration=total_seconds)
else:
    reps = request.form.getlist("reps" + str(exercise_num))
    for progression, rep in zip(progressions, reps):
        work_set = Set(set_order=..., exercise_id=..., reps=int(rep))

Duration is stored as an integer (total seconds) for easy arithmetic. The Set.duration_formatted property converts it back to mm:ss for display:

@property
def duration_formatted(self) -> str:
    minutes = self.duration // 60
    seconds = self.duration % 60
    return f"{minutes:02d}:{seconds:02d}"

This is a computed property — it's derived from stored data rather than stored separately, which avoids data consistency issues.

Workout logging and templates

Both workouts and templates share the same Workout table and the same form-processing logic. The shared helper _save_exercises_to_workout() reads the dynamic form fields (numbered exercise1, exercise2, etc.) and builds the Exercise and Set records:

def _save_exercises_to_workout(workout, exercise_count, redirect_url):
    for exercise_num in range(1, exercise_count + 1):
        exercise_def_id = request.form.get("exercise" + str(exercise_num))
        exercise = Exercise(exercise_order=exercise_num, ...)
        db.session.add(exercise)
        db.session.flush()  # get exercise.id before adding sets
        ...
    return None  # None = success; a redirect Response = error

db.session.flush() is used after adding an Exercise to get its auto-generated id before the transaction is committed. Think of it as "send the SQL to the database but don't lock it in yet — I still need to add more related rows." The full db.session.commit() happens only at the end, once everything is consistent.

This pattern of returning None on success and a redirect on error is a sentinel return value — the caller checks if err is not None: return err.

Prefilling forms from a template_build_prefill() converts a workout's exercises/sets into a plain Python list of dicts that the Jinja2 template can render as pre-filled form fields:

def _build_prefill(workout):
    return [
        {
            "exercise_def_id": ex.exercise_definition_id,
            "sets": [
                {"progression": s.progression, "reps": s.reps, "duration": s.duration_formatted}
                for s in ex.sets.order_by(Set.set_order).all()
            ],
        }
        for ex in workout.exercises.order_by(Exercise.exercise_order).all()
    ]

Pagination

The workouts and exercises lists use db.paginate() to avoid loading thousands of rows at once:

workouts = db.paginate(
    query.order_by(Workout.timestamp.desc()),
    page=page,
    per_page=current_app.config["WORKOUTS_PER_PAGE"],
    error_out=False,
)
next_url = url_for("main.workouts", page=workouts.next_num) if workouts.has_next else None

WORKOUTS_PER_PAGE is set to 100 in config.py and can be tuned without touching view code. The next_url/prev_url pattern is a standard cursor-based navigation approach.

📖 Flask-SQLAlchemypagination


10. Social features: follow, explore, messages, notifications

Following

The follow/unfollow routes are straightforward — they delegate all the logic to the User model methods:

@bp.route("/follow/<username>")
@login_required
@check_confirmed
def follow(username):
    user = ...  # look up user by username
    current_user.follow(user)
    db.session.commit()
    flash("Du folgst {}!".format(username))
    return redirect(url_for("main.user", username=username))

User.follow() adds a row to the followers association table; User.unfollow() removes it. User.is_following() queries the table to check the relationship. This encapsulation — keeping relationship logic inside the model — is the Active Record pattern in action.

Explore page

The /explore route shows all workouts from users you don't follow (essentially a global discovery feed):

db.select(Workout)
    .filter(
        Workout.user_id != current_user.id,
        Workout.is_template == False,
    )
    .order_by(Workout.timestamp.desc())

Messaging and notifications

When a message is sent, a Notification is also created/updated to track the unread count:

msg = Message(sender_id=current_user.id, recipient_id=user.id, body=form.message.data)
db.session.add(msg)
user.add_notification("unread_message_count", user.new_messages())
db.session.commit()

add_notification() first deletes any existing notification with the same name, then creates a fresh one with the updated count stored as JSON:

def add_notification(self, name, data):
    self.notifications.filter_by(name=name).delete()  # remove stale entry
    n = Notification(name=name, user=self)
    n.payload_json = json.dumps(data)                 # store as JSON string
    db.session.add(n)
    return n

The /notifications route is a lightweight JSON endpoint that the frontend can poll to update the unread message badge without reloading the page:

@bp.route("/notifications")
def notifications():
    since = request.args.get("since", 0.0, type=float)
    notifications = current_user.notifications.filter(Notification.timestamp > since)
    return jsonify([{"name": n.name, "data": n.get_data(), "timestamp": n.timestamp} for n in notifications])

When the /messages inbox is opened, the read time is updated and the count is reset to 0:

current_user.last_message_read_time = datetime.now(timezone.utc)
current_user.add_notification("unread_message_count", 0)

new_messages() counts messages received after last_message_read_time, using a COUNT SQL aggregate:

def new_messages(self):
    result = db.session.execute(
        db.select(db.func.count())
          .select_from(Message)
          .filter(Message.recipient_id == self.id, Message.timestamp > last_read_time)
    ).scalar()
    return int(result) if result is not None else 0

11. Forms with Flask-WTF

Forms in this project use Flask-WTF, which wraps WTForms and adds CSRF token protection automatically. Each form is a Python class:

class RegistrationForm(FlaskForm):
    username = StringField("Benutzername", validators=[DataRequired()])
    email    = StringField("E-Mail",       validators=[DataRequired(), Email()])
    password = PasswordField("Passwort",   validators=[DataRequired()])
    repeat_password = PasswordField("Passwort wiederholen",
                                    validators=[DataRequired(), EqualTo("password")])
    submit = SubmitField("Registrieren")

Custom validators are defined as methods named validate_<fieldname>. Flask-WTF calls them automatically during form.validate_on_submit():

def validate_username(self, username):
    user = db.session.execute(db.select(User).filter_by(username=username.data)).scalars().first()
    if user is not None:
        raise ValidationError("Bitte einen anderen Benutzernamen wählen.")

This hits the database to check uniqueness — something you can't do with a simple regex validator.

The EditProfileForm takes the current username/email in its constructor so it can allow saving without changing those fields (otherwise the uniqueness check would fail on the user's own existing values):

class EditProfileForm(FlaskForm):
    def __init__(self, original_username, original_email, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.original_username = original_username
        self.original_email    = original_email

    def validate_username(self, username):
        if username.data != self.original_username:  # only check if it changed
            ...

📖 WTForms validators


12. Error handling

project/errors/handlers.py registers three custom error handlers using @bp.app_errorhandler():

@bp.app_errorhandler(404)
def not_found_error(error):
    return render_template("errors/404.html"), 404

@bp.app_errorhandler(500)
def internal_error(error):
    db.session.rollback()  # important: undo any partial DB changes
    return render_template("errors/500.html"), 500

The db.session.rollback() call in the 500 handler is critical — if something went wrong mid-request that caused a 500, the database session may be in a broken state. Rolling it back ensures the next request starts with a clean slate.

@bp.app_errorhandler() (not @bp.errorhandler()) registers handlers at the application level, meaning they catch errors from all blueprints, not just the errors blueprint.

📖 Flask errorhandlers


13. Configuration and environment variables

All configuration lives in project/config.py. Secrets and environment-specific values are never hard-coded — they're read from environment variables with safe fallbacks:

class Config(object):
    SECRET_KEY = os.environ.get("SECRET_KEY") or "dev-fallback-key"
    SQLALCHEMY_DATABASE_URI = (
        os.environ.get("DATABASE_URL")
        or "sqlite:///" + os.path.join(basedir, "app.db")
    )
    MAIL_SERVER = os.environ.get("APP_MAIL_SERVER") or os.environ.get("MAIL_SERVER")

In local development, a .env file is loaded by python-dotenv. In production (cPanel), the hosting environment injects the variables directly — config.py skips loading .env if SECRET_KEY is already set in the environment.

This is the twelve-factor app configuration principle: separate code from config; config comes from the environment.

The database URL falls back to SQLite for local development (no server needed), but in production it can point to MySQL/PostgreSQL via DATABASE_URL.

📖 The Twelve-Factor App: Config


14. Logging

Production logging is configured in project/__init__.py's _configure_logging() function, which only runs when app.debug and app.testing are both False:

Email alerts: if a MAIL_SERVER is configured, a SMTPHandler sends an email to the admins listed in ADMINS whenever an ERROR-level log message is emitted. This means you get notified of crashes automatically.

File logging: a RotatingFileHandler writes to logs/calisthenics-progression.log. The file rotates at 10 KB, keeping up to 10 old files — so logs don't grow forever and eat disk space.

Stdout logging: if LOG_TO_STDOUT=1 is set (common on platforms like Heroku or Railway where you can't write files), logs go to standard output instead of a file.


15. Deployment: passenger_wsgi and production setup

There are two entry points:

calisthenics_progression.py — used locally with flask run:

from project import create_app
app = create_app()

passenger_wsgi.py — used on shared hosting platforms (like cPanel with Phusion Passenger). Passenger looks for an application variable by that exact name:

from project import create_app
application = create_app()

The only difference is the variable name (application vs app). Passenger acts as the bridge between the web server (Apache/nginx) and the Python app.

For the database, Flask-Migrate (built on Alembic) manages schema changes. When you change a model, you run:

flask db migrate -m "describe the change"
flask db upgrade

This generates a migration file in migrations/versions/ that can be applied to any environment — development, staging, production — keeping schemas in sync.

📖 Flask-Migrate 📖 Phusion PassengerWSGI


16. How to replicate this project from scratch

Here's the recommended order if you were building this project yourself, from the ground up:

  1. Set up the project skeleton. Create a virtual environment (python -m venv venv), install Flask, create project/__init__.py with a minimal create_app(), and verify flask run works.

  2. Add configuration. Create project/config.py with a Config class. Read SECRET_KEY and DATABASE_URL from environment variables. Create a .env file for local development.

  3. Set up the database. Install Flask-SQLAlchemy and Flask-Migrate. Define the User model in models.py. Run flask db init, flask db migrate, and flask db upgrade to create the schema.

  4. Add Flask-Login. Install Flask-Login. Add the LoginManager and @login.user_loader function. Implement set_password() and check_password() on the User model.

  5. Build the auth blueprint. Create project/auth/__init__.py, forms.py, and routes.py. Implement register, login, and logout routes. Add LoginForm and RegistrationForm with WTForms validators.

  6. Add email confirmation. Install Flask-Mail and itsdangerous. Write token.py (generate/confirm) and email.py (async send). Add the confirmed field to User. Implement the confirm_email route and the check_confirmed decorator.

  7. Build the exercise library. Add ExerciseDefinition, ProgressionLevel, and ExerciseCategory models. Create the add_exercise, all_exercises, update_exercise, and delete_exercise (soft-delete) routes in the main blueprint.

  8. Build workout logging. Add the Workout, Exercise, and Set models. Implement add_workout and edit_workout routes. Write the _save_exercises_to_workout() shared helper.

  9. Add workout templates. Reuse the Workout model with is_template=True. Add add_template, edit_template, delete_template, and use_template routes. Add the prefill logic (_build_prefill()).

  10. Add social features. Add the followers association table and the follow/unfollow/is_following methods on User. Implement the explore, user, follow, and unfollow routes.

  11. Add messaging and notifications. Add Message and Notification models. Implement send_message, messages, and notifications routes. Add the new_messages() and add_notification() methods to User.

  12. Add profile pictures. Install Pillow. Implement save_picture() in auth/routes.py. Add the image file field to User and the EditProfileForm.

  13. Add error handlers. Create the errors blueprint with custom 404, 403, and 500 handlers.

  14. Set up logging. Implement _configure_logging() in create_app(). Configure rotating file logs for development and SMTP email alerts for production errors.

  15. Write tests. Set up tests/conftest.py with a test app factory that uses an in-memory SQLite database. Write unit tests for models and helpers, integration tests for routes.

  16. Configure deployment. Create passenger_wsgi.py for shared hosting. Set production environment variables (real SECRET_KEY, database URL, mail server). Run flask db upgrade on the production server.


Generated March 2026 — covers commit state as of the documentation date.