chore: initial project setup

Initialize Sneaky Klaus project with:
- uv package management and pyproject.toml
- Flask application structure (app.py, config.py)
- SQLAlchemy models for Admin and Exchange
- Alembic database migrations
- Pre-commit hooks configuration
- Development tooling (pytest, ruff, mypy)

Initial structure follows design documents in docs/:
- src/app.py: Application factory with Flask extensions
- src/config.py: Environment-based configuration
- src/models/: Admin and Exchange models
- migrations/: Alembic migration setup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-22 11:28:15 -07:00
commit b077112aba
32 changed files with 10931 additions and 0 deletions

0
src/__init__.py Normal file
View File

143
src/app.py Normal file
View File

@@ -0,0 +1,143 @@
"""Flask application factory for Sneaky Klaus.
This module provides the application factory function that creates
and configures the Flask application instance.
"""
from pathlib import Path
from flask import Flask
from flask_bcrypt import Bcrypt
from flask_session import Session
from flask_sqlalchemy import SQLAlchemy
from flask_wtf.csrf import CSRFProtect
# Initialize Flask extensions (without app instance)
db = SQLAlchemy()
bcrypt = Bcrypt()
csrf = CSRFProtect()
session = Session()
def create_app(config_name: str | None = None) -> Flask:
"""Create and configure the Flask application.
Args:
config_name: Configuration environment name (development, production, testing).
If None, uses FLASK_ENV environment variable.
Returns:
Configured Flask application instance.
"""
# Create Flask app instance
app = Flask(__name__)
# Load configuration
from src.config import ProductionConfig, get_config
config_class = get_config(config_name)
app.config.from_object(config_class)
# Validate production config if needed
if config_name == "production":
ProductionConfig.validate()
# Ensure data directory exists
data_dir = Path(app.config["DATA_DIR"])
data_dir.mkdir(parents=True, exist_ok=True)
# Initialize extensions
db.init_app(app)
bcrypt.init_app(app)
csrf.init_app(app)
# Initialize session with SQLAlchemy backend
app.config["SESSION_SQLALCHEMY"] = db
session.init_app(app)
# Register blueprints (will be created later)
# from src.routes import admin, auth, participant, public
# app.register_blueprint(admin.bp)
# app.register_blueprint(auth.bp)
# app.register_blueprint(participant.bp)
# app.register_blueprint(public.bp)
# Register error handlers
register_error_handlers(app)
# First-run setup check
register_setup_check(app)
# Import models to ensure they're registered with SQLAlchemy
with app.app_context():
from src.models import Admin, Exchange # noqa: F401
db.create_all()
return app
def register_error_handlers(app: Flask) -> None:
"""Register custom error handlers.
Args:
app: Flask application instance.
"""
from flask import render_template
@app.errorhandler(404)
def not_found_error(_error):
"""Handle 404 Not Found errors."""
return render_template("errors/404.html"), 404
@app.errorhandler(403)
def forbidden_error(_error):
"""Handle 403 Forbidden errors."""
return render_template("errors/403.html"), 403
@app.errorhandler(500)
def internal_error(error):
"""Handle 500 Internal Server Error."""
db.session.rollback() # Rollback any failed transactions
app.logger.error(f"Internal server error: {error}")
return render_template("errors/500.html"), 500
@app.errorhandler(429)
def rate_limit_error(_error):
"""Handle 429 Too Many Requests errors."""
from flask import flash, redirect, request
flash("Too many attempts. Please try again later.", "error")
return redirect(request.referrer or "/"), 429
def register_setup_check(app: Flask) -> None:
"""Register before_request handler for first-run setup detection.
Args:
app: Flask application instance.
"""
from flask import redirect, request, url_for
@app.before_request
def check_setup_required():
"""Redirect to setup page if no admin exists.
This runs before every request to ensure the application
has been set up with an admin account.
"""
# Skip check for certain endpoints
if request.endpoint in ["setup", "static", "health"]:
return
# Check if we've already determined setup is required
if not hasattr(app, "_setup_checked"):
from src.models.admin import Admin
admin_count = db.session.query(Admin).count()
app.config["REQUIRES_SETUP"] = admin_count == 0
app._setup_checked = True
# Redirect to setup if needed
if app.config.get("REQUIRES_SETUP") and request.endpoint != "setup":
return redirect(url_for("setup"))

120
src/config.py Normal file
View File

