Files
sneakyklaus/docs/designs/v0.3.0/participant-self-management.md
Phil Skentelbery 915e77d994 chore: add production deployment config and upgrade path requirements
- Add docker-compose.yml and docker-compose.example.yml for production deployment
- Add .env.example with all required environment variables
- Update architect agent with upgrade path requirements
- Update developer agent with migration best practices
- Add Phase 3 design documents (v0.3.0)
- Add ADR-0006 for participant state management

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 19:32:42 -07:00

34 KiB

Participant Self-Management Component Design

Version: 0.3.0 Date: 2025-12-22 Status: Component Design

Overview

This document provides detailed component specifications for participant self-management features in Phase 3. It covers profile updates, withdrawals, reminder preferences, and the participant list view.

Component Architecture

graph TB
    subgraph "Presentation Layer"
        Dashboard[Participant Dashboard]
        ProfileEdit[Profile Edit Page]
        WithdrawPage[Withdraw Confirmation Page]
    end

    subgraph "Forms Layer"
        ProfileForm[ProfileUpdateForm]
        ReminderForm[ReminderPreferenceForm]
        WithdrawForm[WithdrawForm]
    end

    subgraph "Route Layer"
        DashboardRoute[dashboard()]
        ProfileEditRoute[profile_edit()]
        UpdatePrefsRoute[update_preferences()]
        WithdrawRoute[withdraw()]
    end

    subgraph "Business Logic"
        StateChecks[State Validation Functions]
        WithdrawService[Withdrawal Service]
    end

    subgraph "Data Layer"
        ParticipantModel[Participant Model]
        ExchangeModel[Exchange Model]
    end

    Dashboard --> DashboardRoute
    ProfileEdit --> ProfileEditRoute
    WithdrawPage --> WithdrawRoute

    ProfileEditRoute --> ProfileForm
    UpdatePrefsRoute --> ReminderForm
    WithdrawRoute --> WithdrawForm

    DashboardRoute --> StateChecks
    ProfileEditRoute --> StateChecks
    WithdrawRoute --> StateChecks

    WithdrawRoute --> WithdrawService

    StateChecks --> ParticipantModel
    StateChecks --> ExchangeModel
    WithdrawService --> ParticipantModel

1. Business Logic Functions

1.1 State Validation Functions

Location: src/utils/participant.py (new file)

These functions encapsulate business rules for participant operations:

"""Participant business logic utilities."""
from datetime import datetime
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from src.models import Participant


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


def can_withdraw(participant: 'Participant') -> bool:
    """Check if participant can withdraw from the exchange.

    Withdrawals are only allowed before registration closes.
    After that, admin intervention is required.

    Args:
        participant: The participant to check

    Returns:
        True if withdrawal is allowed, False otherwise
    """
    # Already withdrawn
    if participant.withdrawn_at is not None:
        return False

    exchange = participant.exchange
    allowed_states = ['draft', 'registration_open']
    return exchange.state in allowed_states


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 import Participant

    return Participant.query.filter(
        Participant.exchange_id == exchange_id,
        Participant.withdrawn_at.is_(None)
    ).order_by(Participant.name).all()


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

Design Rationale:

  • Centralize business logic for reuse across routes and templates
  • Make state transition rules explicit and testable
  • Simplify route handlers (delegate to pure functions)
  • Enable easy rule changes without touching routes

1.2 Withdrawal Service

Location: src/services/withdrawal.py (new file)

Encapsulates the withdrawal process:

"""Participant withdrawal service."""
from datetime import datetime
from flask import current_app
from src.models import Participant, db
from src.services.email import EmailService
from src.utils.participant import can_withdraw


class WithdrawalError(Exception):
    """Raised when withdrawal operation fails."""
    pass


