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:
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
143
src/app.py
Normal file
143
src/app.py
Normal 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
120
src/config.py
Normal 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
9
src/models/__init__.py
Normal 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
48
src/models/admin.py
Normal 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
98
src/models/exchange.py
Normal 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})>"
|
||||
Reference in New Issue
Block a user