@@ -0,0 +1,120 @@
"""Configuration management for Sneaky Klaus application.
This module defines configuration classes for different environments
(development, production, testing) with environment variable support.
"""
import os
from datetime import timedelta
from pathlib import Path
class Config:
"""Base configuration class with common settings."""
# Base paths
BASE_DIR = Path(__file__).parent.parent
DATA_DIR = BASE_DIR / "data"
# Security
SECRET_KEY = os.environ.get("SECRET_KEY") or "dev-secret-key-change-in-production"
# Database
SQLALCHEMY_DATABASE_URI = os.environ.get(
"DATABASE_URL", f"sqlite:///{DATA_DIR / 'sneaky-klaus.db'}"
)
SQLALCHEMY_TRACK_MODIFICATIONS = False
# Session management
SESSION_TYPE = "sqlalchemy"
SESSION_PERMANENT = True
SESSION_USE_SIGNER = True
SESSION_KEY_PREFIX = "sk:"
PERMANENT_SESSION_LIFETIME = timedelta(days=7)
SESSION_COOKIE_SECURE = True # HTTPS only
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
# Flask-WTF CSRF Protection
WTF_CSRF_ENABLED = True
WTF_CSRF_TIME_LIMIT = None # No time limit for CSRF tokens
# Email service (Resend)
RESEND_API_KEY = os.environ.get("RESEND_API_KEY")
# Application URLs
APP_URL = os.environ.get("APP_URL", "http://localhost:5000")
# Timezone
TIMEZONE = os.environ.get("TZ", "UTC")
# Password requirements
MIN_PASSWORD_LENGTH = 12
class DevelopmentConfig(Config):
"""Development environment configuration."""
DEBUG = True
TESTING = False
SESSION_COOKIE_SECURE = False # Allow HTTP in development
SQLALCHEMY_ECHO = True # Log SQL queries
class ProductionConfig(Config):
"""Production environment configuration."""
DEBUG = False
TESTING = False
# Ensure critical environment variables are set
@classmethod
def validate(cls):
"""Validate that required production configuration is present."""
required_vars = ["SECRET_KEY", "RESEND_API_KEY", "APP_URL"]
missing_vars = [var for var in required_vars if not os.environ.get(var)]
if missing_vars:
raise ValueError(
f"Missing required environment variables: {', '.join(missing_vars)}"
)
class TestConfig(Config):
"""Test environment configuration."""
TESTING = True
DEBUG = True
SESSION_COOKIE_SECURE = False
# Use in-memory database for tests
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
# Disable CSRF for easier testing
WTF_CSRF_ENABLED = False
# Use a predictable secret key for tests
SECRET_KEY = "test-secret-key"
# Configuration dictionary for easy access
config = {
"development": DevelopmentConfig,
"production": ProductionConfig,
"testing": TestConfig,
"default": DevelopmentConfig,
}
def get_config(env: str | None = None) -> type[Config]:
"""Get configuration class based on environment.
Args:
env: Environment name (development, production, testing).
If None, uses FLASK_ENV environment variable.
Returns:
Configuration class for the specified environment.
"""
if env is None:
env = os.environ.get("FLASK_ENV", "development")
return config.get(env, config["default"])

9
src/models/__init__.py Normal file
View File

@@ -0,0 +1,9 @@
"""SQLAlchemy models for Sneaky Klaus.
This package contains all database models used by the application.
"""
from src.models.admin import Admin
from src.models.exchange import Exchange
__all__ = ["Admin", "Exchange"]

48
src/models/admin.py Normal file
View File

@@ -0,0 +1,48 @@
"""Admin model for Sneaky Klaus.
The Admin model represents the single administrator account
for the entire installation.
"""
from datetime import datetime
from sqlalchemy import DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
from src.app import db
class Admin(db.Model): # type: ignore[name-defined]
"""Administrator user model.
Represents the single admin account for the entire Sneaky Klaus installation.
Only one admin should exist per deployment.
Attributes:
id: Auto-increment primary key.
email: Admin email address (unique, indexed).
password_hash: bcrypt password hash.
created_at: Account creation timestamp.
updated_at: Last update timestamp.
"""
__tablename__ = "admin"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(
String(255), unique=True, nullable=False, index=True
)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=datetime.utcnow,
onupdate=datetime.utcnow,
)
def __repr__(self) -> str:
"""String representation of Admin instance."""
return f"<Admin {self.email}>"

98
src/models/exchange.py Normal file
View File

@@ -0,0 +1,98 @@
"""Exchange model for Sneaky Klaus.
The Exchange model represents a single Secret Santa exchange event.
"""
import secrets
import string
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from src.app import db
class Exchange(db.Model): # type: ignore[name-defined]
"""Exchange (Secret Santa event) model.
Represents a single Secret Santa exchange with all configuration
and state management.
Attributes:
id: Auto-increment primary key.
slug: URL-safe unique identifier (12 characters).
name: Exchange name/title.
description: Optional description.
budget: Gift budget (freeform text, e.g., "$20-30").
max_participants: Maximum participant limit (minimum 3).
registration_close_date: When registration ends.
exchange_date: When gifts are exchanged.
timezone: IANA timezone name (e.g., "America/New_York").
state: Current state (draft, registration_open, etc.).
created_at: Exchange creation timestamp.
updated_at: Last update timestamp.
completed_at: When exchange was marked complete.
"""
__tablename__ = "exchange"
# Valid state transitions
STATE_DRAFT = "draft"
STATE_REGISTRATION_OPEN = "registration_open"
STATE_REGISTRATION_CLOSED = "registration_closed"
STATE_MATCHED = "matched"
STATE_COMPLETED = "completed"
VALID_STATES = [
STATE_DRAFT,
STATE_REGISTRATION_OPEN,
STATE_REGISTRATION_CLOSED,
STATE_MATCHED,
STATE_COMPLETED,
]
id: Mapped[int] = mapped_column(primary_key=True)
slug: Mapped[str] = mapped_column(
String(12), unique=True, nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
budget: Mapped[str] = mapped_column(String(100), nullable=False)
max_participants: Mapped[int] = mapped_column(Integer, nullable=False)
registration_close_date: Mapped[datetime] = mapped_column(DateTime, nullable=False)
exchange_date: Mapped[datetime] = mapped_column(DateTime, nullable=False)
timezone: Mapped[str] = mapped_column(String(50), nullable=False)
state: Mapped[str] = mapped_column(
String(20), nullable=False, default=STATE_DRAFT, index=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=datetime.utcnow,
onupdate=datetime.utcnow,
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime, nullable=True, index=True
)
__table_args__ = (
CheckConstraint("max_participants >= 3", name="min_participants_check"),
)
@staticmethod
def generate_slug() -> str:
"""Generate a unique 12-character URL-safe slug.
Returns:
Random 12-character alphanumeric string.
"""
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(12))
def __repr__(self) -> str:
"""String representation of Exchange instance."""
return f"<Exchange {self.name} ({self.slug})>"