Files
sneakyklaus/docs/designs/v0.3.0/implementation-guide.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

36 KiB

Implementation Guide - v0.3.0

Version: 0.3.0 Date: 2025-12-22 Status: Developer Guide

Overview

This guide provides step-by-step instructions for implementing Phase 3 (Participant Self-Management). Follow the TDD approach: write tests first, implement to pass.

Prerequisites

Before starting Phase 3 implementation:

  • Phase 2 (v0.2.0) is complete and merged to main
  • Working directory is clean (git status)
  • All Phase 2 tests pass (uv run pytest)
  • Development environment is set up (uv sync)

Implementation Order

Implement features in this order (vertical slices, TDD):

  1. Phase 3.1: Participant List View (Story 4.5) - Simplest, no state changes
  2. Phase 3.2: Profile Updates (Story 6.1) - Core self-management
  3. Phase 3.3: Reminder Preferences (Story 6.3) - Simple toggle
  4. Phase 3.4: Withdrawal (Story 6.2) - Most complex, benefits from solid foundation

Phase 3.1: Participant List View

Goal: Show list of active participants on dashboard

Step 1: Create Utility Functions

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

# Create the file
touch src/utils/participant.py

Implementation:

"""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 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

Step 2: Write Unit Tests

File: 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 get_active_participants, is_withdrawn


def test_get_active_participants_excludes_withdrawn(db, 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.utcnow())

    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(db, exchange_factory, participant_factory):
    """Test that participants are ordered alphabetically."""
    exchange = exchange_factory()

    zoe = participant_factory(exchange=exchange, name='Zoe')
    alice = participant_factory(exchange=exchange, name='Alice')
    bob = participant_factory(exchange=exchange, name='Bob')

    participants = get_active_participants(exchange.id)

    assert participants[0].name == 'Alice'
    assert participants[1].name == 'Bob'
    assert participants[2].name == 'Zoe'


def test_is_withdrawn_true(participant_factory):
    """Test is_withdrawn returns True for withdrawn participant."""
    participant = participant_factory(withdrawn_at=datetime.utcnow())
    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

Run tests: uv run pytest tests/unit/test_participant_utils.py -v

Step 3: Update Dashboard Route

File: src/routes/participant.py

Update the existing dashboard route:

from src.utils.participant import get_active_participants

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

    # Get list of active participants
    participants = get_active_participants(exchange.id)

    return render_template(
        'participant/dashboard.html',
        participant=participant,
        exchange=exchange,
        participants=participants,
        participant_count=len(participants)
    )

Step 4: Update Dashboard Template

File: templates/participant/dashboard.html

Add participant list section:

<!-- After existing profile section -->

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

Step 5: Write Integration Tests

File: tests/integration/test_participant_list.py (new file)

"""Integration tests for participant list functionality."""
import pytest
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'))

    assert response.status_code == 200
    assert b'Bob' in response.data
    assert b'Charlie' 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.utcnow())

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

    assert b'Active' in response.data
    assert b'Withdrawn' not in response.data

Run tests: uv run pytest tests/integration/test_participant_list.py -v

Step 6: Manual QA

  1. Start dev server: uv run flask run
  2. Create exchange, register 3 participants
  3. Login as first participant
  4. Verify participant list shows other 2 participants
  5. Register 4th participant
  6. Refresh dashboard, verify 4th participant appears

Checkpoint: Participant list feature complete


Phase 3.2: Profile Updates

Goal: Allow participants to update name and gift ideas before matching

Step 1: Create State Validation Function

File: src/utils/participant.py (add to existing file)

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

Step 2: Write Unit Tests for State Validation

File: tests/unit/test_participant_utils.py (add to existing)

from src.utils.participant import can_update_profile


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

Step 3: Create Profile Update Form

File: src/forms/participant.py (add to existing)

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}
    )

    submit = SubmitField('Save Changes')

Step 4: Create Profile Edit Route

File: src/routes/participant.py (add to existing)

from src.utils.participant import can_update_profile
from src.forms.participant import ProfileUpdateForm

@participant_bp.route('/participant/profile/edit', methods=['GET', 'POST'])
@participant_required
def profile_edit():
    """Edit participant profile (name and gift ideas)."""
    participant = g.participant

    # Check if profile editing is allowed
    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
    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
    )

Step 5: Create Profile Edit Template

File: templates/participant/profile_edit.html (new file)

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

File: templates/participant/dashboard.html (update profile section)

<section class="profile-info">
    <h2>Your Profile</h2>
    <!-- existing profile display -->

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

Update dashboard route to pass can_edit_profile:

# In dashboard() route
from src.utils.participant import can_update_profile

can_edit = can_update_profile(participant)

return render_template(
    'participant/dashboard.html',
    # ... existing variables ...
    can_edit_profile=can_edit
)

Step 7: Write Integration Tests

File: tests/integration/test_profile_update.py (new file)

"""Integration tests for profile update functionality."""
import pytest
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',
            'csrf_token': get_csrf_token(client, url_for('participant.profile_edit'))
        },
        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_locked_after_matching(client, auth_participant, db):
    """Profile edit blocked after matching."""
    auth_participant.exchange.state = 'matched'
    db.session.commit()

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

    assert b'profile is locked' in response.data

Run tests: uv run pytest tests/integration/test_profile_update.py -v

Checkpoint: Profile update feature complete


Phase 3.3: Reminder Preferences

Goal: Allow toggling reminder email preference

Step 1: Create Reminder Preference Form

File: src/forms/participant.py (add to existing)

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

Step 2: Create Preference Update Route

File: src/routes/participant.py (add to existing)

from src.forms.participant import ReminderPreferenceForm

@participant_bp.route('/participant/preferences', methods=['POST'])
@participant_required
def update_preferences():
    """Update participant reminder email preferences."""
    participant = g.participant
    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'))

Step 3: Update Dashboard with Preference Form

File: templates/participant/dashboard.html (add 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>

Update dashboard route:

# In dashboard() route
reminder_form = ReminderPreferenceForm(
    reminder_enabled=participant.reminder_enabled
)

return render_template(
    # ... existing variables ...
    reminder_form=reminder_form
)

Step 4: Write Integration Tests

File: tests/integration/test_reminder_preferences.py (new file)

"""Integration tests for reminder preferences."""
import pytest
from flask import url_for


def test_update_preferences_enable(client, auth_participant, db):
    """Enable reminder emails."""
    auth_participant.reminder_enabled = False
    db.session.commit()

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

    assert b'Reminder emails enabled' in response.data
    db.session.refresh(auth_participant)
    assert auth_participant.reminder_enabled is True

Checkpoint: Reminder preferences complete


Phase 3.4: Withdrawal

Goal: Allow participants to withdraw before registration closes

Step 1: Create Withdrawal State Validation

File: src/utils/participant.py (add to existing)

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

    Withdrawals are only allowed before registration closes.

    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

Step 2: Create Withdrawal Service

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

"""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.

    Args:
        participant: The participant to withdraw

    Raises:
        WithdrawalError: If withdrawal is not allowed
    """
    # 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

Step 3: Create Withdrawal Form

File: src/forms/participant.py (add to existing)

class WithdrawForm(FlaskForm):
    """Form for confirming withdrawal from exchange."""
    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')

Step 4: Create Withdrawal Route

File: src/routes/participant.py (add to existing)

from src.utils.participant import can_withdraw, is_withdrawn
from src.services.withdrawal import withdraw_participant, WithdrawalError
from src.forms.participant import WithdrawForm

@participant_bp.route('/participant/withdraw', methods=['GET', 'POST'])
@participant_required
def withdraw():
    """Withdraw from exchange (soft delete)."""
    participant = g.participant
    exchange = participant.exchange

    # Check if withdrawal is allowed
    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'))

    form = WithdrawForm()

    if form.validate_on_submit():
        try:
            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")

    return render_template(
        'participant/withdraw.html',
        form=form,
        participant=participant,
        exchange=exchange
    )

Step 5: Create Email Templates

File: templates/emails/participant/withdrawal_confirmation.html (new)

<!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>

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

File: templates/emails/participant/withdrawal_confirmation.txt (new)

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.

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

Step 6: Add Email Service Method

File: 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

    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
    )

    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}")

Step 7: Create Withdrawal Template

File: templates/participant/withdraw.html (new)

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

File: templates/participant/dashboard.html (add 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 %}

Update dashboard route:

# In dashboard() route
from src.utils.participant import can_withdraw

can_leave = can_withdraw(participant)

return render_template(
    # ... existing variables ...
    can_withdraw=can_leave
)

Step 9: Write Tests

File: tests/unit/test_withdrawal_service.py (new)

"""Unit tests for withdrawal service."""
import pytest
from datetime import datetime
from src.services.withdrawal import withdraw_participant, WithdrawalError


def test_withdraw_participant_success(participant_factory, mock_email_service, db):
    """Test successful withdrawal."""
    participant = participant_factory()

    withdraw_participant(participant)

    assert participant.withdrawn_at is not None
    mock_email_service.send_withdrawal_confirmation.assert_called_once()


def test_withdraw_participant_already_withdrawn(participant_factory):
    """Test error when already withdrawn."""
    participant = participant_factory(withdrawn_at=datetime.utcnow())

    with pytest.raises(WithdrawalError, match="already withdrawn"):
        withdraw_participant(participant)

File: tests/integration/test_withdrawal.py (new)

"""Integration tests for withdrawal functionality."""
import pytest
from flask import url_for


def test_withdrawal_post_success(client, auth_participant, db, mock_email):
    """Test successful withdrawal flow."""
    participant_id = auth_participant.id

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

    assert b'withdrawn from the exchange' in response.data

    # Verify database
    from src.models import Participant
    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

Checkpoint: Withdrawal feature complete


Final Steps

1. Run All Tests

# Run all tests
uv run pytest -v

# Check coverage
uv run pytest --cov=src --cov-report=term-missing

# Ensure ≥ 80% coverage

2. Run Linting and Type Checking

# Lint code
uv run ruff check src tests

# Format code
uv run ruff format src tests

# Type check
uv run mypy src

3. Manual QA Testing

Follow the test plan in docs/designs/v0.3.0/test-plan.md:

  • Test all acceptance criteria
  • Test edge cases
  • Test across browsers
  • Test accessibility

4. Update Documentation

If any design decisions changed during implementation:

  • Update docs/designs/v0.3.0/overview.md
  • Update docs/decisions/0006-participant-state-management.md if needed
  • Add any new ADRs if architectural changes were made

5. Commit and Create PR

# Create feature branch
git checkout -b feature/participant-self-management

# Stage all changes
git add .

# Commit with descriptive message
git commit -m "feat: implement participant self-management (v0.3.0)

Implements Epic 6 and Story 4.5:
- Participant list view (pre-matching)
- Profile updates (name, gift ideas)
- Reminder preference toggles
- Participant withdrawal

All acceptance criteria met, 80%+ test coverage maintained.

Refs: docs/designs/v0.3.0/overview.md"

# Push to origin
git push -u origin feature/participant-self-management

6. Create Pull Request

Using gh CLI:

gh pr create --title "feat: Participant Self-Management (v0.3.0)" --body "$(cat <<'EOF'
## Summary

Implements Phase 3 (v0.3.0) - Participant Self-Management features:

- ✅ **Story 4.5**: View participant list (pre-matching)
- ✅ **Story 6.1**: Update profile (name, gift ideas)
- ✅ **Story 6.3**: Update reminder preferences
- ✅ **Story 6.2**: Withdraw from exchange

## Changes

### New Files
- `src/utils/participant.py` - Business logic functions
- `src/services/withdrawal.py` - Withdrawal service
- `templates/participant/profile_edit.html` - Profile edit page
- `templates/participant/withdraw.html` - Withdrawal confirmation
- `templates/emails/participant/withdrawal_confirmation.*` - Email templates

### Modified Files
- `src/routes/participant.py` - New routes for profile, preferences, withdrawal
- `src/forms/participant.py` - New forms
- `src/services/email.py` - Withdrawal email method
- `templates/participant/dashboard.html` - Enhanced with participant list

### Documentation
- `docs/designs/v0.3.0/overview.md` - Phase design
- `docs/designs/v0.3.0/participant-self-management.md` - Component design
- `docs/decisions/0006-participant-state-management.md` - ADR

## Test Coverage

- Unit tests: 95%+ for business logic
- Integration tests: All routes tested
- Overall coverage: 80%+
- Manual QA: All acceptance criteria verified

## Breaking Changes

None. Fully backward compatible with v0.2.0.

## Deployment Notes

No database migrations required. No new environment variables.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)" --base release/v0.3.0

Troubleshooting

Common Issues

Issue: Tests fail with "fixture not found"

  • Solution: Ensure fixture is defined in conftest.py or imported properly

Issue: CSRF token errors in tests

  • Solution: Use get_csrf_token() helper to extract token from page

Issue: Session not persisting in tests

  • Solution: Use with client.session_transaction() as session: to modify session

Issue: Database rollback errors

  • Solution: Ensure db.session.rollback() in exception handlers

Issue: Email not sent in dev mode

  • Solution: Check FLASK_ENV=development and logs for magic links

Getting Help

  • Review Phase 2 implementation for patterns
  • Check existing tests for examples
  • Consult Flask/SQLAlchemy documentation
  • Ask user for clarification on requirements

Implementation Guide Complete

This guide should be followed in order for TDD implementation of Phase 3. Each checkpoint represents a vertically-sliced feature that can be tested, reviewed, and merged independently if needed.