# 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)
```bash
# Create the file
touch src/utils/participant.py
```
**Implementation**:
```python
"""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)
```python
"""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:
```python
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:
```html
Participants ({{ participant_count }})
{% if participants %}
{% for p in participants %}
-
{{ p.name }}
{% if p.id == participant.id %}
You
{% endif %}
{% endfor %}
{% else %}
No other participants yet. Share the registration link!
{% endif %}
```
### Step 5: Write Integration Tests
**File**: `tests/integration/test_participant_list.py` (new file)
```python
"""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)
```python
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)
```python
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)
```python
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)
```python
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)
```html
{% extends "layouts/participant.html" %}
{% block title %}Edit Profile - {{ exchange.name }}{% endblock %}
{% block content %}
Edit Your Profile
Update your display name and gift ideas.
Your Secret Santa will see this information after matching.
{% endblock %}
```
### Step 6: Update Dashboard to Show Edit Link
**File**: `templates/participant/dashboard.html` (update profile section)
```html
Your Profile
{% if can_edit_profile %}
Edit Profile
{% endif %}
```
Update dashboard route to pass `can_edit_profile`:
```python
# 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)
```python
"""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)
```python
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)
```python
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)
```html
```
Update dashboard route:
```python
# 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)
```python
"""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)
```python
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)
```python
"""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)
```python
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)
```python
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)
```html
Withdrawal Confirmation
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.
```
**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)
```python
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)
```html
{% extends "layouts/participant.html" %}
{% block title %}Withdraw from {{ exchange.name }}{% endblock %}
{% block content %}
Withdraw from Exchange
⚠️ Are you sure?
Withdrawing from this exchange means:
- Your registration will be cancelled
- You will be removed from the participant list
- You cannot undo this action
- You will need to re-register with a different email to rejoin
{% endblock %}
```
### Step 8: Update Dashboard with Withdraw Link
**File**: `templates/participant/dashboard.html` (add section)
```html
{% if can_withdraw %}
Withdraw from Exchange
If you can no longer participate, you can withdraw from this exchange.
This cannot be undone.
Withdraw from Exchange
{% endif %}
```
Update dashboard route:
```python
# 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)
```python
"""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)
```python
"""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
```bash
# 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
```bash
# 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
```bash
# 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:
```bash
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
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.