- 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>
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):
- Phase 3.1: Participant List View (Story 4.5) - Simplest, no state changes
- Phase 3.2: Profile Updates (Story 6.1) - Core self-management
- Phase 3.3: Reminder Preferences (Story 6.3) - Simple toggle
- 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
- Start dev server:
uv run flask run - Create exchange, register 3 participants
- Login as first participant
- Verify participant list shows other 2 participants
- Register 4th participant
- 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 %}
Step 6: Update Dashboard to Show Edit Link
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 %}
Step 8: Update Dashboard with Withdraw Link
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.mdif 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.pyor 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=developmentand 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.