Tutorial-like documentation of Calisthenics Progression App
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
- What is this project?
- Tech stack at a glance
- Project structure
- How Flask works: routes, blueprints, and the app factory
- The database layer: SQLAlchemy models
- Authentication system
- Email confirmation and tokens
- The check_confirmed decorator
- Core features: workouts and exercises
- Social features: follow, explore, messages, notifications
- Forms with Flask-WTF
- Error handling
- Configuration and environment variables
- Logging
- Deployment: passenger_wsgi and production setup
- 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
| Technology | What it does in this project | Learn more |
|---|---|---|
| Flask | The web framework — handles HTTP requests and routes them to Python functions | 📖 Flask docs |
| SQLAlchemy / Flask-SQLAlchemy | Maps Python classes to database tables (ORM) | 📖 SQLAlchemy docs |
| Flask-Migrate / Alembic | Generates and applies database schema migrations | 📖 Flask-Migrate |
| Flask-Login | Manages user sessions (who is logged in?) | 📖 Flask-Login docs |
| Flask-WTF / WTForms | Renders and validates HTML forms, with CSRF protection built in | 📖 Flask-WTF docs |
| Flask-Mail | Sends emails (account confirmation, password reset) | 📖 Flask-Mail docs |
| itsdangerous | Creates and verifies cryptographically signed tokens for email confirmation | 📖 itsdangerous docs |
| Pillow | Resizes profile pictures before saving them to disk | 📖 Pillow docs |
| Bootstrap-Flask / Bootstrap 5 | Provides ready-made CSS components (buttons, cards, navbars) | 📖 Bootstrap-Flask docs |
| Flask-Moment | Formats timestamps in the user's local timezone via moment.js | 📖 Flask-Moment |
| python-dotenv | Loads .env files into environment variables for local development | 📖 python-dotenv |
| Gunicorn | Production-grade WSGI server (used alongside Passenger on shared hosting) | 📖 Gunicorn docs |
| pytest / pytest-flask | Automated 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.
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).
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").
How a request flows through the app
When a user visits /auth/login:
- Flask matches the URL to the
loginview function in theauthblueprint. - On a
GETrequest, the function creates aLoginFormand passes it torender_template("auth/login.html", form=form). - On a
POSTrequest (form submission),form.validate_on_submit()checks the data. If valid, Flask-Login'slogin_user()saves the user to the session cookie. - 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?".
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 upload — save_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:
- Unlink follower relationships (association table rows)
- Delete notifications and messages (no children)
- Delete workouts via the ORM so the cascade to
Exercise → Setfires flush()to push those deletes to the DB before the next step- Delete exercise definitions
- Delete the user itself
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.
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.
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.
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.
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
...
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.
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:
-
Set up the project skeleton. Create a virtual environment (
python -m venv venv), install Flask, createproject/__init__.pywith a minimalcreate_app(), and verifyflask runworks. -
Add configuration. Create
project/config.pywith aConfigclass. ReadSECRET_KEYandDATABASE_URLfrom environment variables. Create a.envfile for local development. -
Set up the database. Install Flask-SQLAlchemy and Flask-Migrate. Define the
Usermodel inmodels.py. Runflask db init,flask db migrate, andflask db upgradeto create the schema. -
Add Flask-Login. Install Flask-Login. Add the
LoginManagerand@login.user_loaderfunction. Implementset_password()andcheck_password()on theUsermodel. -
Build the auth blueprint. Create
project/auth/__init__.py,forms.py, androutes.py. Implementregister,login, andlogoutroutes. AddLoginFormandRegistrationFormwith WTForms validators. -
Add email confirmation. Install Flask-Mail and itsdangerous. Write
token.py(generate/confirm) andemail.py(async send). Add theconfirmedfield toUser. Implement theconfirm_emailroute and thecheck_confirmeddecorator. -
Build the exercise library. Add
ExerciseDefinition,ProgressionLevel, andExerciseCategorymodels. Create theadd_exercise,all_exercises,update_exercise, anddelete_exercise(soft-delete) routes in the main blueprint. -
Build workout logging. Add the
Workout,Exercise, andSetmodels. Implementadd_workoutandedit_workoutroutes. Write the_save_exercises_to_workout()shared helper. -
Add workout templates. Reuse the
Workoutmodel withis_template=True. Addadd_template,edit_template,delete_template, anduse_templateroutes. Add the prefill logic (_build_prefill()). -
Add social features. Add the
followersassociation table and thefollow/unfollow/is_followingmethods onUser. Implement theexplore,user,follow, andunfollowroutes. -
Add messaging and notifications. Add
MessageandNotificationmodels. Implementsend_message,messages, andnotificationsroutes. Add thenew_messages()andadd_notification()methods toUser. -
Add profile pictures. Install Pillow. Implement
save_picture()inauth/routes.py. Add the image file field toUserand theEditProfileForm. -
Add error handlers. Create the
errorsblueprint with custom 404, 403, and 500 handlers. -
Set up logging. Implement
_configure_logging()increate_app(). Configure rotating file logs for development and SMTP email alerts for production errors. -
Write tests. Set up
tests/conftest.pywith a test app factory that uses an in-memory SQLite database. Write unit tests for models and helpers, integration tests for routes. -
Configure deployment. Create
passenger_wsgi.pyfor shared hosting. Set production environment variables (realSECRET_KEY, database URL, mail server). Runflask db upgradeon the production server.
Generated March 2026 — covers commit state as of the documentation date.