- 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>
1142 lines
34 KiB
Markdown
1142 lines
34 KiB
Markdown
# 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
|
|
|
|
```mermaid
|
|
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:
|
|
|
|
```python
|
|
"""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:
|
|
|
|
```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.
|
|
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)
|
|
|
|
```python
|
|
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`
|
|
|
|
```python
|
|
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`
|
|
|
|
```python
|
|
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`
|
|
|
|
```python
|
|
@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 object
|
|
- `exchange`: Associated exchange object
|
|
- `participants`: List of active participants (for participant list)
|
|
- `participant_count`: Number of active participants
|
|
- `can_edit_profile`: Boolean flag for showing edit button
|
|
- `can_withdraw`: Boolean flag for showing withdraw button
|
|
- `reminder_form`: Pre-populated reminder preference form
|
|
|
|
### 3.2 Profile Edit Route
|
|
|
|
**Location**: `src/routes/participant.py`
|
|
|
|
```python
|
|
@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**:
|
|
1. Check participant authentication (@participant_required)
|
|
2. Verify profile editing is allowed (state check)
|
|
3. GET: Show form pre-populated with current values
|
|
4. 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`
|
|
|
|
```python
|
|
@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**:
|
|
1. Check participant authentication
|
|
2. Validate CSRF token
|
|
3. Update reminder preference
|
|
4. 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`
|
|
|
|
```python
|
|
@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**:
|
|
1. Check authentication and withdrawal eligibility
|
|
2. GET: Show confirmation page with warnings
|
|
3. 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`
|
|
|
|
```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`
|
|
|
|
```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`
|
|
|
|
```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`
|
|
|
|
```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)
|
|
|
|
```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
|
|
|
|
# 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)
|
|
|
|
```python
|
|
"""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)
|
|
|
|
```python
|
|
"""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_required` authentication
|
|
- ✅ 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**
|