def withdraw_participant(participant: Participant) -> None:
    """Withdraw a participant from their exchange.

    This performs a soft delete by setting withdrawn_at timestamp.
    Participant record is retained for audit trail.

    Args:
        participant: The participant to withdraw

    Raises:
        WithdrawalError: If withdrawal is not allowed

    Side effects:
        - Sets participant.withdrawn_at to current UTC time
        - Commits database transaction
        - Sends withdrawal confirmation email
    """
    # Validate withdrawal is allowed
    if not can_withdraw(participant):
        if participant.withdrawn_at is not None:
            raise WithdrawalError("You have already withdrawn from this exchange.")

        exchange = participant.exchange
        if exchange.state == 'registration_closed':
            raise WithdrawalError(
                "Registration has closed. Please contact the admin to withdraw."
            )
        elif exchange.state in ['matched', 'completed']:
            raise WithdrawalError(
                "Matching has already occurred. Please contact the admin."
            )
        else:
            raise WithdrawalError("Withdrawal is not allowed at this time.")

    # Perform withdrawal
    participant.withdrawn_at = datetime.utcnow()

    try:
        db.session.commit()
        current_app.logger.info(
            f"Participant {participant.id} withdrawn from exchange {participant.exchange_id}"
        )
    except Exception as e:
        db.session.rollback()
        current_app.logger.error(f"Failed to withdraw participant: {e}")
        raise WithdrawalError("Failed to process withdrawal. Please try again.")

    # Send confirmation email
    try:
        email_service = EmailService()
        email_service.send_withdrawal_confirmation(participant)
    except Exception as e:
        current_app.logger.error(f"Failed to send withdrawal email: {e}")
        # Don't raise - withdrawal already committed

Design Rationale:

  • Encapsulate complex multi-step operation (validate, update DB, send email)
  • Provide clear error messages for different failure scenarios
  • Separate concerns: business logic from route handling
  • Log all withdrawal events for debugging and audit
  • Gracefully handle email failures (withdrawal is primary operation)

2. Forms

2.1 ProfileUpdateForm

Location: src/forms/participant.py (enhance existing file)

from wtforms import StringField, TextAreaField, SubmitField
from wtforms.validators import DataRequired, Length
from flask_wtf import FlaskForm


class ProfileUpdateForm(FlaskForm):
    """Form for updating participant profile.

    Allows editing name and gift ideas before matching occurs.
    """
    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}
    )

    submit = SubmitField('Save Changes')

Validation Rules:

  • Name: Required, 1-255 characters
  • Gift ideas: Optional, max 10,000 characters
  • CSRF token: Automatic (FlaskForm)

UX Enhancements:

  • Maxlength attribute provides browser-level hint
  • Rows attribute sizes textarea appropriately
  • Description text provides context

2.2 ReminderPreferenceForm

Location: src/forms/participant.py

from wtforms import BooleanField, SubmitField
from flask_wtf import FlaskForm


class ReminderPreferenceForm(FlaskForm):
    """Form for updating reminder email preferences."""
    reminder_enabled = BooleanField(
        'Send me reminder emails before the exchange date',
        description="You can change this at any time"
    )

    submit = SubmitField('Update Preferences')

Validation Rules:

  • No validation needed (boolean field)
  • CSRF token: Automatic

UX Note: Form pre-populates with current preference value.

2.3 WithdrawForm

Location: src/forms/participant.py

from wtforms import BooleanField, SubmitField
from wtforms.validators import DataRequired
from flask_wtf import FlaskForm


class WithdrawForm(FlaskForm):
    """Form for confirming withdrawal from exchange.

    Requires explicit confirmation to prevent accidental withdrawals.
    """
    confirm = BooleanField(
        'I understand this cannot be undone and I will need to re-register to rejoin',
        validators=[
            DataRequired(message="You must confirm to withdraw")
        ]
    )

    submit = SubmitField('Withdraw from Exchange')

Validation Rules:

  • Confirmation: Must be checked (DataRequired on BooleanField)
  • CSRF token: Automatic

Design Rationale:

  • Explicit confirmation prevents accidental clicks
  • Clear warning about consequences
  • Single-step process (no "are you sure?" modal)

3. Routes

3.1 Enhanced Dashboard Route

Location: src/routes/participant.py

@participant_bp.route('/participant/dashboard')
@participant_required
def dashboard():
    """Participant dashboard showing exchange info and participant list.

    Requires authentication as participant.

    Returns:
        Rendered dashboard template
    """
    participant = g.participant
    exchange = participant.exchange

    # Get list of active participants
    from src.utils.participant import get_active_participants
    participants = get_active_participants(exchange.id)

    # Check available actions
    from src.utils.participant import can_update_profile, can_withdraw
    can_edit = can_update_profile(participant)
    can_leave = can_withdraw(participant)

    # Create reminder preference form
    from src.forms.participant import ReminderPreferenceForm
    reminder_form = ReminderPreferenceForm(
        reminder_enabled=participant.reminder_enabled
    )

    return render_template(
        'participant/dashboard.html',
        participant=participant,
        exchange=exchange,
        participants=participants,
        participant_count=len(participants),
        can_edit_profile=can_edit,
        can_withdraw=can_leave,
        reminder_form=reminder_form
    )

