- 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>
1270 lines
36 KiB
Markdown
1270 lines
36 KiB
Markdown
# 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
|
|
<!-- 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)
|
|
|
|
```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 %}
|
|
<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)
|
|
|
|
```html
|
|
<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`:
|
|
|
|
```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
|
|
<!-- 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:
|
|
|
|
```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
|
|
<!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)
|
|
|
|
```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 %}
|
|
<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)
|
|
|
|
```html
|
|
<!-- 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:
|
|
|
|
```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 <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.
|