feat: add Participant and MagicToken models with automatic migrations

Implements Phase 2 infrastructure for participant registration and authentication:

Database Models:
- Add Participant model with exchange scoping and soft deletes
- Add MagicToken model for passwordless authentication
- Add participants relationship to Exchange model
- Include proper indexes and foreign key constraints

Migration Infrastructure:
- Generate Alembic migration for new models
- Create entrypoint.sh script for automatic migrations on container startup
- Update Containerfile to use entrypoint script and include uv binary
- Remove db.create_all() in favor of migration-based schema management

This establishes the foundation for implementing stories 4.1-4.3, 5.1-5.3, and 10.1.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-22 16:23:47 -07:00
parent 5201b2f036
commit eaafa78cf3
22 changed files with 10459 additions and 7 deletions

View File

@@ -72,9 +72,14 @@ def create_app(config_name: str | None = None) -> Flask:
# Import models to ensure they're registered with SQLAlchemy
with app.app_context():
from src.models import Admin, Exchange, RateLimit # noqa: F401
db.create_all()
from src.models import ( # noqa: F401
Admin,
Exchange,
MagicToken,
Participant,
RateLimit,
)
# Schema managed by Alembic migrations (applied via entrypoint script)
return app

View File

@@ -5,6 +5,8 @@ This package contains all database models used by the application.
from src.models.admin import Admin
from src.models.exchange import Exchange
from src.models.magic_token import MagicToken
from src.models.participant import Participant
from src.models.rate_limit import RateLimit
__all__ = ["Admin", "Exchange", "RateLimit"]
__all__ = ["Admin", "Exchange", "MagicToken", "Participant", "RateLimit"]

View File

@@ -8,7 +8,7 @@ import string
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.app import db
@@ -79,6 +79,11 @@ class Exchange(db.Model): # type: ignore[name-defined]
DateTime, nullable=True, index=True
)
# Relationships
participants: Mapped[list["Participant"]] = relationship( # type: ignore # noqa: F821
"Participant", back_populates="exchange", cascade="all, delete-orphan"
)
__table_args__ = (
CheckConstraint("max_participants >= 3", name="min_participants_check"),
)

99
src/models/magic_token.py Normal file
View File

@@ -0,0 +1,99 @@
"""Magic token model for passwordless authentication.
Handles both participant magic links and admin password reset tokens.
"""
from datetime import UTC, datetime
from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
from src.app import db
class MagicToken(db.Model): # type: ignore[name-defined]
"""Time-limited tokens for participant authentication and password reset.
Token types:
- magic_link: For participant passwordless authentication
- password_reset: For admin password reset
Security:
- Original token is never stored (only SHA-256 hash)
- Tokens expire after 1 hour
- Single-use only (marked with used_at timestamp)
"""
__tablename__ = "magic_token"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
token_hash: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
token_type: Mapped[str] = mapped_column(String(20), nullable=False)
email: Mapped[str] = mapped_column(String(255), nullable=False)
participant_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("participant.id", ondelete="CASCADE"), nullable=True
)
exchange_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("exchange.id", ondelete="CASCADE"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
nullable=False, default=lambda: datetime.now(UTC)
)
expires_at: Mapped[datetime] = mapped_column(nullable=False)
used_at: Mapped[datetime | None] = mapped_column(nullable=True)
# Relationships (optional because password_reset tokens won't have these)
participant: Mapped["Participant | None"] = relationship("Participant") # type: ignore # noqa: F821
exchange: Mapped["Exchange | None"] = relationship("Exchange") # type: ignore # noqa: F821
# Indexes
__table_args__ = (
db.Index("idx_magic_token_hash", "token_hash", unique=True),
db.Index("idx_magic_token_type_email", "token_type", "email"),
db.Index("idx_magic_token_expires_at", "expires_at"),
)
@validates("token_type")
def validate_token_type(self, _key: str, value: str) -> str:
"""Validate token_type is one of the allowed values."""
allowed_types = ["magic_link", "password_reset"]
if value not in allowed_types:
raise ValueError(f"token_type must be one of {allowed_types}")
return value
def validate(self) -> None:
"""Validate foreign key requirements based on token type.
- magic_link tokens require participant_id and exchange_id
- password_reset tokens must NOT have participant_id or exchange_id
"""
if self.token_type == "magic_link":
if not self.participant_id or not self.exchange_id:
raise ValueError(
"Magic link tokens require participant_id and exchange_id"
)
elif self.token_type == "password_reset" and (
self.participant_id or self.exchange_id
):
raise ValueError(
"Password reset tokens cannot have participant_id or exchange_id"
)
@property
def is_expired(self) -> bool:
"""Check if token has expired."""
return bool(datetime.now(UTC) > self.expires_at)
@property
def is_used(self) -> bool:
"""Check if token has already been used."""
return self.used_at is not None
@property
def is_valid(self) -> bool:
"""Check if token is valid (not expired and not used)."""
return not self.is_expired and not self.is_used
def __repr__(self) -> str:
"""String representation of MagicToken."""
return f"<MagicToken {self.token_type} for {self.email}>"

64
src/models/participant.py Normal file
View File

@@ -0,0 +1,64 @@
"""Participant model for Sneaky Klaus.
Represents a person registered in a specific Secret Santa exchange.
"""
from datetime import UTC, datetime
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.app import db
class Participant(db.Model): # type: ignore[name-defined]
"""A person registered in a specific exchange.
Each participant belongs to exactly one exchange. If the same email
registers for multiple exchanges, separate Participant records are created.
"""
__tablename__ = "participant"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
exchange_id: Mapped[int] = mapped_column(
Integer, ForeignKey("exchange.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
email: Mapped[str] = mapped_column(String(255), nullable=False)
gift_ideas: Mapped[str | None] = mapped_column(Text, nullable=True)
reminder_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True
)
created_at: Mapped[datetime] = mapped_column(
nullable=False, default=lambda: datetime.now(UTC)
)
updated_at: Mapped[datetime] = mapped_column(
nullable=False,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
withdrawn_at: Mapped[datetime | None] = mapped_column(nullable=True)
# Relationships
exchange: Mapped["Exchange"] = relationship( # type: ignore # noqa: F821
"Exchange", back_populates="participants"
)
# Indexes
__table_args__ = (
db.Index("idx_participant_exchange_id", "exchange_id"),
db.Index("idx_participant_email", "email"),
db.Index("idx_participant_exchange_email", "exchange_id", "email", unique=True),
)
def __repr__(self) -> str:
"""String representation of Participant."""
return (
f"<Participant {self.name} ({self.email}) in Exchange {self.exchange_id}>"
)
@property
def is_withdrawn(self) -> bool:
"""Check if participant has withdrawn from the exchange."""
return self.withdrawn_at is not None