Template Variables:

  • participant: Current participant object
  • exchange: Associated exchange object
  • participants: List of active participants (for participant list)
  • participant_count: Number of active participants
  • can_edit_profile: Boolean flag for showing edit button
  • can_withdraw: Boolean flag for showing withdraw button
  • reminder_form: Pre-populated reminder preference form

3.2 Profile Edit Route

Location: src/routes/participant.py

@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
    """
    participant = g.participant

    # 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'))

    # Create form with current values
    from src.forms.participant import ProfileUpdateForm
    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'))

        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
    )

Flow:

  1. Check participant authentication (@participant_required)
  2. Verify profile editing is allowed (state check)
  3. GET: Show form pre-populated with current values
  4. POST: Validate form, update database, redirect with success message

Error Handling:

  • Profile locked: Flash error, redirect to dashboard
  • Form validation errors: Re-render form with field errors
  • Database errors: Rollback, flash error, re-render form

3.3 Update Preferences Route

Location: src/routes/participant.py

@participant_bp.route('/participant/preferences', methods=['POST'])
@participant_required
def update_preferences():
    """Update participant reminder email preferences.

    Returns:
        Redirect to dashboard
    """
    participant = g.participant

    from src.forms.participant import ReminderPreferenceForm
    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'))

Flow:

  1. Check participant authentication
  2. Validate CSRF token
  3. Update reminder preference
  4. Redirect to dashboard with status message

Design Note: No GET route - preference update is POST-only from dashboard form.

3.4 Withdraw Route

Location: src/routes/participant.py

@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
    """
    participant = g.participant
    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'))

    # Create withdrawal confirmation form
    from src.forms.participant import WithdrawForm
    form = WithdrawForm()

    if form.validate_on_submit():
        try:
            # Perform withdrawal
            from src.services.withdrawal import withdraw_participant, WithdrawalError
            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'))

        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
    )

Flow:

  1. Check authentication and withdrawal eligibility
  2. GET: Show confirmation page with warnings
  3. POST: Process withdrawal, send email, clear session, redirect

