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 %} + + {% 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 %} +
+
+

Edit Your Profile

+

Update your display name and gift ideas. Your Secret Santa will see this information after matching.

+
+ +
+ {{ form.hidden_tag() }} + +
+ {{ form.name.label }} + {{ form.name(class="form-control") }} + {% if form.name.errors %} +
    + {% for error in form.name.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} + {{ form.name.description }} +
+ +
+ {{ form.gift_ideas.label }} + {{ form.gift_ideas(class="form-control") }} + {% if form.gift_ideas.errors %} +
    + {% for error in form.gift_ideas.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} + {{ form.gift_ideas.description }} + + {{ (form.gift_ideas.data or '')|length }} / 10,000 characters + +
+ +
+ + Cancel +
+
+
+ + +{% 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