From 51289ef456eacc075ea395cb1d93ea4d6cdbf9b9 Mon Sep 17 00:00:00 2001 From: Patsy Date: Mon, 20 Jul 2026 20:14:41 +0200 Subject: [PATCH] Phase 1: skeleton --- .env | 0 .gitignore | 0 .python-version | 1 + CLAUDE.md | 531 +++++++++++++++++++++++++++++++++++ app/__init__.py | 0 app/auth/__init__.py | 0 app/auth/forms.py | 0 app/auth/routes.py | 0 app/main/__init__.py | 0 app/main/routes.py | 0 app/models.py | 0 config.py | 0 requirements.txt | 0 run.py | 0 templates/auth/login.html | 0 templates/auth/register.html | 0 templates/base.html | 0 templates/index.html | 0 18 files changed, 532 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 CLAUDE.md create mode 100644 app/__init__.py create mode 100644 app/auth/__init__.py create mode 100644 app/auth/forms.py create mode 100644 app/auth/routes.py create mode 100644 app/main/__init__.py create mode 100644 app/main/routes.py create mode 100644 app/models.py create mode 100644 config.py create mode 100644 requirements.txt create mode 100644 run.py create mode 100644 templates/auth/login.html create mode 100644 templates/auth/register.html create mode 100644 templates/base.html create mode 100644 templates/index.html diff --git a/.env b/.env new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..5ceb386 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +venv diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0e0064c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,531 @@ +# CLAUDE.md + +This file is project memory for Claude Code. Drop it in the repo root, `cd` into +the project, run `claude`, and Claude Code will read it automatically. Work +through the phases below **in order**, one at a time — commit after each phase. + +## Project Overview + +A multi-feature Flask app built with the **app-factory + blueprint** pattern. +Every feature (auth, main, and anything you add later) lives in its own +blueprint folder, so new features drop in without touching existing code. +Ships with a full authentication flow (register / login / logout) and a +public index page. + +## Tech Stack + +- Python 3.11+ (pyenv-managed) +- Flask 3.x — app factory pattern +- Flask-SQLAlchemy — ORM, SQLite by default +- Flask-Login — session-based auth +- Flask-WTF + WTForms — forms, CSRF protection +- Flask-Migrate — Alembic migrations +- python-dotenv — env config +- Jinja2 templates, Bootstrap 5 via CDN (swap for your own CI later) + +## Target Project Structure + +``` +myapp/ +├── app/ +│ ├── __init__.py # app factory +│ ├── models.py # shared models (User) +│ ├── auth/ +│ │ ├── __init__.py +│ │ ├── forms.py +│ │ └── routes.py +│ └── main/ +│ ├── __init__.py +│ └── routes.py +├── templates/ +│ ├── base.html +│ ├── index.html +│ └── auth/ +│ ├── login.html +│ └── register.html +├── static/ +│ └── css/ +├── config.py +├── run.py +├── requirements.txt +├── .env +└── .gitignore +``` + +## Conventions for Claude Code + +- One blueprint per feature, under `app//`. +- Each blueprint: `__init__.py`, `routes.py`, and `forms.py` / `models.py` only if that feature needs them. +- Templates mirror blueprint structure: feature-specific templates go in `templates//`. +- Every template extends `templates/base.html`. +- No secrets hardcoded — config comes from environment variables via `.env`. +- Any model change → `flask db migrate` then `flask db upgrade`, never edit the DB by hand. +- When asked to "implement Phase N", only touch the files listed for that phase. + +--- + +## Current Session Status (resume point) + +Phase 0 is mostly done: +- `pyenv install 3.13 -s`, `pyenv local 3.13`, `pyenv virtualenv 3.13 venv`, and `git init` all completed. +- `.python-version` is set to `venv`. +- **Known issue:** inside the Claude Code sandboxed Bash tool, `$PATH` did not include `~/.pyenv/shims`, so the venv never actually activated in that tool — `python -V` kept resolving to the system Python. +- **Resolution in progress:** the user is activating the virtualenv manually in their own terminal (`pyenv activate venv` or equivalent), then quitting and restarting `claude` from inside that activated shell so the tool inherits the correct environment. +- **On resume:** verify with `which python` / `python -V` that the venv (Python 3.13, path containing `.pyenv/versions/3.13.14/envs/venv`) is active before running any `pip install` or `flask` commands. Once confirmed, proceed to Phase 1 (Skeleton). + +--- + +## Build Tutorial + +### Phase 0 — Environment + +```bash +pyenv install 3.13 -s +pyenv local 3.13 +pyenv virtualenv 3.13 venv +pyenv activate venv +git init +``` + +### Phase 1 — Skeleton + +```bash +mkdir -p app/auth app/main templates/auth static/css +touch app/__init__.py app/models.py \ + app/auth/__init__.py app/auth/routes.py app/auth/forms.py \ + app/main/__init__.py app/main/routes.py \ + templates/base.html templates/index.html \ + templates/auth/login.html templates/auth/register.html \ + config.py run.py requirements.txt .env .gitignore +``` + +### Phase 2 — Dependencies + +`requirements.txt`: + +``` +Flask==3.0.3 +Flask-SQLAlchemy==3.1.1 +Flask-Login==0.6.3 +Flask-WTF==1.2.1 +Flask-Migrate==4.0.7 +python-dotenv==1.0.1 +email-validator==2.1.1 +``` + +```bash +pip install -r requirements.txt +``` + +`.env`: + +``` +FLASK_APP=run.py +FLASK_DEBUG=1 +SECRET_KEY=change-me-to-a-random-string +DATABASE_URL=sqlite:///app.db +``` + +`.gitignore`: + +``` +.venv/ +__pycache__/ +*.pyc +instance/ +.env +app.db +``` + +**Prompt for Claude Code:** +``` +claude "Implement Phase 2 exactly as specified in CLAUDE.md: create requirements.txt, .env, and .gitignore with the given content." +``` + +### Phase 3 — Config & App Factory + +`config.py`: + +```python +import os +from dotenv import load_dotenv + +load_dotenv() + +class Config: + SECRET_KEY = os.environ.get("SECRET_KEY", "dev-key-not-safe") + SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "sqlite:///app.db") + SQLALCHEMY_TRACK_MODIFICATIONS = False +``` + +`app/__init__.py`: + +```python +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from flask_login import LoginManager +from flask_migrate import Migrate +from config import Config + +db = SQLAlchemy() +login_manager = LoginManager() +migrate = Migrate() + +def create_app(config_class=Config): + app = Flask(__name__) + app.config.from_object(config_class) + + db.init_app(app) + login_manager.init_app(app) + migrate.init_app(app, db) + + login_manager.login_view = "auth.login" + login_manager.login_message_category = "info" + + from app.auth.routes import auth_bp + from app.main.routes import main_bp + app.register_blueprint(auth_bp, url_prefix="/auth") + app.register_blueprint(main_bp) + + from app import models # noqa: F401 — registers models with SQLAlchemy + + return app +``` + +`run.py`: + +```python +from app import create_app + +app = create_app() + +if __name__ == "__main__": + app.run() +``` + +### Phase 4 — User Model + +`app/models.py`: + +```python +from datetime import datetime +from flask_login import UserMixin +from werkzeug.security import generate_password_hash, check_password_hash +from app import db, login_manager + +class User(UserMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(64), unique=True, nullable=False) + email = db.Column(db.String(120), unique=True, nullable=False) + password_hash = db.Column(db.String(255), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + def set_password(self, password): + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + return check_password_hash(self.password_hash, password) + + def __repr__(self): + return f"" + +@login_manager.user_loader +def load_user(user_id): + return User.query.get(int(user_id)) +``` + +### Phase 5 — Auth Blueprint + +`app/auth/forms.py`: + +```python +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, BooleanField, SubmitField +from wtforms.validators import DataRequired, Email, EqualTo, Length, ValidationError +from app.models import User + +class RegistrationForm(FlaskForm): + username = StringField("Username", validators=[DataRequired(), Length(min=3, max=64)]) + email = StringField("Email", validators=[DataRequired(), Email()]) + password = PasswordField("Password", validators=[DataRequired(), Length(min=8)]) + confirm_password = PasswordField( + "Confirm Password", validators=[DataRequired(), EqualTo("password")] + ) + submit = SubmitField("Create Account") + + def validate_username(self, username): + if User.query.filter_by(username=username.data).first(): + raise ValidationError("That username is already taken.") + + def validate_email(self, email): + if User.query.filter_by(email=email.data).first(): + raise ValidationError("That email is already registered.") + +class LoginForm(FlaskForm): + email = StringField("Email", validators=[DataRequired(), Email()]) + password = PasswordField("Password", validators=[DataRequired()]) + remember_me = BooleanField("Remember Me") + submit = SubmitField("Log In") +``` + +`app/auth/routes.py`: + +```python +from urllib.parse import urlparse +from flask import Blueprint, render_template, redirect, url_for, flash, request +from flask_login import login_user, logout_user, login_required, current_user +from app import db +from app.models import User +from app.auth.forms import RegistrationForm, LoginForm + +auth_bp = Blueprint("auth", __name__, template_folder="../../templates/auth") + +@auth_bp.route("/register", methods=["GET", "POST"]) +def register(): + if current_user.is_authenticated: + return redirect(url_for("main.index")) + form = RegistrationForm() + if form.validate_on_submit(): + user = User(username=form.username.data, email=form.email.data) + user.set_password(form.password.data) + db.session.add(user) + db.session.commit() + flash("Account created. You can log in now.", "success") + return redirect(url_for("auth.login")) + return render_template("auth/register.html", form=form) + +@auth_bp.route("/login", methods=["GET", "POST"]) +def login(): + if current_user.is_authenticated: + return redirect(url_for("main.index")) + form = LoginForm() + if form.validate_on_submit(): + user = User.query.filter_by(email=form.email.data).first() + if user is None or not user.check_password(form.password.data): + flash("Invalid email or password.", "danger") + return redirect(url_for("auth.login")) + login_user(user, remember=form.remember_me.data) + next_page = request.args.get("next") + if not next_page or urlparse(next_page).netloc != "": + next_page = url_for("main.index") + return redirect(next_page) + return render_template("auth/login.html", form=form) + +@auth_bp.route("/logout") +@login_required +def logout(): + logout_user() + flash("You have been logged out.", "info") + return redirect(url_for("main.index")) +``` + +### Phase 6 — Main Blueprint + +`app/main/routes.py`: + +```python +from flask import Blueprint, render_template + +main_bp = Blueprint("main", __name__) + +@main_bp.route("/") +def index(): + return render_template("index.html") +``` + +### Phase 7 — Templates + +`templates/base.html`: + +```html + + + + + + {% block title %}MyApp{% endblock %} + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + +``` + +`templates/index.html`: + +```html +{% extends "base.html" %} +{% block title %}Home{% endblock %} +{% block content %} +