Security Notes:

  • Session cleared immediately after withdrawal (no orphaned sessions)
  • Redirect to public registration page (safe landing)
  • Email sent asynchronously (don't block on email failure)

4. Templates

4.1 Enhanced Dashboard Template

Location: templates/participant/dashboard.html

{% extends "layouts/participant.html" %}

{% block title %}{{ exchange.name }} - Dashboard{% endblock %}

{% block content %}
<div class="dashboard-container">
    <h1>{{ exchange.name }}</h1>

    <!-- Exchange Information -->
    <section class="exchange-info">
        <h2>Exchange Details</h2>
        <dl>
            <dt>Gift Budget:</dt>
            <dd>{{ exchange.budget }}</dd>

            <dt>Exchange Date:</dt>
            <dd>{{ exchange.exchange_date|format_datetime }}</dd>

            <dt>Status:</dt>
            <dd class="status-{{ exchange.state }}">
                {{ exchange.state|format_state }}
            </dd>
        </dl>
    </section>

    <!-- Your Profile -->
    <section class="profile-info">
        <h2>Your Profile</h2>
        <dl>
            <dt>Name:</dt>
            <dd>{{ participant.name }}</dd>

            <dt>Email:</dt>
            <dd>{{ participant.email }}</dd>

            <dt>Gift Ideas:</dt>
            <dd>{{ participant.gift_ideas or 'None provided' }}</dd>
        </dl>

        {% if can_edit_profile %}
        <a href="{{ url_for('participant.profile_edit') }}" class="button button-secondary">
            Edit Profile
        </a>
        {% endif %}
    </section>

    <!-- Reminder Preferences -->
    <section class="reminder-preferences">
        <h2>Email Reminders</h2>
        <form method="POST" action="{{ url_for('participant.update_preferences') }}">
            {{ reminder_form.hidden_tag() }}
            <div class="checkbox-field">
                {{ reminder_form.reminder_enabled() }}
                {{ reminder_form.reminder_enabled.label }}
            </div>
            {{ reminder_form.submit(class="button button-secondary") }}
        </form>
    </section>

    <!-- Participant List -->
    <section class="participant-list">
        <h2>Participants ({{ participant_count }})</h2>
        {% if participants %}
        <ul>
            {% for p in participants %}
            <li>
                {{ p.name }}
                {% if p.id == participant.id %}
                <span class="badge">You</span>
                {% endif %}
            </li>
            {% endfor %}
        </ul>
        {% else %}
        <p>No other participants yet. Share the registration link!</p>
        {% endif %}
    </section>

    <!-- Withdrawal Option -->
    {% if can_withdraw %}
    <section class="danger-zone">
        <h2>Withdraw from Exchange</h2>
        <p>
            If you can no longer participate, you can withdraw from this exchange.
            This cannot be undone.
        </p>
        <a href="{{ url_for('participant.withdraw') }}" class="button button-danger">
            Withdraw from Exchange
        </a>
    </section>
    {% endif %}
</div>
{% endblock %}

Design Notes:

  • Clear information hierarchy (exchange → profile → participants → actions)
  • Conditional rendering based on state (can_edit_profile, can_withdraw)
  • Inline reminder preference form (no separate page needed)
  • Participant list with "You" badge for current user
  • Danger zone styling for withdrawal (red/warning colors)

4.2 Profile Edit Template

Location: templates/participant/profile_edit.html

{% extends "layouts/participant.html" %}

{% block title %}Edit Profile - {{ exchange.name }}{% endblock %}

{% block content %}
<div class="profile-edit-container">
    <h1>Edit Your Profile</h1>

    <p class="help-text">
        Update your display name and gift ideas.
        Your Secret Santa will see this information after matching.
    </p>

    <form method="POST" action="{{ url_for('participant.profile_edit') }}">
        {{ form.hidden_tag() }}

        <div class="form-field">
            {{ form.name.label }}
            {{ form.name(class="form-control") }}
            {% if form.name.errors %}
            <ul class="field-errors">
                {% for error in form.name.errors %}
                <li>{{ error }}</li>
                {% endfor %}
            </ul>
            {% endif %}
            <small class="help-text">{{ form.name.description }}</small>
        </div>

        <div class="form-field">
            {{ form.gift_ideas.label }}
            {{ form.gift_ideas(class="form-control") }}
            {% if form.gift_ideas.errors %}
            <ul class="field-errors">
                {% for error in form.gift_ideas.errors %}
                <li>{{ error }}</li>
                {% endfor %}
            </ul>
            {% endif %}
            <small class="help-text">{{ form.gift_ideas.description }}</small>
            <small class="char-count" id="gift-ideas-count">
                {{ (form.gift_ideas.data or '')|length }} / 10,000 characters
            </small>
        </div>

        <div class="form-actions">
            {{ form.submit(class="button button-primary") }}
            <a href="{{ url_for('participant.dashboard') }}" class="button button-secondary">
                Cancel
            </a>
        </div>
    </form>
</div>

<script>
// Character counter for gift ideas (progressive enhancement)
document.addEventListener('DOMContentLoaded', function() {
    const textarea = document.querySelector('textarea[name="gift_ideas"]');
    const counter = document.getElementById('gift-ideas-count');

    if (textarea && counter) {
        textarea.addEventListener('input', function() {
            counter.textContent = this.value.length + ' / 10,000 characters';
        });
    }
});
</script>
{% endblock %}

UX Features:

  • Help text explains purpose
  • Field descriptions provide guidance
  • Character counter for gift ideas (client-side)
  • Clear error display
  • Cancel button returns to dashboard

4.3 Withdrawal Confirmation Template

Location: templates/participant/withdraw.html

{% extends "layouts/participant.html" %}

{% block title %}Withdraw from {{ exchange.name }}{% endblock %}

{% block content %}
<div class="withdraw-container">
    <h1>Withdraw from Exchange</h1>

    <div class="warning-box">
        <h2>⚠️ Are you sure?</h2>
        <p>Withdrawing from this exchange means:</p>
        <ul>
            <li>Your registration will be cancelled</li>
            <li>You will be removed from the participant list</li>
            <li>You cannot undo this action</li>
            <li>You will need to re-register with a different email to rejoin</li>
        </ul>
    </div>

    <form method="POST" action="{{ url_for('participant.withdraw') }}">
        {{ form.hidden_tag() }}

        <div class="form-field">
            <label class="checkbox-label">
                {{ form.confirm() }}
                {{ form.confirm.label.text }}
            </label>
            {% if form.confirm.errors %}
            <ul class="field-errors">
                {% for error in form.confirm.errors %}
                <li>{{ error }}</li>
                {% endfor %}
            </ul>
            {% endif %}
        </div>

        <div class="form-actions">
            {{ form.submit(class="button button-danger") }}
            <a href="{{ url_for('participant.dashboard') }}" class="button button-secondary">
                Cancel
            </a>
        </div>
    </form>
</div>
{% endblock %}

Design Notes:

  • Prominent warning box with icon
  • Clear list of consequences
  • Required confirmation checkbox
  • Danger-styled submit button (red)
  • Easy cancel option

5. Email Template

5.1 Withdrawal Confirmation Email

Location: templates/emails/participant/withdrawal_confirmation.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Withdrawal Confirmation</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
    <div style="max-width: 600px; margin: 0 auto; padding: 20px;">
        <h1 style="color: #e74c3c;">Withdrawal Confirmed</h1>

        <p>Hello {{ participant.name }},</p>

        <p>
            This email confirms that you have withdrawn from the Secret Santa exchange
            <strong>{{ exchange.name }}</strong>.
        </p>

        <div style="background-color: #f8f9fa; border-left: 4px solid #e74c3c; padding: 15px; margin: 20px 0;">
            <p style="margin: 0;">
                <strong>What happens now:</strong>
            </p>
            <ul style="margin: 10px 0;">
                <li>You have been removed from the participant list</li>
                <li>Your profile information has been archived</li>
                <li>You will not receive further emails about this exchange</li>
            </ul>
        </div>

        <p>
            If you withdrew by mistake, you can re-register using a different email address
            while registration is still open.
        </p>

        <p>
            If you have any questions, please contact the exchange organizer.
        </p>

        <hr style="border: none; border-top: 1px solid #ddd; margin: 30px 0;">

        <p style="font-size: 12px; color: #666;">
            This is an automated message from Sneaky Klaus.
        </p>
    </div>
</body>
</html>

Plain Text Version: templates/emails/participant/withdrawal_confirmation.txt

Withdrawal Confirmed

Hello {{ participant.name }},

This email confirms that you have withdrawn from the Secret Santa exchange "{{ exchange.name }}".

What happens now:
- You have been removed from the participant list
- Your profile information has been archived
- You will not receive further emails about this exchange

If you withdrew by mistake, you can re-register using a different email address while registration is still open.

If you have any questions, please contact the exchange organizer.

---
This is an automated message from Sneaky Klaus.

5.2 Email Service Update

Location: src/services/email.py (add method)

def send_withdrawal_confirmation(self, participant: Participant) -> None:
    """Send withdrawal confirmation email to participant.

    Args:
        participant: The participant who withdrew

    Raises:
        EmailError: If email send fails
    """
    exchange = participant.exchange

    # Render email templates
    html_body = render_template(
        'emails/participant/withdrawal_confirmation.html',
        participant=participant,
        exchange=exchange
    )
    text_body = render_template(
        'emails/participant/withdrawal_confirmation.txt',
        participant=participant,
        exchange=exchange
    )

    # Send email
    try:
        resend.Emails.send({
            "from": self.from_email,
            "to": [participant.email],
            "subject": f"Withdrawal Confirmed - {exchange.name}",
            "html": html_body,
            "text": text_body,
        })

        logger.info(f"Withdrawal confirmation sent to {participant.email}")

    except Exception as e:
        logger.error(f"Failed to send withdrawal email: {e}")
        raise EmailError(f"Failed to send withdrawal confirmation: {e}")

6. Testing Specifications

6.1 Unit Tests

Location: tests/unit/test_participant_utils.py (new file)

"""Unit tests for participant utility functions."""
import pytest
from datetime import datetime
from src.utils.participant import can_update_profile, can_withdraw, is_withdrawn


def test_can_update_profile_allowed_states(participant_factory, exchange_factory):
    """Test profile updates allowed in pre-matching states."""
    for state in ['draft', 'registration_open', 'registration_closed']:
        exchange = exchange_factory(state=state)
        participant = participant_factory(exchange=exchange)

        assert can_update_profile(participant) is True


def test_can_update_profile_disallowed_states(participant_factory, exchange_factory):
    """Test profile updates blocked after matching."""
    for state in ['matched', 'completed']:
        exchange = exchange_factory(state=state)
        participant = participant_factory(exchange=exchange)

        assert can_update_profile(participant) is False


def test_can_withdraw_allowed_states(participant_factory, exchange_factory):
    """Test withdrawal allowed before registration closes."""
    for state in ['draft', 'registration_open']:
        exchange = exchange_factory(state=state)
        participant = participant_factory(exchange=exchange)

        assert can_withdraw(participant) is True


def test_can_withdraw_disallowed_states(participant_factory, exchange_factory):
    """Test withdrawal blocked after registration closes."""
    for state in ['registration_closed', 'matched', 'completed']:
        exchange = exchange_factory(state=state)
        participant = participant_factory(exchange=exchange)

        assert can_withdraw(participant) is False


def test_can_withdraw_already_withdrawn(participant_factory):
    """Test withdrawal blocked if already withdrawn."""
    participant = participant_factory(withdrawn_at=datetime.utcnow())

    assert can_withdraw(participant) is False

6.2 Integration Tests

Location: tests/integration/test_participant_self_management.py (new file)

"""Integration tests for participant self-management features."""
import pytest
from flask import url_for


class TestProfileUpdate:
    """Tests for profile update functionality."""

    def test_profile_update_success(self, client, participant_session, db):
        """Test successful profile update."""
        response = client.post(
            url_for('participant.profile_edit'),
            data={
                'name': 'Updated Name',
                'gift_ideas': 'Updated gift ideas',
                'csrf_token': get_csrf_token(client)
            },
            follow_redirects=True
        )

        assert response.status_code == 200
        assert b'profile has been updated' in response.data

        # Verify database updated
        participant = Participant.query.get(participant_session['user_id'])
        assert participant.name == 'Updated Name'
        assert participant.gift_ideas == 'Updated gift ideas'

    def test_profile_update_locked_after_matching(
        self, client, participant_session, db, exchange_factory
    ):
        """Test profile update blocked after matching."""
        # Set exchange to matched state
        participant = Participant.query.get(participant_session['user_id'])
        participant.exchange.state = 'matched'
        db.session.commit()

        response = client.get(url_for('participant.profile_edit'))

        assert response.status_code == 302  # Redirect
        # Follow redirect
        response = client.get(url_for('participant.profile_edit'), follow_redirects=True)
        assert b'profile is locked' in response.data


class TestWithdrawal:
    """Tests for withdrawal functionality."""

    def test_withdrawal_success(self, client, participant_session, db, mock_email):
        """Test successful withdrawal."""
        participant_id = participant_session['user_id']

        response = client.post(
            url_for('participant.withdraw'),
            data={
                'confirm': True,
                'csrf_token': get_csrf_token(client)
            },
            follow_redirects=True
        )

        assert response.status_code == 200
        assert b'withdrawn from the exchange' in response.data

        # Verify database updated
        participant = Participant.query.get(participant_id)
        assert participant.withdrawn_at is not None

        # Verify session cleared
        with client.session_transaction() as session:
            assert 'user_id' not in session

        # Verify email sent
        mock_email.send_withdrawal_confirmation.assert_called_once()

    def test_withdrawal_blocked_after_close(
        self, client, participant_session, db
    ):
        """Test withdrawal blocked after registration closes."""
        participant = Participant.query.get(participant_session['user_id'])
        participant.exchange.state = 'registration_closed'
        db.session.commit()

        response = client.get(url_for('participant.withdraw'), follow_redirects=True)

        assert b'Registration has closed' in response.data

7. Security Checklist

  • All routes require @participant_required authentication
  • CSRF protection on all POST operations (WTForms)
  • State validation before allowing operations
  • Input sanitization (WTForms validators + Jinja2 auto-escaping)
  • Session cleared on withdrawal (no orphaned sessions)
  • Withdrawn participants excluded from participant list (privacy)
  • No email enumeration (withdrawn status not revealed to other participants)
  • Database rollback on errors (transaction safety)

8. Performance Checklist

  • Participant list loaded with single query (no N+1)
  • State checks use in-memory objects (no extra DB hits)
  • Email sent asynchronously (doesn't block response)
  • Form pre-population uses ORM objects (efficient)
  • No caching needed (small datasets, infrequent updates)

9. Accessibility Checklist

  • Form labels properly associated with inputs
  • Error messages linked to fields (ARIA)
  • Warning boxes use semantic HTML
  • Buttons have descriptive text (no icon-only)
  • Character counter is non-critical (progressive enhancement)
  • All actions keyboard-accessible

End of Component Design