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

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})>"