Welcome{% if current_user.is_authenticated %}, {{ current_user.username }}{% endif %}!

+ {% if not current_user.is_authenticated %} +

Please log in or + create an account.

+ {% endif %} +{% endblock %} +``` + +`templates/auth/login.html`: + +```html +{% extends "base.html" %} +{% block title %}Log In{% endblock %} +{% block content %} +

Log In

+
+ {{ form.hidden_tag() }} +
+ {{ form.email.label(class="form-label") }} + {{ form.email(class="form-control") }} + {% for error in form.email.errors %}
{{ error }}
{% endfor %} +
+
+ {{ form.password.label(class="form-label") }} + {{ form.password(class="form-control") }} + {% for error in form.password.errors %}
{{ error }}
{% endfor %} +
+
+ {{ form.remember_me(class="form-check-input") }} + {{ form.remember_me.label(class="form-check-label") }} +
+ {{ form.submit(class="btn btn-primary") }} +
+{% endblock %} +``` + +`templates/auth/register.html`: + +```html +{% extends "base.html" %} +{% block title %}Register{% endblock %} +{% block content %} +

Create Account

+
+ {{ form.hidden_tag() }} +
+ {{ form.username.label(class="form-label") }} + {{ form.username(class="form-control") }} + {% for error in form.username.errors %}
{{ error }}
{% endfor %} +
+
+ {{ form.email.label(class="form-label") }} + {{ form.email(class="form-control") }} + {% for error in form.email.errors %}
{{ error }}
{% endfor %} +
+
+ {{ form.password.label(class="form-label") }} + {{ form.password(class="form-control") }} + {% for error in form.password.errors %}
{{ error }}
{% endfor %} +
+
+ {{ form.confirm_password.label(class="form-label") }} + {{ form.confirm_password(class="form-control") }} + {% for error in form.confirm_password.errors %}
{{ error }}
{% endfor %} +
+ {{ form.submit(class="btn btn-primary") }} +
+{% endblock %} +``` + +### Phase 8 — Wire Up & Run + +```bash +flask db init +flask db migrate -m "initial: User model" +flask db upgrade +flask run +``` + +Visit `http://127.0.0.1:5000/` → should show the index page. +Visit `/auth/register` → create an account → redirected to `/auth/login`. +Log in → index page now greets you by username and shows "Log out". + +### Phase 9 — Adding a New Feature (this is the "multiple feature" part) + +Every additional feature follows the same pattern as `auth`/`main`. Example — +adding a `dashboard` feature that only logged-in users can see: + +```bash +mkdir -p app/dashboard templates/dashboard +touch app/dashboard/__init__.py app/dashboard/routes.py templates/dashboard/index.html +``` + +`app/dashboard/routes.py`: + +```python +from flask import Blueprint, render_template +from flask_login import login_required + +dashboard_bp = Blueprint("dashboard", __name__, template_folder="../../templates/dashboard") + +@dashboard_bp.route("/dashboard") +@login_required +def index(): + return render_template("dashboard/index.html") +``` + +Register it in `app/__init__.py` alongside the other blueprints: + +```python +from app.dashboard.routes import dashboard_bp +app.register_blueprint(dashboard_bp) +``` + +Repeat this pattern for every new feature — that's the whole point of the +blueprint layout: no existing file changes when a feature is added, only new +ones. + +--- + +## Using This File With the Claude Code CLI + +1. Place `CLAUDE.md` at the project root before running `claude`. +2. Work phase by phase, e.g.: + ``` + claude "Implement Phase 3 exactly as specified in CLAUDE.md" + ``` +3. Review the diff Claude Code produces, run/test it, `git commit`. +4. Move to the next phase. Don't skip ahead — later phases assume earlier files exist exactly as specified. + +## Testing Checklist + +- [ ] Can register a new user +- [ ] Duplicate email/username is rejected with a clear error +- [ ] Password is hashed, never stored in plain text +- [ ] Can log in and log out +- [ ] Index page differs for logged-in vs. anonymous users +- [ ] CSRF token present in both forms (`form.hidden_tag()`) +- [ ] `flask db upgrade` runs clean on a fresh clone + +## Common Pitfalls + +- Forgetting to set `SECRET_KEY` / `FLASK_APP` in `.env`. +- Circular imports between `app/__init__.py` and blueprints — always import blueprints *inside* `create_app()`, not at module top level. +- Forgetting `login_manager.login_view = "auth.login"` → `@login_required` redirects to a 404 instead of the login page. +- Running the app before `flask db upgrade` → "no such table: user". diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/forms.py b/app/auth/forms.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/routes.py b/app/auth/routes.py new file mode 100644 index 0000000..e69de29 diff --git a/app/main/__init__.py b/app/main/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/main/routes.py b/app/main/routes.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..e69de29 diff --git a/config.py b/config.py new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/run.py b/run.py new file mode 100644 index 0000000..e69de29 diff --git a/templates/auth/login.html b/templates/auth/login.html new file mode 100644 index 0000000..e69de29 diff --git a/templates/auth/register.html b/templates/auth/register.html new file mode 100644 index 0000000..e69de29 diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..e69de29 diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..e69de29