diff --git a/src/app.py b/src/app.py index 33600bb..2c0b290 100644 --- a/src/app.py +++ b/src/app.py @@ -134,7 +134,16 @@ def register_setup_check(app: Flask) -> None: has been set up with an admin account. """ # Skip check for certain endpoints - if request.endpoint in ["setup.setup", "static", "health"]: + # Participant routes are public and don't require setup + if request.endpoint in [ + "setup.setup", + "static", + "health", + "participant.register", + "participant.request_access", + "participant.magic_login", + "participant.dashboard", + ]: return # Check if admin exists (always check in testing mode) diff --git a/src/forms/__init__.py b/src/forms/__init__.py index 2cb2b2e..98317d9 100644 --- a/src/forms/__init__.py +++ b/src/forms/__init__.py @@ -2,6 +2,7 @@ from src.forms.exchange import ExchangeForm from src.forms.login import LoginForm +from src.forms.participant import ParticipantRegistrationForm from src.forms.setup import SetupForm -__all__ = ["ExchangeForm", "LoginForm", "SetupForm"] +__all__ = ["ExchangeForm", "LoginForm", "ParticipantRegistrationForm", "SetupForm"] diff --git a/src/forms/participant.py b/src/forms/participant.py new file mode 100644 index 0000000..a3a94d1 --- /dev/null +++ b/src/forms/participant.py @@ -0,0 +1,37 @@ +"""Forms for participant registration and management.""" + +from flask_wtf import FlaskForm +from wtforms import BooleanField, EmailField, StringField, TextAreaField +from wtforms.validators import DataRequired, Email, Length + + +class ParticipantRegistrationForm(FlaskForm): + """Form for participant registration.""" + + name = StringField( + "Name", + validators=[ + DataRequired(message="Name is required"), + Length(max=255, message="Name must be less than 255 characters"), + ], + ) + + email = EmailField( + "Email", + validators=[ + DataRequired(message="Email is required"), + Email(message="Please enter a valid email address"), + Length(max=255, message="Email must be less than 255 characters"), + ], + ) + + gift_ideas = TextAreaField( + "Gift Ideas", + description="Optional: Share ideas to help your Secret Santa", + ) + + reminder_enabled = BooleanField( + "Send me reminders", + default=True, + description="Receive email reminders about important dates", + ) diff --git a/src/routes/participant.py b/src/routes/participant.py index d1ef6ac..dfba076 100644 --- a/src/routes/participant.py +++ b/src/routes/participant.py @@ -1,13 +1,17 @@ """Participant routes for Sneaky Klaus application.""" -from flask import Blueprint +from flask import Blueprint, abort, render_template + +from src.app import db +from src.forms.participant import ParticipantRegistrationForm +from src.models.exchange import Exchange participant_bp = Blueprint("participant", __name__, url_prefix="") -@participant_bp.route("/exchange//register") -def register(slug): - """Participant registration page (stub for now). +@participant_bp.route("/exchange//register", methods=["GET", "POST"]) +def register(slug: str): + """Participant registration page. Args: slug: Exchange registration slug. @@ -15,4 +19,16 @@ def register(slug): Returns: Rendered registration page template. """ - return f"Registration page for exchange: {slug}" + # 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() + + return render_template( + "participant/register.html", + exchange=exchange, + form=form, + ) diff --git a/src/templates/participant/register.html b/src/templates/participant/register.html new file mode 100644 index 0000000..d24fd20 --- /dev/null +++ b/src/templates/participant/register.html @@ -0,0 +1,72 @@ +{% extends "layouts/base.html" %} + +{% block title %}Register for {{ exchange.name }}{% endblock %} + +{% block content %} +
+
+

{{ exchange.name }}

+ {% if exchange.description %} +

{{ exchange.description }}

+ {% endif %} +
+ +
+

Exchange Details

+
+
Budget
+
{{ exchange.budget }}
+ +
Gift Exchange Date
+
{{ exchange.exchange_date.strftime('%Y-%m-%d') }}
+ +
Registration Deadline
+
{{ exchange.registration_close_date.strftime('%Y-%m-%d') }}
+
+
+ +
+

Register

