diff --git a/src/app.py b/src/app.py
index 0b33386..5bb5ec3 100644
--- a/src/app.py
+++ b/src/app.py
@@ -144,6 +144,7 @@ def register_setup_check(app: Flask) -> None:
"participant.magic_login",
"participant.dashboard",
"participant.logout",
+ "participant.profile_edit",
]:
return
diff --git a/src/forms/participant.py b/src/forms/participant.py
index 91df24d..9a1cb9f 100644
--- a/src/forms/participant.py
+++ b/src/forms/participant.py
@@ -48,3 +48,25 @@ class MagicLinkRequestForm(FlaskForm):
Length(max=255, message="Email must be less than 255 characters"),
],
)
+
+
+class ProfileUpdateForm(FlaskForm):
+ """Form for updating participant profile."""
+
+ name = StringField(
+ "Name",
+ validators=[
+ DataRequired(message="Name is required"),
+ Length(min=1, max=255, message="Name must be 1-255 characters"),
+ ],
+ description="Your display name (visible to other participants)",
+ )
+
+ gift_ideas = TextAreaField(
+ "Gift Ideas",
+ validators=[
+ Length(max=10000, message="Gift ideas must be less than 10,000 characters")
+ ],
+ description="Optional wishlist or gift preferences for your Secret Santa",
+ render_kw={"rows": 6, "maxlength": 10000},
+ )
diff --git a/src/routes/participant.py b/src/routes/participant.py
index 7fa5d80..c592c60 100644
--- a/src/routes/participant.py
+++ b/src/routes/participant.py
@@ -17,7 +17,11 @@ from flask import (
from src.app import db
from src.decorators.auth import participant_required
-from src.forms.participant import MagicLinkRequestForm, ParticipantRegistrationForm
+from src.forms.participant import (
+ MagicLinkRequestForm,
+ ParticipantRegistrationForm,
+ ProfileUpdateForm,
+)
from src.models.exchange import Exchange
from src.models.magic_token import MagicToken
from src.models.participant import Participant
@@ -341,10 +345,78 @@ def dashboard(id: int): # noqa: A002
if not participant:
abort(404)
+ # Get list of active participants
+ from src.utils.participant import can_update_profile, get_active_participants
+
+ participants = get_active_participants(exchange.id)
+ can_edit = can_update_profile(participant)
+
return render_template(
"participant/dashboard.html",
exchange=exchange,
participant=participant,
+ participants=participants,
+ participant_count=len(participants),
+ can_edit_profile=can_edit,
+ )
+
+
+@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,
)
diff --git a/src/templates/participant/dashboard.html b/src/templates/participant/dashboard.html
index 1a4b6e1..2927ae7 100644
--- a/src/templates/participant/dashboard.html
+++ b/src/templates/participant/dashboard.html
@@ -37,6 +37,28 @@
{{ participant.gift_ideas }}
{% endif %}
+
+ {% if can_edit_profile %}
+ Edit Profile
+ {% endif %}
+
+
+
+ Participants ({{ participant_count }})
+ {% if participants %}
+
+ {% for p in participants %}
+ -
+ {{ p.name }}
+ {% if p.id == participant.id %}
+ You
+ {% endif %}
+
+ {% endfor %}
+
+ {% else %}
+ No other participants yet. Share the registration link!
+ {% endif %}
diff --git a/src/templates/participant/profile_edit.html b/src/templates/participant/profile_edit.html
new file mode 100644
index 0000000..41f91e4
--- /dev/null
+++ b/src/templates/participant/profile_edit.html
@@ -0,0 +1,64 @@
+{% extends "layouts/base.html" %}
+
+{% block title %}Edit Profile - {{ exchange.name }}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/src/utils/participant.py b/src/utils/participant.py
new file mode 100644
index 0000000..21e1cbb
--- /dev/null
+++ b/src/utils/participant.py
@@ -0,0 +1,55 @@
+"""Participant business logic utilities."""
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from src.models import Participant
+
+
+def get_active_participants(exchange_id: int) -> list["Participant"]:
+ """Get all active (non-withdrawn) participants for an exchange.
+
+ Args:
+ exchange_id: ID of the exchange
+
+ Returns:
+ List of active participants, ordered by name
+ """
+ from src.models.participant import Participant
+
+ result: list[Participant] = (
+ Participant.query.filter(
+ Participant.exchange_id == exchange_id, Participant.withdrawn_at.is_(None)
+ )
+ .order_by(Participant.name)
+ .all()
+ )
+ return result
+
+
+def is_withdrawn(participant: "Participant") -> bool:
+ """Check if participant has withdrawn.
+
+ Args:
+ participant: The participant to check
+
+ Returns:
+ True if withdrawn, False otherwise
+ """
+ return participant.withdrawn_at is not None
+
+
+def can_update_profile(participant: "Participant") -> bool:
+ """Check if participant can update their profile.
+
+ Profile updates are allowed until matching occurs.
+
+ Args:
+ participant: The participant to check
+
+ Returns:
+ True if profile updates are allowed, False otherwise
+ """
+ exchange = participant.exchange
+ allowed_states = ["draft", "registration_open", "registration_closed"]
+ return exchange.state in allowed_states
diff --git a/tests/conftest.py b/tests/conftest.py
index e066452..bc0fb8b 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -75,3 +75,97 @@ def admin(db):
db.session.add(admin)
db.session.commit()
return admin
+
+
+@pytest.fixture
+def exchange_factory(db):
+ """Factory for creating test exchanges.
+
+ Args:
+ db: Database instance.
+
+ Returns:
+ Function that creates and returns Exchange instances.
+ """
+ from datetime import UTC, datetime, timedelta
+
+ from src.models.exchange import Exchange
+
+ def _create(state="draft", **kwargs):
+ exchange = Exchange(
+ slug=kwargs.get("slug", Exchange.generate_slug()),
+ name=kwargs.get("name", "Test Exchange"),
+ budget=kwargs.get("budget", "$25-50"),
+ max_participants=kwargs.get("max_participants", 50),
+ registration_close_date=kwargs.get(
+ "registration_close_date", datetime.now(UTC) + timedelta(days=7)
+ ),
+ exchange_date=kwargs.get(
+ "exchange_date", datetime.now(UTC) + timedelta(days=14)
+ ),
+ timezone=kwargs.get("timezone", "UTC"),
+ state=state,
+ )
+ db.session.add(exchange)
+ db.session.commit()
+ return exchange
+
+ return _create
+
+
+@pytest.fixture
+def participant_factory(db, exchange_factory):
+ """Factory for creating test participants.
+
+ Args:
+ db: Database instance.
+ exchange_factory: Exchange factory fixture.
+
+ Returns:
+ Function that creates and returns Participant instances.
+ """
+ from src.models.participant import Participant
+
+ counter = {"value": 0}
+
+ def _create(exchange=None, **kwargs):
+ if not exchange:
+ exchange = exchange_factory()
+
+ counter["value"] += 1
+ participant = Participant(
+ exchange_id=exchange.id,
+ name=kwargs.get("name", f"Test Participant {counter['value']}"),
+ email=kwargs.get("email", f"test{counter['value']}@example.com"),
+ gift_ideas=kwargs.get("gift_ideas", "Test ideas"),
+ reminder_enabled=kwargs.get("reminder_enabled", True),
+ withdrawn_at=kwargs.get("withdrawn_at"),
+ )
+ db.session.add(participant)
+ db.session.commit()
+ return participant
+
+ return _create
+
+
+@pytest.fixture
+def auth_participant(client, exchange_factory, participant_factory):
+ """Create an authenticated participant session.
+
+ Args:
+ client: Flask test client.
+ exchange_factory: Exchange factory fixture.
+ participant_factory: Participant factory fixture.
+
+ Returns:
+ Authenticated participant instance.
+ """
+ exchange = exchange_factory(state="registration_open")
+ participant = participant_factory(exchange=exchange)
+
+ with client.session_transaction() as session:
+ session["user_id"] = participant.id
+ session["user_type"] = "participant"
+ session["exchange_id"] = exchange.id
+
+ return participant
diff --git a/tests/integration/test_participant_list.py b/tests/integration/test_participant_list.py
new file mode 100644
index 0000000..f967f5d
--- /dev/null
+++ b/tests/integration/test_participant_list.py
@@ -0,0 +1,135 @@
+"""Integration tests for participant list functionality."""
+
+from datetime import UTC, datetime
+
+from flask import url_for
+
+
+def test_participant_list_shows_all_active(
+ client, auth_participant, participant_factory
+):
+ """Test participant list shows all active participants."""
+ # auth_participant fixture creates session and one participant
+ exchange = auth_participant.exchange
+
+ # Create 2 more active participants
+ participant_factory(exchange=exchange, name="Bob")
+ participant_factory(exchange=exchange, name="Charlie")
+
+ response = client.get(url_for("participant.dashboard", id=exchange.id))
+
+ assert response.status_code == 200
+ assert b"Bob" in response.data
+ assert b"Charlie" in response.data
+ assert b"Participants (3)" in response.data
+
+
+def test_participant_list_excludes_withdrawn(
+ client, auth_participant, participant_factory
+):
+ """Test withdrawn participants are not shown."""
+ exchange = auth_participant.exchange
+
+ # Create active and withdrawn participants
+ participant_factory(exchange=exchange, name="Active")
+ participant_factory(
+ exchange=exchange, name="Withdrawn", withdrawn_at=datetime.now(UTC)
+ )
+
+ response = client.get(url_for("participant.dashboard", id=exchange.id))
+
+ assert response.status_code == 200
+ assert b"Active" in response.data
+ assert b"Withdrawn" not in response.data
+ # Count should be 2 (auth_participant + Active)
+ assert b"Participants (2)" in response.data
+
+
+def test_participant_list_shows_self_badge(client, auth_participant):
+ """Test participant list marks current user with badge."""
+ exchange = auth_participant.exchange
+
+ response = client.get(url_for("participant.dashboard", id=exchange.id))
+
+ assert response.status_code == 200
+ # Should show the participant's name
+ assert auth_participant.name.encode() in response.data
+ # Should show "You" badge
+ assert b"You" in response.data
+
+
+def test_participant_list_ordered_by_name(
+ client, auth_participant, participant_factory
+):
+ """Test participants are shown in alphabetical order."""
+ exchange = auth_participant.exchange
+
+ # Create participants with names that should be sorted
+ participant_factory(exchange=exchange, name="Zoe")
+ participant_factory(exchange=exchange, name="Alice")
+ participant_factory(exchange=exchange, name="Bob")
+
+ response = client.get(url_for("participant.dashboard", id=exchange.id))
+
+ assert response.status_code == 200
+ # Check order by finding positions in response
+ data = response.data.decode()
+ alice_pos = data.find("Alice")
+ bob_pos = data.find("Bob")
+ zoe_pos = data.find("Zoe")
+
+ # All should be present
+ assert alice_pos > 0
+ assert bob_pos > 0
+ assert zoe_pos > 0
+
+ # Should be in alphabetical order
+ assert alice_pos < bob_pos < zoe_pos
+
+
+def test_participant_list_empty_state(client, exchange_factory, participant_factory):
+ """Test message shown when only one participant."""
+ exchange = exchange_factory(state="registration_open")
+ participant = participant_factory(exchange=exchange)
+
+ # Create session for the only participant
+ with client.session_transaction() as session:
+ session["user_id"] = participant.id
+ session["user_type"] = "participant"
+ session["exchange_id"] = exchange.id
+
+ response = client.get(url_for("participant.dashboard", id=exchange.id))
+
+ assert response.status_code == 200
+ assert b"Participants (1)" in response.data
+ # Should show participant's own name
+ assert participant.name.encode() in response.data
+
+
+def test_participant_list_requires_auth(client, exchange_factory):
+ """Test participant list requires authentication."""
+ exchange = exchange_factory()
+
+ response = client.get(
+ url_for("participant.dashboard", id=exchange.id), follow_redirects=False
+ )
+
+ # Should redirect to login (or show error)
+ assert response.status_code in [302, 403]
+
+
+def test_participant_list_different_exchange(
+ client, auth_participant, exchange_factory, participant_factory
+):
+ """Test participants filtered by exchange."""
+ exchange1 = auth_participant.exchange
+ exchange2 = exchange_factory(state="registration_open")
+
+ # Create participant in different exchange
+ participant_factory(exchange=exchange2, name="Other Exchange")
+
+ response = client.get(url_for("participant.dashboard", id=exchange1.id))
+
+ assert response.status_code == 200
+ # Should not show participant from other exchange
+ assert b"Other Exchange" not in response.data
diff --git a/tests/integration/test_profile_update.py b/tests/integration/test_profile_update.py
new file mode 100644
index 0000000..2dc8d21
--- /dev/null
+++ b/tests/integration/test_profile_update.py
@@ -0,0 +1,120 @@
+"""Integration tests for profile update functionality."""
+
+from flask import url_for
+
+
+def test_profile_update_get_shows_form(client, auth_participant):
+ """GET shows edit form with current values."""
+ response = client.get(url_for("participant.profile_edit"))
+
+ assert response.status_code == 200
+ assert auth_participant.name.encode() in response.data
+ assert b"Edit Your Profile" in response.data
+
+
+def test_profile_update_post_success(client, auth_participant, db):
+ """POST updates profile successfully."""
+ response = client.post(
+ url_for("participant.profile_edit"),
+ data={"name": "Updated Name", "gift_ideas": "Updated ideas"},
+ follow_redirects=True,
+ )
+
+ assert response.status_code == 200
+ assert b"profile has been updated" in response.data
+
+ # Verify database
+ db.session.refresh(auth_participant)
+ assert auth_participant.name == "Updated Name"
+ assert auth_participant.gift_ideas == "Updated ideas"
+
+
+def test_profile_update_name_change(client, auth_participant, db):
+ """Name updates in database."""
+ original_name = auth_participant.name
+
+ client.post(
+ url_for("participant.profile_edit"),
+ data={"name": "New Name", "gift_ideas": ""},
+ follow_redirects=True,
+ )
+
+ db.session.refresh(auth_participant)
+ assert auth_participant.name == "New Name"
+ assert auth_participant.name != original_name
+
+
+def test_profile_update_locked_after_matching(
+ client, exchange_factory, participant_factory
+):
+ """Profile edit blocked after matching."""
+ exchange = exchange_factory(state="matched")
+ participant = participant_factory(exchange=exchange)
+
+ with client.session_transaction() as session:
+ session["user_id"] = participant.id
+ session["user_type"] = "participant"
+ session["exchange_id"] = exchange.id
+
+ response = client.get(url_for("participant.profile_edit"), follow_redirects=True)
+
+ assert b"profile is locked" in response.data
+
+
+def test_profile_update_form_validation_empty_name(client):
+ """Empty name shows validation error."""
+ response = client.post(
+ url_for("participant.profile_edit"), data={"name": "", "gift_ideas": "Test"}
+ )
+
+ assert response.status_code == 200
+ assert b"Name is required" in response.data
+
+
+def test_profile_update_requires_auth(client):
+ """Profile edit requires authentication."""
+ response = client.get(url_for("participant.profile_edit"), follow_redirects=False)
+
+ assert response.status_code == 403
+
+
+def test_profile_update_strips_whitespace(client, auth_participant, db):
+ """Whitespace is stripped from name and gift ideas."""
+ client.post(
+ url_for("participant.profile_edit"),
+ data={"name": " Spaces ", "gift_ideas": " Gift "},
+ follow_redirects=True,
+ )
+
+ db.session.refresh(auth_participant)
+ assert auth_participant.name == "Spaces"
+ assert auth_participant.gift_ideas == "Gift"
+
+
+def test_dashboard_shows_edit_link_when_allowed(client, auth_participant):
+ """Dashboard shows edit profile link when editing is allowed."""
+ response = client.get(
+ url_for("participant.dashboard", id=auth_participant.exchange_id)
+ )
+
+ assert response.status_code == 200
+ assert b"Edit Profile" in response.data
+
+
+def test_dashboard_hides_edit_link_after_matching(
+ client, exchange_factory, participant_factory
+):
+ """Dashboard hides edit profile link after matching."""
+ exchange = exchange_factory(state="matched")
+ participant = participant_factory(exchange=exchange)
+
+ with client.session_transaction() as session:
+ session["user_id"] = participant.id
+ session["user_type"] = "participant"
+ session["exchange_id"] = exchange.id
+
+ response = client.get(url_for("participant.dashboard", id=exchange.id))
+
+ assert response.status_code == 200
+ # Edit link should not be present
+ assert b"Edit Profile" not in response.data
diff --git a/tests/unit/test_participant_utils.py b/tests/unit/test_participant_utils.py
new file mode 100644
index 0000000..0ce1c5e
--- /dev/null
+++ b/tests/unit/test_participant_utils.py
@@ -0,0 +1,123 @@
+"""Unit tests for participant utility functions."""
+
+from datetime import UTC, datetime
+
+from src.utils.participant import (
+ can_update_profile,
+ get_active_participants,
+ is_withdrawn,
+)
+
+
+def test_get_active_participants_excludes_withdrawn(
+ exchange_factory, participant_factory
+):
+ """Test that get_active_participants excludes withdrawn participants."""
+ exchange = exchange_factory()
+
+ # Create 2 active, 1 withdrawn
+ active1 = participant_factory(exchange=exchange, name="Alice")
+ active2 = participant_factory(exchange=exchange, name="Bob")
+ withdrawn = participant_factory(
+ exchange=exchange, name="Charlie", withdrawn_at=datetime.now(UTC)
+ )
+
+ participants = get_active_participants(exchange.id)
+
+ assert len(participants) == 2
+ assert active1 in participants
+ assert active2 in participants
+ assert withdrawn not in participants
+
+
+def test_get_active_participants_ordered_by_name(exchange_factory, participant_factory):
+ """Test that participants are ordered alphabetically."""
+ exchange = exchange_factory()
+
+ participant_factory(exchange=exchange, name="Zoe")
+ participant_factory(exchange=exchange, name="Alice")
+ participant_factory(exchange=exchange, name="Bob")
+
+ participants = get_active_participants(exchange.id)
+
+ assert len(participants) == 3
+ assert participants[0].name == "Alice"
+ assert participants[1].name == "Bob"
+ assert participants[2].name == "Zoe"
+
+
+def test_get_active_participants_empty_when_all_withdrawn(
+ exchange_factory, participant_factory
+):
+ """Test that empty list returned when all participants withdrawn."""
+ exchange = exchange_factory()
+
+ participant_factory(exchange=exchange, withdrawn_at=datetime.now(UTC))
+ participant_factory(exchange=exchange, withdrawn_at=datetime.now(UTC))
+
+ participants = get_active_participants(exchange.id)
+
+ assert len(participants) == 0
+
+
+def test_get_active_participants_different_exchanges(
+ exchange_factory, participant_factory
+):
+ """Test that participants are filtered by exchange_id."""
+ exchange1 = exchange_factory()
+ exchange2 = exchange_factory()
+
+ participant_factory(exchange=exchange1, name="Alice")
+ participant_factory(exchange=exchange2, name="Bob")
+
+ participants = get_active_participants(exchange1.id)
+
+ assert len(participants) == 1
+ assert participants[0].name == "Alice"
+
+
+def test_is_withdrawn_true(participant_factory):
+ """Test is_withdrawn returns True for withdrawn participant."""
+ participant = participant_factory(withdrawn_at=datetime.now(UTC))
+ assert is_withdrawn(participant) is True
+
+
+def test_is_withdrawn_false(participant_factory):
+ """Test is_withdrawn returns False for active participant."""
+ participant = participant_factory()
+ assert is_withdrawn(participant) is False
+
+
+def test_can_update_profile_draft_state(participant_factory, exchange_factory):
+ """Profile updates allowed in draft state."""
+ exchange = exchange_factory(state="draft")
+ participant = participant_factory(exchange=exchange)
+ assert can_update_profile(participant) is True
+
+
+def test_can_update_profile_registration_open(participant_factory, exchange_factory):
+ """Profile updates allowed when registration open."""
+ exchange = exchange_factory(state="registration_open")
+ participant = participant_factory(exchange=exchange)
+ assert can_update_profile(participant) is True
+
+
+def test_can_update_profile_registration_closed(participant_factory, exchange_factory):
+ """Profile updates allowed when registration closed."""
+ exchange = exchange_factory(state="registration_closed")
+ participant = participant_factory(exchange=exchange)
+ assert can_update_profile(participant) is True
+
+
+def test_can_update_profile_matched_state(participant_factory, exchange_factory):
+ """Profile updates blocked after matching."""
+ exchange = exchange_factory(state="matched")
+ participant = participant_factory(exchange=exchange)
+ assert can_update_profile(participant) is False
+
+
+def test_can_update_profile_completed_state(participant_factory, exchange_factory):
+ """Profile updates blocked when completed."""
+ exchange = exchange_factory(state="completed")
+ participant = participant_factory(exchange=exchange)
+ assert can_update_profile(participant) is False