test_template_flask/CLAUDE.md
2026-07-20 20:14:41 +02:00

16 KiB

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/<feature>/.
  • 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/<feature>/.
  • 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

pyenv install 3.13 -s
pyenv local 3.13
pyenv virtualenv 3.13 venv
pyenv activate venv
git init

Phase 1 — Skeleton

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
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:

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:

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:

from app import create_app

app = create_app()

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

Phase 4 — User Model

app/models.py:

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"<User {self.username}>"

@login_manager.user_loader
def load_user(user_id):
    return User.query.get(int(user_id))

Phase 5 — Auth Blueprint

app/auth/forms.py:

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:

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:

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:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{% block title %}MyApp{% endblock %}</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
</head>
<body>
  <nav class="navbar navbar-expand-lg navbar-light bg-light mb-4">
    <div class="container">
      <a class="navbar-brand" href="{{ url_for('main.index') }}">MyApp</a>
      <div class="d-flex gap-2">
        {% if current_user.is_authenticated %}
          <span class="navbar-text">Hi, {{ current_user.username }}</span>
          <a class="btn btn-outline-secondary btn-sm" href="{{ url_for('auth.logout') }}">Log out</a>
        {% else %}
          <a class="btn btn-outline-primary btn-sm" href="{{ url_for('auth.login') }}">Log in</a>
          <a class="btn btn-primary btn-sm" href="{{ url_for('auth.register') }}">Register</a>
        {% endif %}
      </div>
    </div>
  </nav>

  <div class="container">
    {% with messages = get_flashed_messages(with_categories=true) %}
      {% if messages %}
        {% for category, message in messages %}
          <div class="alert alert-{{ category }}">{{ message }}</div>
        {% endfor %}
      {% endif %}
    {% endwith %}

    {% block content %}{% endblock %}
  </div>
</body>
</html>

templates/index.html:

{% extends "base.html" %}
{% block title %}Home{% endblock %}
{% block content %}
  <h1>Welcome{% if current_user.is_authenticated %}, {{ current_user.username }}{% endif %}!</h1>
  {% if not current_user.is_authenticated %}
    <p>Please <a href="{{ url_for('auth.login') }}">log in</a> or
       <a href="{{ url_for('auth.register') }}">create an account</a>.</p>
  {% endif %}
{% endblock %}

templates/auth/login.html:

{% extends "base.html" %}
{% block title %}Log In{% endblock %}
{% block content %}
  <h2>Log In</h2>
  <form method="POST" novalidate class="col-md-6">
    {{ form.hidden_tag() }}
    <div class="mb-3">
      {{ form.email.label(class="form-label") }}
      {{ form.email(class="form-control") }}
      {% for error in form.email.errors %}<div class="text-danger small">{{ error }}</div>{% endfor %}
    </div>
    <div class="mb-3">
      {{ form.password.label(class="form-label") }}
      {{ form.password(class="form-control") }}
      {% for error in form.password.errors %}<div class="text-danger small">{{ error }}</div>{% endfor %}
    </div>
    <div class="form-check mb-3">
      {{ form.remember_me(class="form-check-input") }}
      {{ form.remember_me.label(class="form-check-label") }}
    </div>
    {{ form.submit(class="btn btn-primary") }}
  </form>
{% endblock %}

templates/auth/register.html:

{% extends "base.html" %}
{% block title %}Register{% endblock %}
{% block content %}
  <h2>Create Account</h2>
  <form method="POST" novalidate class="col-md-6">
    {{ form.hidden_tag() }}
    <div class="mb-3">
      {{ form.username.label(class="form-label") }}
      {{ form.username(class="form-control") }}
      {% for error in form.username.errors %}<div class="text-danger small">{{ error }}</div>{% endfor %}
    </div>
    <div class="mb-3">
      {{ form.email.label(class="form-label") }}
      {{ form.email(class="form-control") }}
      {% for error in form.email.errors %}<div class="text-danger small">{{ error }}</div>{% endfor %}
    </div>
    <div class="mb-3">
      {{ form.password.label(class="form-label") }}
      {{ form.password(class="form-control") }}
      {% for error in form.password.errors %}<div class="text-danger small">{{ error }}</div>{% endfor %}
    </div>
    <div class="mb-3">
      {{ form.confirm_password.label(class="form-label") }}
      {{ form.confirm_password(class="form-control") }}
      {% for error in form.confirm_password.errors %}<div class="text-danger small">{{ error }}</div>{% endfor %}
    </div>
    {{ form.submit(class="btn btn-primary") }}
  </form>
{% endblock %}

Phase 8 — Wire Up & Run

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:

mkdir -p app/dashboard templates/dashboard
touch app/dashboard/__init__.py app/dashboard/routes.py templates/dashboard/index.html

app/dashboard/routes.py:

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:

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".