"""Participant routes for Sneaky Klaus application.""" import hashlib import secrets from datetime import UTC, datetime, timedelta from flask import Blueprint, abort, flash, redirect, render_template, request, url_for from src.app import db from src.forms.participant import MagicLinkRequestForm, ParticipantRegistrationForm from src.models.exchange import Exchange from src.models.magic_token import MagicToken from src.models.participant import Participant from src.services.email import EmailService from src.utils.rate_limit import check_rate_limit, increment_rate_limit participant_bp = Blueprint("participant", __name__, url_prefix="") @participant_bp.route("/exchange//register", methods=["GET", "POST"]) def register(slug: str): """Participant registration page. Args: slug: Exchange registration slug. Returns: Rendered registration page template or redirect to success. """ # Find the exchange by slug exchange = db.session.query(Exchange).filter_by(slug=slug).first() if not exchange: abort(404) # Create the registration form form = ParticipantRegistrationForm() # Handle POST request if form.validate_on_submit(): # Check if exchange is open for registration if exchange.state != Exchange.STATE_REGISTRATION_OPEN: flash("Registration is not currently open for this exchange.", "error") return render_template( "participant/register.html", exchange=exchange, form=form, ) # Rate limiting: 10 registrations per hour per IP ip_address = request.remote_addr or "unknown" rate_limit_key = f"register:{slug}:{ip_address}" if check_rate_limit(rate_limit_key, max_attempts=10, window_minutes=60): abort(429) # Too Many Requests # Lowercase email for consistency email = form.email.data.lower() name = form.name.data gift_ideas = form.gift_ideas.data or None # Get reminder_enabled from form data, defaulting to True # If checkbox not in POST data at all, it should default to True # If explicitly unchecked, it will be False reminder_enabled = form.reminder_enabled.data # Check if participant with this email already exists for this exchange existing_participant = ( db.session.query(Participant) .filter_by(exchange_id=exchange.id, email=email) .first() ) if existing_participant: # Participant already registered flash( "You're already registered for this exchange. " "If you need access, you can request a new magic link below.", "info", ) return render_template( "participant/register.html", exchange=exchange, form=form, ) # Create participant record participant = Participant( exchange_id=exchange.id, name=name, email=email, gift_ideas=gift_ideas, reminder_enabled=reminder_enabled, ) db.session.add(participant) db.session.flush() # Get participant ID # Generate magic token token = secrets.token_urlsafe(32) token_hash = hashlib.sha256(token.encode()).hexdigest() magic_token = MagicToken( token_hash=token_hash, token_type="magic_link", email=email, participant_id=participant.id, exchange_id=exchange.id, expires_at=datetime.now(UTC) + timedelta(hours=1), ) magic_token.validate() db.session.add(magic_token) # Commit before sending email db.session.commit() # Send registration confirmation email magic_link_url = url_for( "participant.magic_login", token=token, _external=True, ) email_service = EmailService() try: email_service.send_registration_confirmation( to=email, participant_name=name, magic_link_url=magic_link_url, exchange_name=exchange.name, exchange_description=exchange.description, budget_amount=float(exchange.budget.replace("$", "")), gift_exchange_date=exchange.exchange_date.strftime("%Y-%m-%d"), ) except Exception as e: # Log error but don't fail registration # In production, we'd want proper logging print(f"Failed to send confirmation email: {e}") # Increment rate limit increment_rate_limit(rate_limit_key, window_minutes=60) return redirect(url_for("participant.register_success", slug=slug)) return render_template( "participant/register.html", exchange=exchange, form=form, ) @participant_bp.route("/exchange//register/success") def register_success(slug: str): """Registration success page. Args: slug: Exchange registration slug. Returns: Rendered success page template. """ exchange = db.session.query(Exchange).filter_by(slug=slug).first() if not exchange: abort(404) return render_template( "participant/register_success.html", exchange=exchange, ) @participant_bp.route("/exchange//request-access", methods=["GET", "POST"]) def request_access(slug: str): """Request a magic link for participant access. Args: slug: Exchange registration slug. Returns: Rendered request access page template or success message. """ # Find the exchange by slug exchange = db.session.query(Exchange).filter_by(slug=slug).first() if not exchange: abort(404) # Create the magic link request form form = MagicLinkRequestForm() # Handle POST request if form.validate_on_submit(): # Lowercase email for consistency email = form.email.data.lower() # Rate limiting: 3 requests per hour per email rate_limit_key = f"magic_link:{slug}:{email}" if check_rate_limit(rate_limit_key, max_attempts=3, window_minutes=60): abort(429) # Too Many Requests # Look up participant participant = ( db.session.query(Participant) .filter_by(exchange_id=exchange.id, email=email) .first() ) # Always show generic success message (prevent enumeration) # But only send email if participant exists if participant: # Generate magic token token = secrets.token_urlsafe(32) token_hash = hashlib.sha256(token.encode()).hexdigest() magic_token = MagicToken( token_hash=token_hash, token_type="magic_link", email=email, participant_id=participant.id, exchange_id=exchange.id, expires_at=datetime.now(UTC) + timedelta(hours=1), ) magic_token.validate() db.session.add(magic_token) db.session.commit() # Send magic link email magic_link_url = url_for( "participant.magic_login", token=token, _external=True, ) email_service = EmailService() try: email_service.send_magic_link( to=email, magic_link_url=magic_link_url, exchange_name=exchange.name, ) except Exception as e: # Log error but don't fail the request print(f"Failed to send magic link email: {e}") # Increment rate limit increment_rate_limit(rate_limit_key, window_minutes=60) # Show generic success message flash( "If you're registered for this exchange, we've sent you a magic link. " "Please check your email.", "success", ) return render_template( "participant/request_access.html", exchange=exchange, form=form, success=True, ) return render_template( "participant/request_access.html", exchange=exchange, form=form, ) @participant_bp.route("/auth/participant/magic/") def magic_login(token: str): # noqa: ARG001 """Magic link login for participants. Args: token: Magic token from email link. Returns: Redirect to participant dashboard. """ # Placeholder for Story 5.2 abort(404)