- 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>
34 KiB
Participant Self-Management Component Design
Version: 0.3.0 Date: 2025-12-22 Status: Component Design
Overview
This document provides detailed component specifications for participant self-management features in Phase 3. It covers profile updates, withdrawals, reminder preferences, and the participant list view.
Component Architecture
graph TB
subgraph "Presentation Layer"
Dashboard[Participant Dashboard]
ProfileEdit[Profile Edit Page]
WithdrawPage[Withdraw Confirmation Page]
end
subgraph "Forms Layer"
ProfileForm[ProfileUpdateForm]
ReminderForm[ReminderPreferenceForm]
WithdrawForm[WithdrawForm]
end
subgraph "Route Layer"
DashboardRoute[dashboard()]
ProfileEditRoute[profile_edit()]
UpdatePrefsRoute[update_preferences()]
WithdrawRoute[withdraw()]
end
subgraph "Business Logic"
StateChecks[State Validation Functions]
WithdrawService[Withdrawal Service]
end
subgraph "Data Layer"
ParticipantModel[Participant Model]
ExchangeModel[Exchange Model]
end
Dashboard --> DashboardRoute
ProfileEdit --> ProfileEditRoute
WithdrawPage --> WithdrawRoute
ProfileEditRoute --> ProfileForm
UpdatePrefsRoute --> ReminderForm
WithdrawRoute --> WithdrawForm
DashboardRoute --> StateChecks
ProfileEditRoute --> StateChecks
WithdrawRoute --> StateChecks
WithdrawRoute --> WithdrawService
StateChecks --> ParticipantModel
StateChecks --> ExchangeModel
WithdrawService --> ParticipantModel
1. Business Logic Functions
1.1 State Validation Functions
Location: src/utils/participant.py (new file)
These functions encapsulate business rules for participant operations:
"""Participant business logic utilities."""
from datetime import datetime
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from src.models import Participant
def can_update_profile(participant: 'Participant') -> bool:
"""Check if participant can update their profile.
Profile updates are allowed until matching occurs.
Args:
participant: The participant to check
Returns:
True if profile updates are allowed, False otherwise
"""
exchange = participant.exchange
allowed_states = ['draft', 'registration_open', 'registration_closed']
return exchange.state in allowed_states
def can_withdraw(participant: 'Participant') -> bool:
"""Check if participant can withdraw from the exchange.
Withdrawals are only allowed before registration closes.
After that, admin intervention is required.
Args:
participant: The participant to check
Returns:
True if withdrawal is allowed, False otherwise
"""
# Already withdrawn
if participant.withdrawn_at is not None:
return False
exchange = participant.exchange
allowed_states = ['draft', 'registration_open']
return exchange.state in allowed_states
def get_active_participants(exchange_id: int) -> list['Participant']:
"""Get all active (non-withdrawn) participants for an exchange.
Args:
exchange_id: ID of the exchange
Returns:
List of active participants, ordered by name
"""
from src.models import Participant
return Participant.query.filter(
Participant.exchange_id == exchange_id,
Participant.withdrawn_at.is_(None)
).order_by(Participant.name).all()
def is_withdrawn(participant: 'Participant') -> bool:
"""Check if participant has withdrawn.
Args:
participant: The participant to check
Returns:
True if withdrawn, False otherwise
"""
return participant.withdrawn_at is not None
Design Rationale:
- Centralize business logic for reuse across routes and templates
- Make state transition rules explicit and testable
- Simplify route handlers (delegate to pure functions)
- Enable easy rule changes without touching routes
1.2 Withdrawal Service
Location: src/services/withdrawal.py (new file)
Encapsulates the withdrawal process:
"""Participant withdrawal service."""
from datetime import datetime
from flask import current_app
from src.models import Participant, db
from src.services.email import EmailService
from src.utils.participant import can_withdraw
class WithdrawalError(Exception):
"""Raised when withdrawal operation fails."""
pass
def withdraw_participant(participant: Participant) -> None:
"""Withdraw a participant from their exchange.
This performs a soft delete by setting withdrawn_at timestamp.
Participant record is retained for audit trail.
Args:
participant: The participant to withdraw
Raises:
WithdrawalError: If withdrawal is not allowed
Side effects:
- Sets participant.withdrawn_at to current UTC time
- Commits database transaction
- Sends withdrawal confirmation email
"""
# Validate withdrawal is allowed
if not can_withdraw(participant):
if participant.withdrawn_at is not None:
raise WithdrawalError("You have already withdrawn from this exchange.")
exchange = participant.exchange
if exchange.state == 'registration_closed':
raise WithdrawalError(
"Registration has closed. Please contact the admin to withdraw."
)
elif exchange.state in ['matched', 'completed']:
raise WithdrawalError(
"Matching has already occurred. Please contact the admin."
)
else:
raise WithdrawalError("Withdrawal is not allowed at this time.")
# Perform withdrawal
participant.withdrawn_at = datetime.utcnow()
try:
db.session.commit()
current_app.logger.info(
f"Participant {participant.id} withdrawn from exchange {participant.exchange_id}"
)
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Failed to withdraw participant: {e}")
raise WithdrawalError("Failed to process withdrawal. Please try again.")
# Send confirmation email
try:
email_service = EmailService()
email_service.send_withdrawal_confirmation(participant)
except Exception as e:
current_app.logger.error(f"Failed to send withdrawal email: {e}")
# Don't raise - withdrawal already committed
Design Rationale:
- Encapsulate complex multi-step operation (validate, update DB, send email)
- Provide clear error messages for different failure scenarios
- Separate concerns: business logic from route handling
- Log all withdrawal events for debugging and audit
- Gracefully handle email failures (withdrawal is primary operation)
2. Forms
2.1 ProfileUpdateForm
Location: src/forms/participant.py (enhance existing file)
from wtforms import StringField, TextAreaField, SubmitField
from wtforms.validators import DataRequired, Length
from flask_wtf import FlaskForm
class ProfileUpdateForm(FlaskForm):
"""Form for updating participant profile.
Allows editing name and gift ideas before matching occurs.
"""
name = StringField(
'Name',
validators=[
DataRequired(message="Name is required"),
Length(min=1, max=255, message="Name must be 1-255 characters")
],
description="Your display name (visible to other participants)"
)
gift_ideas = TextAreaField(
'Gift Ideas',
validators=[
Length(max=10000, message="Gift ideas must be less than 10,000 characters")
],
description="Optional wishlist or gift preferences for your Secret Santa",
render_kw={"rows": 6, "maxlength": 10000}
)
submit = SubmitField('Save Changes')
Validation Rules:
- Name: Required, 1-255 characters
- Gift ideas: Optional, max 10,000 characters
- CSRF token: Automatic (FlaskForm)
UX Enhancements:
- Maxlength attribute provides browser-level hint
- Rows attribute sizes textarea appropriately
- Description text provides context
2.2 ReminderPreferenceForm
Location: src/forms/participant.py
from wtforms import BooleanField, SubmitField
from flask_wtf import FlaskForm
class ReminderPreferenceForm(FlaskForm):
"""Form for updating reminder email preferences."""
reminder_enabled = BooleanField(
'Send me reminder emails before the exchange date',
description="You can change this at any time"
)
submit = SubmitField('Update Preferences')
Validation Rules:
- No validation needed (boolean field)
- CSRF token: Automatic
UX Note: Form pre-populates with current preference value.
2.3 WithdrawForm
Location: src/forms/participant.py
from wtforms import BooleanField, SubmitField
from wtforms.validators import DataRequired
from flask_wtf import FlaskForm
class WithdrawForm(FlaskForm):
"""Form for confirming withdrawal from exchange.
Requires explicit confirmation to prevent accidental withdrawals.
"""
confirm = BooleanField(
'I understand this cannot be undone and I will need to re-register to rejoin',
validators=[
DataRequired(message="You must confirm to withdraw")
]
)
submit = SubmitField('Withdraw from Exchange')
Validation Rules:
- Confirmation: Must be checked (DataRequired on BooleanField)
- CSRF token: Automatic
Design Rationale:
- Explicit confirmation prevents accidental clicks
- Clear warning about consequences
- Single-step process (no "are you sure?" modal)
3. Routes
3.1 Enhanced Dashboard Route
Location: src/routes/participant.py
@participant_bp.route('/participant/dashboard')
@participant_required
def dashboard():
"""Participant dashboard showing exchange info and participant list.
Requires authentication as participant.
Returns:
Rendered dashboard template
"""
participant = g.participant
exchange = participant.exchange
# Get list of active participants
from src.utils.participant import get_active_participants
participants = get_active_participants(exchange.id)
# Check available actions
from src.utils.participant import can_update_profile, can_withdraw
can_edit = can_update_profile(participant)
can_leave = can_withdraw(participant)
# Create reminder preference form
from src.forms.participant import ReminderPreferenceForm
reminder_form = ReminderPreferenceForm(
reminder_enabled=participant.reminder_enabled
)
return render_template(
'participant/dashboard.html',
participant=participant,
exchange=exchange,
participants=participants,
participant_count=len(participants),
can_edit_profile=can_edit,
can_withdraw=can_leave,
reminder_form=reminder_form
)
Template Variables:
participant: Current participant objectexchange: Associated exchange objectparticipants: List of active participants (for participant list)participant_count: Number of active participantscan_edit_profile: Boolean flag for showing edit buttoncan_withdraw: Boolean flag for showing withdraw buttonreminder_form: Pre-populated reminder preference form
3.2 Profile Edit Route
Location: src/routes/participant.py
@participant_bp.route('/participant/profile/edit', methods=['GET', 'POST'])
@participant_required
def profile_edit():
"""Edit participant profile (name and gift ideas).
Only allowed before matching occurs.
Returns:
GET: Profile edit form
POST: Redirect to dashboard on success, or re-render form on error
"""
participant = g.participant
# Check if profile editing is allowed
from src.utils.participant import can_update_profile
if not can_update_profile(participant):
flash(
"Your profile is locked after matching. Contact the admin for changes.",
"error"
)
return redirect(url_for('participant.dashboard'))
# Create form with current values
from src.forms.participant import ProfileUpdateForm
form = ProfileUpdateForm(obj=participant)
if form.validate_on_submit():
try:
# Update participant
participant.name = form.name.data.strip()
participant.gift_ideas = form.gift_ideas.data.strip() if form.gift_ideas.data else None
db.session.commit()
flash("Your profile has been updated successfully.", "success")
return redirect(url_for('participant.dashboard'))
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Failed to update participant profile: {e}")
flash("Failed to update profile. Please try again.", "error")
return render_template(
'participant/profile_edit.html',
form=form,
participant=participant,
exchange=participant.exchange
)
Flow:
- Check participant authentication (@participant_required)
- Verify profile editing is allowed (state check)
- GET: Show form pre-populated with current values
- POST: Validate form, update database, redirect with success message
Error Handling:
- Profile locked: Flash error, redirect to dashboard
- Form validation errors: Re-render form with field errors
- Database errors: Rollback, flash error, re-render form
3.3 Update Preferences Route
Location: src/routes/participant.py
@participant_bp.route('/participant/preferences', methods=['POST'])
@participant_required
def update_preferences():
"""Update participant reminder email preferences.
Returns:
Redirect to dashboard
"""
participant = g.participant
from src.forms.participant import ReminderPreferenceForm
form = ReminderPreferenceForm()
if form.validate_on_submit():
try:
participant.reminder_enabled = form.reminder_enabled.data
db.session.commit()
if form.reminder_enabled.data:
flash("Reminder emails enabled.", "success")
else:
flash("Reminder emails disabled.", "success")
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Failed to update preferences: {e}")
flash("Failed to update preferences. Please try again.", "error")
else:
flash("Invalid request.", "error")
return redirect(url_for('participant.dashboard'))
Flow:
- Check participant authentication
- Validate CSRF token
- Update reminder preference
- Redirect to dashboard with status message
Design Note: No GET route - preference update is POST-only from dashboard form.
3.4 Withdraw Route
Location: src/routes/participant.py
@participant_bp.route('/participant/withdraw', methods=['GET', 'POST'])
@participant_required
def withdraw():
"""Withdraw from exchange (soft delete).
GET: Show confirmation page with warnings
POST: Process withdrawal, log out, redirect to public page
Returns:
GET: Withdrawal confirmation page
POST: Redirect to exchange registration page
"""
participant = g.participant
exchange = participant.exchange
# Check if withdrawal is allowed
from src.utils.participant import can_withdraw, is_withdrawn
if is_withdrawn(participant):
flash("You have already withdrawn from this exchange.", "info")
return redirect(url_for('participant.register', slug=exchange.slug))
if not can_withdraw(participant):
if exchange.state == 'registration_closed':
message = "Registration has closed. Please contact the admin to withdraw."
else:
message = "Withdrawal is no longer available. Please contact the admin."
flash(message, "error")
return redirect(url_for('participant.dashboard'))
# Create withdrawal confirmation form
from src.forms.participant import WithdrawForm
form = WithdrawForm()
if form.validate_on_submit():
try:
# Perform withdrawal
from src.services.withdrawal import withdraw_participant, WithdrawalError
withdraw_participant(participant)
# Log out participant
session.clear()
flash(
"You have been withdrawn from the exchange. "
"A confirmation email has been sent.",
"success"
)
return redirect(url_for('participant.register', slug=exchange.slug))
except WithdrawalError as e:
flash(str(e), "error")
return redirect(url_for('participant.dashboard'))
except Exception as e:
current_app.logger.error(f"Unexpected error during withdrawal: {e}")
flash("An unexpected error occurred. Please try again.", "error")
# GET request: show confirmation page
return render_template(
'participant/withdraw.html',
form=form,
participant=participant,
exchange=exchange
)
Flow:
- Check authentication and withdrawal eligibility
- GET: Show confirmation page with warnings
- POST: Process withdrawal, send email, clear session, redirect
Security Notes:
- Session cleared immediately after withdrawal (no orphaned sessions)
- Redirect to public registration page (safe landing)
- Email sent asynchronously (don't block on email failure)
4. Templates
4.1 Enhanced Dashboard Template
Location: templates/participant/dashboard.html
{% extends "layouts/participant.html" %}
{% block title %}{{ exchange.name }} - Dashboard{% endblock %}
{% block content %}
<div class="dashboard-container">
<h1>{{ exchange.name }}</h1>
<!-- Exchange Information -->
<section class="exchange-info">
<h2>Exchange Details</h2>
<dl>
<dt>Gift Budget:</dt>
<dd>{{ exchange.budget }}</dd>
<dt>Exchange Date:</dt>
<dd>{{ exchange.exchange_date|format_datetime }}</dd>
<dt>Status:</dt>
<dd class="status-{{ exchange.state }}">
{{ exchange.state|format_state }}
</dd>
</dl>
</section>
<!-- Your Profile -->
<section class="profile-info">
<h2>Your Profile</h2>
<dl>
<dt>Name:</dt>
<dd>{{ participant.name }}</dd>
<dt>Email:</dt>
<dd>{{ participant.email }}</dd>
<dt>Gift Ideas:</dt>
<dd>{{ participant.gift_ideas or 'None provided' }}</dd>
</dl>
{% if can_edit_profile %}
<a href="{{ url_for('participant.profile_edit') }}" class="button button-secondary">
Edit Profile
</a>
{% endif %}
</section>
<!-- Reminder Preferences -->
<section class="reminder-preferences">
<h2>Email Reminders</h2>
<form method="POST" action="{{ url_for('participant.update_preferences') }}">
{{ reminder_form.hidden_tag() }}
<div class="checkbox-field">
{{ reminder_form.reminder_enabled() }}
{{ reminder_form.reminder_enabled.label }}
</div>
{{ reminder_form.submit(class="button button-secondary") }}
</form>
</section>
<!-- Participant List -->
<section class="participant-list">
<h2>Participants ({{ participant_count }})</h2>
{% if participants %}
<ul>
{% for p in participants %}
<li>
{{ p.name }}
{% if p.id == participant.id %}
<span class="badge">You</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p>No other participants yet. Share the registration link!</p>
{% endif %}
</section>
<!-- Withdrawal Option -->
{% if can_withdraw %}
<section class="danger-zone">
<h2>Withdraw from Exchange</h2>
<p>
If you can no longer participate, you can withdraw from this exchange.
This cannot be undone.
</p>
<a href="{{ url_for('participant.withdraw') }}" class="button button-danger">
Withdraw from Exchange
</a>
</section>
{% endif %}
</div>
{% endblock %}
Design Notes:
- Clear information hierarchy (exchange → profile → participants → actions)
- Conditional rendering based on state (can_edit_profile, can_withdraw)
- Inline reminder preference form (no separate page needed)
- Participant list with "You" badge for current user
- Danger zone styling for withdrawal (red/warning colors)
4.2 Profile Edit Template
Location: templates/participant/profile_edit.html
{% extends "layouts/participant.html" %}
{% block title %}Edit Profile - {{ exchange.name }}{% endblock %}
{% block content %}
<div class="profile-edit-container">
<h1>Edit Your Profile</h1>
<p class="help-text">
Update your display name and gift ideas.
Your Secret Santa will see this information after matching.
</p>
<form method="POST" action="{{ url_for('participant.profile_edit') }}">
{{ form.hidden_tag() }}
<div class="form-field">
{{ form.name.label }}
{{ form.name(class="form-control") }}
{% if form.name.errors %}
<ul class="field-errors">
{% for error in form.name.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
<small class="help-text">{{ form.name.description }}</small>
</div>
<div class="form-field">
{{ form.gift_ideas.label }}
{{ form.gift_ideas(class="form-control") }}
{% if form.gift_ideas.errors %}
<ul class="field-errors">
{% for error in form.gift_ideas.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
<small class="help-text">{{ form.gift_ideas.description }}</small>
<small class="char-count" id="gift-ideas-count">
{{ (form.gift_ideas.data or '')|length }} / 10,000 characters
</small>
</div>
<div class="form-actions">
{{ form.submit(class="button button-primary") }}
<a href="{{ url_for('participant.dashboard') }}" class="button button-secondary">
Cancel
</a>
</div>
</form>
</div>
<script>
// Character counter for gift ideas (progressive enhancement)
document.addEventListener('DOMContentLoaded', function() {
const textarea = document.querySelector('textarea[name="gift_ideas"]');
const counter = document.getElementById('gift-ideas-count');
if (textarea && counter) {
textarea.addEventListener('input', function() {
counter.textContent = this.value.length + ' / 10,000 characters';
});
}
});
</script>
{% endblock %}
UX Features:
- Help text explains purpose
- Field descriptions provide guidance
- Character counter for gift ideas (client-side)
- Clear error display
- Cancel button returns to dashboard
4.3 Withdrawal Confirmation Template
Location: templates/participant/withdraw.html
{% extends "layouts/participant.html" %}
{% block title %}Withdraw from {{ exchange.name }}{% endblock %}
{% block content %}
<div class="withdraw-container">
<h1>Withdraw from Exchange</h1>
<div class="warning-box">
<h2>⚠️ Are you sure?</h2>
<p>Withdrawing from this exchange means:</p>
<ul>
<li>Your registration will be cancelled</li>
<li>You will be removed from the participant list</li>
<li>You cannot undo this action</li>
<li>You will need to re-register with a different email to rejoin</li>
</ul>
</div>
<form method="POST" action="{{ url_for('participant.withdraw') }}">
{{ form.hidden_tag() }}
<div class="form-field">
<label class="checkbox-label">
{{ form.confirm() }}
{{ form.confirm.label.text }}
</label>
{% if form.confirm.errors %}
<ul class="field-errors">
{% for error in form.confirm.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
<div class="form-actions">
{{ form.submit(class="button button-danger") }}
<a href="{{ url_for('participant.dashboard') }}" class="button button-secondary">
Cancel
</a>
</div>
</form>
</div>
{% endblock %}
Design Notes:
- Prominent warning box with icon
- Clear list of consequences
- Required confirmation checkbox
- Danger-styled submit button (red)
- Easy cancel option
5. Email Template
5.1 Withdrawal Confirmation Email
Location: templates/emails/participant/withdrawal_confirmation.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Withdrawal Confirmation</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #e74c3c;">Withdrawal Confirmed</h1>
<p>Hello {{ participant.name }},</p>
<p>
This email confirms that you have withdrawn from the Secret Santa exchange
<strong>{{ exchange.name }}</strong>.
</p>
<div style="background-color: #f8f9fa; border-left: 4px solid #e74c3c; padding: 15px; margin: 20px 0;">
<p style="margin: 0;">
<strong>What happens now:</strong>
</p>
<ul style="margin: 10px 0;">
<li>You have been removed from the participant list</li>
<li>Your profile information has been archived</li>
<li>You will not receive further emails about this exchange</li>
</ul>
</div>
<p>
If you withdrew by mistake, you can re-register using a different email address
while registration is still open.
</p>
<p>
If you have any questions, please contact the exchange organizer.
</p>
<hr style="border: none; border-top: 1px solid #ddd; margin: 30px 0;">
<p style="font-size: 12px; color: #666;">
This is an automated message from Sneaky Klaus.
</p>
</div>
</body>
</html>
Plain Text Version: templates/emails/participant/withdrawal_confirmation.txt
Withdrawal Confirmed
Hello {{ participant.name }},
This email confirms that you have withdrawn from the Secret Santa exchange "{{ exchange.name }}".
What happens now:
- You have been removed from the participant list
- Your profile information has been archived
- You will not receive further emails about this exchange
If you withdrew by mistake, you can re-register using a different email address while registration is still open.
If you have any questions, please contact the exchange organizer.
---
This is an automated message from Sneaky Klaus.
5.2 Email Service Update
Location: src/services/email.py (add method)
def send_withdrawal_confirmation(self, participant: Participant) -> None:
"""Send withdrawal confirmation email to participant.
Args:
participant: The participant who withdrew
Raises:
EmailError: If email send fails
"""
exchange = participant.exchange
# Render email templates
html_body = render_template(
'emails/participant/withdrawal_confirmation.html',
participant=participant,
exchange=exchange
)
text_body = render_template(
'emails/participant/withdrawal_confirmation.txt',
participant=participant,
exchange=exchange
)
# Send email
try:
resend.Emails.send({
"from": self.from_email,
"to": [participant.email],
"subject": f"Withdrawal Confirmed - {exchange.name}",
"html": html_body,
"text": text_body,
})
logger.info(f"Withdrawal confirmation sent to {participant.email}")
except Exception as e:
logger.error(f"Failed to send withdrawal email: {e}")
raise EmailError(f"Failed to send withdrawal confirmation: {e}")
6. Testing Specifications
6.1 Unit Tests
Location: tests/unit/test_participant_utils.py (new file)
"""Unit tests for participant utility functions."""
import pytest
from datetime import datetime
from src.utils.participant import can_update_profile, can_withdraw, is_withdrawn
def test_can_update_profile_allowed_states(participant_factory, exchange_factory):
"""Test profile updates allowed in pre-matching states."""
for state in ['draft', 'registration_open', 'registration_closed']:
exchange = exchange_factory(state=state)
participant = participant_factory(exchange=exchange)
assert can_update_profile(participant) is True
def test_can_update_profile_disallowed_states(participant_factory, exchange_factory):
"""Test profile updates blocked after matching."""
for state in ['matched', 'completed']:
exchange = exchange_factory(state=state)
participant = participant_factory(exchange=exchange)
assert can_update_profile(participant) is False
def test_can_withdraw_allowed_states(participant_factory, exchange_factory):
"""Test withdrawal allowed before registration closes."""
for state in ['draft', 'registration_open']:
exchange = exchange_factory(state=state)
participant = participant_factory(exchange=exchange)
assert can_withdraw(participant) is True
def test_can_withdraw_disallowed_states(participant_factory, exchange_factory):
"""Test withdrawal blocked after registration closes."""
for state in ['registration_closed', 'matched', 'completed']:
exchange = exchange_factory(state=state)
participant = participant_factory(exchange=exchange)
assert can_withdraw(participant) is False
def test_can_withdraw_already_withdrawn(participant_factory):
"""Test withdrawal blocked if already withdrawn."""
participant = participant_factory(withdrawn_at=datetime.utcnow())
assert can_withdraw(participant) is False
6.2 Integration Tests
Location: tests/integration/test_participant_self_management.py (new file)
"""Integration tests for participant self-management features."""
import pytest
from flask import url_for
class TestProfileUpdate:
"""Tests for profile update functionality."""
def test_profile_update_success(self, client, participant_session, db):
"""Test successful profile update."""
response = client.post(
url_for('participant.profile_edit'),
data={
'name': 'Updated Name',
'gift_ideas': 'Updated gift ideas',
'csrf_token': get_csrf_token(client)
},
follow_redirects=True
)
assert response.status_code == 200
assert b'profile has been updated' in response.data
# Verify database updated
participant = Participant.query.get(participant_session['user_id'])
assert participant.name == 'Updated Name'
assert participant.gift_ideas == 'Updated gift ideas'
def test_profile_update_locked_after_matching(
self, client, participant_session, db, exchange_factory
):
"""Test profile update blocked after matching."""
# Set exchange to matched state
participant = Participant.query.get(participant_session['user_id'])
participant.exchange.state = 'matched'
db.session.commit()
response = client.get(url_for('participant.profile_edit'))
assert response.status_code == 302 # Redirect
# Follow redirect
response = client.get(url_for('participant.profile_edit'), follow_redirects=True)
assert b'profile is locked' in response.data
class TestWithdrawal:
"""Tests for withdrawal functionality."""
def test_withdrawal_success(self, client, participant_session, db, mock_email):
"""Test successful withdrawal."""
participant_id = participant_session['user_id']
response = client.post(
url_for('participant.withdraw'),
data={
'confirm': True,
'csrf_token': get_csrf_token(client)
},
follow_redirects=True
)
assert response.status_code == 200
assert b'withdrawn from the exchange' in response.data
# Verify database updated
participant = Participant.query.get(participant_id)
assert participant.withdrawn_at is not None
# Verify session cleared
with client.session_transaction() as session:
assert 'user_id' not in session
# Verify email sent
mock_email.send_withdrawal_confirmation.assert_called_once()
def test_withdrawal_blocked_after_close(
self, client, participant_session, db
):
"""Test withdrawal blocked after registration closes."""
participant = Participant.query.get(participant_session['user_id'])
participant.exchange.state = 'registration_closed'
db.session.commit()
response = client.get(url_for('participant.withdraw'), follow_redirects=True)
assert b'Registration has closed' in response.data
7. Security Checklist
- ✅ All routes require
@participant_requiredauthentication - ✅ CSRF protection on all POST operations (WTForms)
- ✅ State validation before allowing operations
- ✅ Input sanitization (WTForms validators + Jinja2 auto-escaping)
- ✅ Session cleared on withdrawal (no orphaned sessions)
- ✅ Withdrawn participants excluded from participant list (privacy)
- ✅ No email enumeration (withdrawn status not revealed to other participants)
- ✅ Database rollback on errors (transaction safety)
8. Performance Checklist
- ✅ Participant list loaded with single query (no N+1)
- ✅ State checks use in-memory objects (no extra DB hits)
- ✅ Email sent asynchronously (doesn't block response)
- ✅ Form pre-population uses ORM objects (efficient)
- ✅ No caching needed (small datasets, infrequent updates)
9. Accessibility Checklist
- ✅ Form labels properly associated with inputs
- ✅ Error messages linked to fields (ARIA)
- ✅ Warning boxes use semantic HTML
- ✅ Buttons have descriptive text (no icon-only)
- ✅ Character counter is non-critical (progressive enhancement)
- ✅ All actions keyboard-accessible
End of Component Design