feat: implement Story 4.2 - New Participant Registration

Implement participant registration with the following features:

- POST handler for /exchange/<slug>/register
- Create Participant record with lowercased email
- Generate magic token and send confirmation email
- Redirect to success page after registration
- Rate limiting: 10 registrations per hour per IP
- Validation for exchange state (must be registration_open)
- Form validation for required fields (name, email)
- Email format validation
- Optional fields support (gift_ideas, reminder_enabled)

Also includes:
- Registration success page template
- 429 error handling template
- Flash message support in base template
- Test config update for email service dev mode
- Comprehensive test suite with 8 tests

All tests passing (86 total), 91% coverage maintained.

🤖 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 17:14:11 -07:00
parent 81e2cb8c86
commit 3467d97828
8 changed files with 506 additions and 6 deletions

View File

@@ -112,10 +112,7 @@ def register_error_handlers(app: Flask) -> None:
@app.errorhandler(429)
def rate_limit_error(_error):
"""Handle 429 Too Many Requests errors."""
from flask import flash, redirect, request
flash("Too many attempts. Please try again later.", "error")
return redirect(request.referrer or "/"), 429
return render_template("errors/429.html"), 429
def register_setup_check(app: Flask) -> None:
@@ -140,6 +137,7 @@ def register_setup_check(app: Flask) -> None:
"static",
"health",
"participant.register",
"participant.register_success",
"participant.request_access",
"participant.magic_login",
"participant.dashboard",

View File

@@ -96,6 +96,9 @@ class TestConfig(Config):
# Use a predictable secret key for tests
SECRET_KEY = "test-secret-key"
# Set FLASK_ENV to development to enable dev mode in EmailService
FLASK_ENV = "development"
# Configuration dictionary for easy access
config = {

View File

@@ -1,10 +1,18 @@
"""Participant routes for Sneaky Klaus application."""
from flask import Blueprint, abort, render_template
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 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="")
@@ -17,7 +25,7 @@ def register(slug: str):
slug: Exchange registration slug.
Returns:
Rendered registration page template.
Rendered registration page template or redirect to success.
"""
# Find the exchange by slug
exchange = db.session.query(Exchange).filter_by(slug=slug).first()
@@ -27,8 +35,126 @@ def register(slug: str):
# 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
# 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/<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("/auth/participant/magic/<token>")
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)

View File

@@ -0,0 +1,12 @@
{% extends "layouts/base.html" %}
{% block title %}Too Many Requests - Sneaky Klaus{% endblock %}
{% block content %}
<article>
<header>
<h1>429 - Too Many Requests</h1>
</header>
<p>Too many registration attempts. Please try again later.</p>
</article>
{% endblock %}

View File

@@ -12,6 +12,15 @@
</head>
<body>
<main class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<article role="alert">
<p>{{ message }}</p>
</article>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>

View File

@@ -0,0 +1,33 @@
{% extends "layouts/base.html" %}
{% block title %}Registration Complete - {{ exchange.name }}{% endblock %}
{% block content %}
<article>
<header>
<h1>Registration Complete!</h1>
</header>
<section>
<p>You've successfully registered for <strong>{{ exchange.name }}</strong>.</p>
<p>Check your email for a confirmation message with a magic link to access your participant dashboard.</p>
<h3>What's Next?</h3>
<ul>
<li>You'll receive an email with a link to access your dashboard</li>
<li>After registration closes, you'll be assigned your Secret Santa recipient</li>
<li>You'll receive reminders about important dates</li>
</ul>
<h3>Exchange Details</h3>
<dl>
<dt>Budget</dt>
<dd>{{ exchange.budget }}</dd>
<dt>Gift Exchange Date</dt>
<dd>{{ exchange.exchange_date.strftime('%Y-%m-%d') }}</dd>
</dl>
</section>
</article>
{% endblock %}