Implement Phase 3 participant self-management features: Story 6.3 - Reminder Preferences: - Add ReminderPreferenceForm to participant forms - Add update_preferences route for preference updates - Update dashboard template with reminder preference toggle - Participants can enable/disable reminder emails at any time Story 6.2 - Withdrawal from Exchange: - Add can_withdraw utility function for state validation - Create WithdrawalService to handle withdrawal process - Add WithdrawForm with explicit confirmation requirement - Add withdraw route with GET (confirmation) and POST (process) - Add withdrawal confirmation email template - Update dashboard to show withdraw link when allowed - Withdrawal only allowed before registration closes - Session cleared after withdrawal, user redirected to registration All acceptance criteria met for both stories. Test coverage: 90.02% (156 tests passing) Linting and type checking: passed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
568 lines
17 KiB
Python
568 lines
17 KiB
Python
"""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/<slug>/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/<slug>/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/<slug>/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/<token>")
|
|
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/<int:id>")
|
|
@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("/")
|