+
+ {{ form.hidden_tag() }} + + + + + + + + + + +
+
+
+{% endblock %} diff --git a/tests/integration/test_story_4_1_access_registration_page.py b/tests/integration/test_story_4_1_access_registration_page.py new file mode 100644 index 0000000..0f2332f --- /dev/null +++ b/tests/integration/test_story_4_1_access_registration_page.py @@ -0,0 +1,152 @@ +"""Tests for Story 4.1: Access Registration Page. + +As a potential participant, I want to access the registration page +via the unique link shared by the admin so that I can sign up for +the Secret Santa exchange. +""" + +from datetime import datetime, timedelta + +from src.models.exchange import Exchange + + +class TestStory41AccessRegistrationPage: + """Test suite for Story 4.1: Access Registration Page.""" + + def test_get_registration_page_with_valid_slug(self, client, db): + """Test registration page with valid slug displays exchange details.""" + # Create an exchange + future_close_date = datetime.utcnow() + timedelta(days=7) + future_exchange_date = datetime.utcnow() + timedelta(days=14) + + exchange = Exchange( + slug="winter2025", + name="Office Secret Santa 2025", + description="Annual office gift exchange", + budget="$50", + max_participants=10, + registration_close_date=future_close_date, + exchange_date=future_exchange_date, + timezone="America/New_York", + state=Exchange.STATE_REGISTRATION_OPEN, + ) + db.session.add(exchange) + db.session.commit() + + # Access registration page + response = client.get("/exchange/winter2025/register") + + # Should return 200 OK + assert response.status_code == 200 + + # Should display exchange details + assert b"Office Secret Santa 2025" in response.data + assert b"Annual office gift exchange" in response.data + assert b"$50" in response.data + # Format the date to match what will be displayed + assert future_exchange_date.strftime("%Y-%m-%d").encode() in response.data + + # Should display registration form + assert b'name="name"' in response.data + assert b'name="email"' in response.data + assert b'name="gift_ideas"' in response.data + assert b'name="reminder_enabled"' in response.data + + def test_get_registration_page_with_invalid_slug(self, client): + """Test accessing registration page with invalid slug returns 404.""" + response = client.get("/exchange/invalid-slug/register") + assert response.status_code == 404 + + def test_registration_form_has_csrf_protection(self, client, db): + """Test registration form includes POST method (CSRF via Flask-WTF).""" + future_close_date = datetime.utcnow() + timedelta(days=7) + future_exchange_date = datetime.utcnow() + timedelta(days=14) + + exchange = Exchange( + slug="test123", + name="Test Exchange", + budget="$30", + max_participants=10, + registration_close_date=future_close_date, + exchange_date=future_exchange_date, + timezone="America/New_York", + state=Exchange.STATE_REGISTRATION_OPEN, + ) + db.session.add(exchange) + db.session.commit() + + response = client.get("/exchange/test123/register") + + # Should have a POST form (CSRF protection is provided by Flask-WTF) + # Note: CSRF tokens are disabled in test mode, so we just verify the form exists + assert b'
' in response.data + assert b'' in response.data + + def test_registration_page_displays_optional_description(self, client, db): + """Test that optional description is displayed if present.""" + future_close_date = datetime.utcnow() + timedelta(days=7) + future_exchange_date = datetime.utcnow() + timedelta(days=14) + + exchange = Exchange( + slug="test123", + name="Test Exchange", + description="This is a test description", + budget="$30", + max_participants=10, + registration_close_date=future_close_date, + exchange_date=future_exchange_date, + timezone="America/New_York", + state=Exchange.STATE_REGISTRATION_OPEN, + ) + db.session.add(exchange) + db.session.commit() + + response = client.get("/exchange/test123/register") + + assert b"This is a test description" in response.data + + def test_registration_page_without_description(self, client, db): + """Test that page works when description is not provided.""" + future_close_date = datetime.utcnow() + timedelta(days=7) + future_exchange_date = datetime.utcnow() + timedelta(days=14) + + exchange = Exchange( + slug="test123", + name="Test Exchange", + budget="$30", + max_participants=10, + registration_close_date=future_close_date, + exchange_date=future_exchange_date, + timezone="America/New_York", + state=Exchange.STATE_REGISTRATION_OPEN, + ) + db.session.add(exchange) + db.session.commit() + + response = client.get("/exchange/test123/register") + + # Should still work without description + assert response.status_code == 200 + assert b"Test Exchange" in response.data + + def test_registration_page_displays_registration_deadline(self, client, db): + """Test that registration deadline is displayed.""" + future_close_date = datetime.utcnow() + timedelta(days=7) + future_exchange_date = datetime.utcnow() + timedelta(days=14) + + exchange = Exchange( + slug="test123", + name="Test Exchange", + budget="$30", + max_participants=10, + registration_close_date=future_close_date, + exchange_date=future_exchange_date, + timezone="America/New_York", + state=Exchange.STATE_REGISTRATION_OPEN, + ) + db.session.add(exchange) + db.session.commit() + + response = client.get("/exchange/test123/register") + + assert future_close_date.strftime("%Y-%m-%d").encode() in response.data