"""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, session, url_for, ) from src.app import db from src.decorators.auth import participant_required from src.forms.participant import ( MagicLinkRequestForm, ParticipantRegistrationForm, ProfileUpdateForm, ReminderPreferenceForm, WithdrawForm, ) 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=exchange.budget, 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): """Magic link login for participants. Args: token: Magic token from email link. Returns: Redirect to participant dashboard or error page. """ # Hash the incoming token to look it up token_hash = hashlib.sha256(token.encode()).hexdigest() # Look up the token magic_token = db.session.query(MagicToken).filter_by(token_hash=token_hash).first() if not magic_token: flash("Invalid or expired magic link.", "error") return render_template("errors/magic_link_error.html"), 200 # Validate token if magic_token.is_expired: flash("This magic link has expired. Please request a new one.", "error") return render_template("errors/magic_link_error.html"), 200 if magic_token.is_used: flash( "This magic link has already been used. Please request a new one.", "error", ) return render_template("errors/magic_link_error.html"), 200 # Mark token as used magic_token.used_at = datetime.now(UTC) db.session.commit() # Create session session["user_id"] = magic_token.participant_id session["user_type"] = "participant" session["exchange_id"] = magic_token.exchange_id # Redirect to participant dashboard return redirect(url_for("participant.dashboard", id=magic_token.exchange_id)) @participant_bp.route("/participant/exchange/") @participant_required def dashboard(id: int): # noqa: A002 """Participant dashboard. Args: id: Exchange ID. Returns: Rendered dashboard template. """ # Verify participant has access to this exchange if session.get("exchange_id") != id: abort(403) # Get exchange and participant exchange = db.session.query(Exchange).filter_by(id=id).first() if not exchange: abort(404) participant = db.session.query(Participant).filter_by(id=session["user_id"]).first() if not participant: abort(404) # Get list of active participants from src.utils.participant import ( can_update_profile, can_withdraw, get_active_participants, ) participants = get_active_participants(exchange.id) can_edit = can_update_profile(participant) can_leave = can_withdraw(participant) # Create reminder preference form reminder_form = ReminderPreferenceForm( reminder_enabled=participant.reminder_enabled ) return render_template( "participant/dashboard.html", exchange=exchange, participant=participant, participants=participants, participant_count=len(participants), can_edit_profile=can_edit, can_withdraw=can_leave, reminder_form=reminder_form, ) @participant_bp.route("/participant/profile/edit", methods=["GET", "POST"]) @participant_required def profile_edit(): """Edit participant profile (name and gift ideas). Only allowed before matching occurs. Returns: GET: Profile edit form POST: Redirect to dashboard on success, or re-render form on error """ from flask import current_app # Get participant from session participant = db.session.query(Participant).filter_by(id=session["user_id"]).first() if not participant: abort(404) # Check if profile editing is allowed from src.utils.participant import can_update_profile if not can_update_profile(participant): flash( "Your profile is locked after matching. Contact the admin for changes.", "error", ) return redirect(url_for("participant.dashboard", id=participant.exchange_id)) # Create form with current values form = ProfileUpdateForm(obj=participant) if form.validate_on_submit(): try: # Update participant participant.name = form.name.data.strip() participant.gift_ideas = ( form.gift_ideas.data.strip() if form.gift_ideas.data else None ) db.session.commit() flash("Your profile has been updated successfully.", "success") return redirect( url_for("participant.dashboard", id=participant.exchange_id) ) except Exception as e: db.session.rollback() current_app.logger.error(f"Failed to update participant profile: {e}") flash("Failed to update profile. Please try again.", "error") return render_template( "participant/profile_edit.html", form=form, participant=participant, exchange=participant.exchange, ) @participant_bp.route("/participant/preferences", methods=["POST"]) @participant_required def update_preferences(): """Update participant reminder email preferences. Returns: Redirect to dashboard """ from flask import current_app participant = db.session.query(Participant).filter_by(id=session["user_id"]).first() if not participant: abort(404) form = ReminderPreferenceForm() if form.validate_on_submit(): try: participant.reminder_enabled = form.reminder_enabled.data db.session.commit() if form.reminder_enabled.data: flash("Reminder emails enabled.", "success") else: flash("Reminder emails disabled.", "success") except Exception as e: db.session.rollback() current_app.logger.error(f"Failed to update preferences: {e}") flash("Failed to update preferences. Please try again.", "error") else: flash("Invalid request.", "error") return redirect(url_for("participant.dashboard", id=participant.exchange_id)) @participant_bp.route("/participant/withdraw", methods=["GET", "POST"]) @participant_required def withdraw(): """Withdraw from exchange (soft delete). GET: Show confirmation page with warnings POST: Process withdrawal, log out, redirect to public page Returns: GET: Withdrawal confirmation page POST: Redirect to exchange registration page """ from flask import current_app participant = db.session.query(Participant).filter_by(id=session["user_id"]).first() if not participant: abort(404) exchange = participant.exchange # Check if withdrawal is allowed from src.utils.participant import can_withdraw, is_withdrawn if is_withdrawn(participant): flash("You have already withdrawn from this exchange.", "info") return redirect(url_for("participant.register", slug=exchange.slug)) if not can_withdraw(participant): if exchange.state == "registration_closed": message = "Registration has closed. Please contact the admin to withdraw." else: message = "Withdrawal is no longer available. Please contact the admin." flash(message, "error") return redirect(url_for("participant.dashboard", id=exchange.id)) # Create withdrawal confirmation form form = WithdrawForm() if form.validate_on_submit(): try: # Perform withdrawal from src.services.withdrawal import WithdrawalError, withdraw_participant withdraw_participant(participant) # Log out participant session.clear() flash( "You have been withdrawn from the exchange. " "A confirmation email has been sent.", "success", ) return redirect(url_for("participant.register", slug=exchange.slug)) except WithdrawalError as e: flash(str(e), "error") return redirect(url_for("participant.dashboard", id=exchange.id)) except Exception as e: current_app.logger.error(f"Unexpected error during withdrawal: {e}") flash("An unexpected error occurred. Please try again.", "error") # GET request: show confirmation page return render_template( "participant/withdraw.html", form=form, participant=participant, exchange=exchange, ) @participant_bp.route("/participant/logout", methods=["POST"]) def logout(): """Participant logout. Returns: Redirect to homepage or success message. """ # Store exchange_id before clearing session exchange_id = session.get("exchange_id") # Clear session session.clear() flash("You've been logged out successfully.", "success") # Redirect to the exchange's request access page if we know the exchange if exchange_id: exchange = db.session.query(Exchange).filter_by(id=exchange_id).first() if exchange: return redirect(url_for("participant.request_access", slug=exchange.slug)) # Fallback to root return redirect("/")