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:
11
src/app.py
11
src/app.py
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
99
src/models/magic_token.py
Normal 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
64
src/models/participant.py
Normal 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
|
||||
Reference in New Issue
Block a user