Phase 1: skeleton
This commit is contained in:
commit
51289ef456
18 changed files with 532 additions and 0 deletions
0
.env
Normal file
0
.env
Normal file
0
.gitignore
vendored
Normal file
0
.gitignore
vendored
Normal file
1
.python-version
Normal file
1
.python-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
venv
|
||||
531
CLAUDE.md
Normal file
531
CLAUDE.md
Normal file
|
|
@ -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/<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
|
||||
|
||||
```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"<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`:
|
||||
|
||||
```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
|
||||
<!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`:
|
||||
|
||||
```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`:
|
||||
|
||||
```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`:
|
||||
|
||||
```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
|
||||
|
||||
```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".
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/auth/__init__.py
Normal file
0
app/auth/__init__.py
Normal file
0
app/auth/forms.py
Normal file
0
app/auth/forms.py
Normal file
0
app/auth/routes.py
Normal file
0
app/auth/routes.py
Normal file
0
app/main/__init__.py
Normal file
0
app/main/__init__.py
Normal file
0
app/main/routes.py
Normal file
0
app/main/routes.py
Normal file
0
app/models.py
Normal file
0
app/models.py
Normal file
0
config.py
Normal file
0
config.py
Normal file
0
requirements.txt
Normal file
0
requirements.txt
Normal file
0
run.py
Normal file
0
run.py
Normal file
0
templates/auth/login.html
Normal file
0
templates/auth/login.html
Normal file
0
templates/auth/register.html
Normal file
0
templates/auth/register.html
Normal file
0
templates/base.html
Normal file
0
templates/base.html
Normal file
0
templates/index.html
Normal file
0
templates/index.html
Normal file
Loading…
Add table
Reference in a new issue