9 Commits

Author SHA1 Message Date
5660d882d6 Merge release/v0.3.0: Phase 3 - Participant Self-Management 2025-12-22 21:22:54 -07:00
0941315a70 Merge feature/phase-3-participant-self-management: Participant Self-Management (Stories 4.5, 6.1, 6.2, 6.3) 2025-12-22 21:21:26 -07:00
c2b3641d74 feat: implement reminder preferences and withdrawal (Stories 6.3, 6.2)
Implement Phase 3 participant self-management features:

Story 6.3 - Reminder Preferences:
- Add ReminderPreferenceForm to participant forms
- Add update_preferences route for preference updates
- Update dashboard template with reminder preference toggle
- Participants can enable/disable reminder emails at any time

Story 6.2 - Withdrawal from Exchange:
- Add can_withdraw utility function for state validation
- Create WithdrawalService to handle withdrawal process
- Add WithdrawForm with explicit confirmation requirement
- Add withdraw route with GET (confirmation) and POST (process)
- Add withdrawal confirmation email template
- Update dashboard to show withdraw link when allowed
- Withdrawal only allowed before registration closes
- Session cleared after withdrawal, user redirected to registration

All acceptance criteria met for both stories.
Test coverage: 90.02% (156 tests passing)
Linting and type checking: passed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 21:18:18 -07:00
4fbb681e03 fix: display participant count in admin dashboard and add startup diagnostics
- Fix admin dashboard showing hardcoded 0 for participant count
- Fix exchange detail page to show participant list with count
- Filter out withdrawn participants from counts
- Add startup diagnostics to entrypoint.sh for troubleshooting
- Fix test_profile_update test that was missing auth fixture

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 21:01:45 -07:00
a7902aa623 feat: implement Phase 3 participant self-management (stories 4.5, 6.1)
Implemented features:
- Story 4.5: View Participant List - participants can see other active participants
- Story 6.1: Update Profile - participants can edit name and gift ideas before matching
- Utility functions for state management and business logic
- Comprehensive unit and integration tests

New files:
- src/utils/participant.py - Business logic utilities
- src/templates/participant/profile_edit.html - Profile edit form
- tests/unit/test_participant_utils.py - Unit tests for utilities
- tests/integration/test_participant_list.py - Integration tests for participant list
- tests/integration/test_profile_update.py - Integration tests for profile updates

Modified files:
- src/routes/participant.py - Added dashboard participant list and profile edit route
- src/templates/participant/dashboard.html - Added participant list section and edit link
- src/forms/participant.py - Added ProfileUpdateForm
- src/app.py - Added participant.profile_edit to setup check exemptions
- tests/conftest.py - Added exchange_factory, participant_factory, auth_participant fixtures

All tests passing. Phase 3.3 (reminder preferences) and 3.4 (withdrawal) remain to be implemented.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 21:01:45 -07:00
75378ac769 Merge release/v0.2.1: Critical database persistence fix 2025-12-22 20:37:23 -07:00
6e8a7186cf fix: use absolute paths for database to ensure persistence
Critical bug fix: Path(__file__).parent.parent returns a relative path,
causing the database to be created in different locations depending on
the working directory. This caused data loss on container restarts.

Changes:
- Add .resolve() to BASE_DIR in src/config.py and migrations/env.py
- Fix admin dashboard showing 0 for participant count (was hardcoded)
- Fix exchange detail page to show actual participant list
- Add startup diagnostics to entrypoint.sh for troubleshooting

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 20:37:11 -07:00
915e77d994 chore: add production deployment config and upgrade path requirements
- 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>
2025-12-22 19:32:42 -07:00
155bd5fcf3 Merge release/v0.2.0: Phase 2 - Participant Registration & Authentication
Features implemented:
- Story 4.1: View Registration Page
- Story 4.2: New Participant Registration
- Story 4.3: Returning Participant Detection
- Story 5.1: Magic Link Request
- Story 5.2: Magic Link Login
- Story 5.3: Participant Session (Dashboard + Logout)
- Story 10.1: DEV Mode Email Logging

Technical changes:
- Alembic migrations with auto-apply on container start
- Filesystem-based sessions (avoids SQLAlchemy race conditions)
- Magic link authentication with SHA-256 hashed tokens
- Rate limiting for registration and magic link requests
- CSRF protection for all forms

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 19:04:30 -07:00
32 changed files with 5755 additions and 7 deletions

View File

@@ -37,6 +37,41 @@ You are an expert in:
3. **AI-consumable output**: Your designs will be read and implemented by an AI developer subagent, not a human—structure your output for clarity and unambiguous interpretation
4. **Explicit over implicit**: State assumptions clearly; avoid ambiguity
5. **Security by default**: Design with security in mind from the start
6. **Clean upgrade paths**: All designs must support existing installations upgrading seamlessly
## Upgrade Path Requirements
**CRITICAL**: Sneaky Klaus is now deployed in production. All designs must include:
1. **Migration Strategy**: How will database schema changes be applied to existing data?
2. **Data Preservation**: Existing exchanges, participants, and settings must never be lost
3. **Backward Compatibility**: Consider whether old clients/data can work with new code
4. **Rollback Plan**: What happens if an upgrade fails? Can users revert?
### Database Changes
- All schema changes MUST use Alembic migrations (never `db.create_all()`)
- Migrations MUST be reversible (`upgrade()` and `downgrade()` functions)
- New columns on existing tables MUST have defaults or be nullable
- Column renames or type changes require careful data migration
- Document migration steps in design documents
### Breaking Changes
If a breaking change is unavoidable:
1. Document it clearly in the design
2. Provide a migration path for existing data
3. Consider a multi-step migration if needed
4. Increment the MAJOR version number
### Design Document Requirements
Each design MUST include an **Upgrade Considerations** section covering:
- Required database migrations
- Data migration requirements
- Configuration changes
- Breaking changes (if any)
- Rollback procedure
## Output Locations

View File

@@ -26,6 +26,55 @@ You implement features based on designs provided by the architect. You write pro
4. **Stop on errors**: When you encounter failing tests, design inconsistencies, or blockers, stop and report to the coordinator immediately
5. **Clean code**: Follow Python best practices and PEP standards
6. **Mandatory docstrings**: All modules, classes, and functions must have docstrings
7. **Clean upgrade paths**: All changes must support existing production installations
## Upgrade Path Requirements
**CRITICAL**: Sneaky Klaus is deployed in production with real user data. All changes must:
1. **Preserve existing data**: Never lose exchanges, participants, or settings
2. **Use Alembic migrations**: All database schema changes MUST use Alembic
3. **Be reversible**: Migrations must have working `downgrade()` functions
4. **Handle existing data**: New columns must have defaults or be nullable
### Database Migration Rules
```bash
# NEVER use db.create_all() for schema changes
# ALWAYS create migrations with:
uv run alembic revision --autogenerate -m "description of change"
# Review generated migration before committing
# Ensure both upgrade() and downgrade() work correctly
# Test migration on a copy of production data if possible
```
### Migration Best Practices
1. **New columns on existing tables**:
- Must be nullable OR have a server_default
- Example: `sa.Column('new_field', sa.String(100), nullable=True)`
2. **Renaming columns**:
- Use `op.alter_column()` with proper data preservation
- Never drop and recreate
3. **Changing column types**:
- Create new column, migrate data, drop old column
- Or use `op.alter_column()` if type is compatible
4. **Adding constraints**:
- Ensure existing data satisfies the constraint
- May need data cleanup migration first
### Testing Migrations
Before merging, verify:
- [ ] Migration applies cleanly to fresh database
- [ ] Migration applies cleanly to database with existing data
- [ ] Downgrade works correctly
- [ ] Application functions correctly after migration
## Code Style & Standards

27
.env.example Normal file
View File

@@ -0,0 +1,27 @@
# Sneaky Klaus Environment Configuration
#
# Copy this file to .env and customize for your environment.
# NEVER commit .env to version control!
# Required: Secret key for session signing and CSRF protection
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=your-secret-key-here
# Required for production: Resend API key for sending emails
# Get your API key at https://resend.com
# Leave empty for development mode (emails logged to stdout)
RESEND_API_KEY=
# Email sender address (must be from a verified domain in Resend)
EMAIL_FROM=noreply@example.com
# Public URL of your application (used in email links)
# Include protocol, no trailing slash
APP_URL=https://secretsanta.example.com
# Environment mode: 'production' or 'development'
# In development mode:
# - Emails are logged to stdout instead of sent
# - Debug mode is enabled
# - Session cookies don't require HTTPS
FLASK_ENV=production

View File

@@ -0,0 +1,72 @@
# Sneaky Klaus - Example Docker Compose Configuration
#
# Copy this file to docker-compose.yml and customize for your environment.
#
# Quick Start:
# 1. cp docker-compose.example.yml docker-compose.yml
# 2. Create .env file with your configuration (see .env.example)
# 3. docker compose up -d
# 4. Visit http://localhost:8000 to set up your admin account
#
# Upgrading:
# docker compose pull
# docker compose up -d
# (Migrations run automatically on container start)
version: "3.8"
services:
sneaky-klaus:
image: git.thesatelliteoflove.com/phil/sneakyklaus:latest
container_name: sneaky-klaus
restart: unless-stopped
ports:
- "8000:8000"
environment:
# Required: Generate a secure secret key
# Example: python -c "import secrets; print(secrets.token_hex(32))"
- SECRET_KEY=${SECRET_KEY:?SECRET_KEY is required}
# Required for production email sending via Resend
# Get your API key at https://resend.com
- RESEND_API_KEY=${RESEND_API_KEY:-}
# Email sender address (must be verified domain with Resend)
- EMAIL_FROM=${EMAIL_FROM:-noreply@example.com}
# Public URL of your application (used in magic link emails)
- APP_URL=${APP_URL:-http://localhost:8000}
# Environment: 'production' or 'development'
# In development mode, emails are logged to stdout instead of sent
- FLASK_ENV=${FLASK_ENV:-production}
volumes:
# Persistent storage for SQLite database and session files
# Database migrations are applied automatically on container start
- sneaky-klaus-data:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes:
sneaky-klaus-data:
driver: local
# Optional: Add a reverse proxy for HTTPS
# Example with Traefik:
#
# services:
# sneaky-klaus:
# labels:
# - "traefik.enable=true"
# - "traefik.http.routers.sneaky-klaus.rule=Host(`secretsanta.example.com`)"
# - "traefik.http.routers.sneaky-klaus.tls.certresolver=letsencrypt"
# networks:
# - traefik
#
# networks:
# traefik:
# external: true

38
docker-compose.yml Normal file
View File

@@ -0,0 +1,38 @@
version: "3.8"
services:
sneaky-klaus:
image: git.thesatelliteoflove.com/phil/sneakyklaus:latest
container_name: sneaky-klaus
restart: unless-stopped
ports:
- "8000:8000"
environment:
# Required: Generate a secure secret key
# Example: python -c "import secrets; print(secrets.token_hex(32))"
- SECRET_KEY=${SECRET_KEY:?SECRET_KEY is required}
# Required for production email sending
- RESEND_API_KEY=${RESEND_API_KEY:-}
# Email sender address (must be verified with Resend)
- EMAIL_FROM=${EMAIL_FROM:-noreply@example.com}
# Public URL of your application (used for magic links)
- APP_URL=${APP_URL:-http://localhost:8000}
# Set to 'development' for testing (logs emails instead of sending)
- FLASK_ENV=${FLASK_ENV:-production}
volumes:
# Persistent storage for SQLite database and sessions
- sneaky-klaus-data:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes:
sneaky-klaus-data:
driver: local

View File

@@ -0,0 +1,252 @@
# ADR-0006: Participant State Management
**Status**: Accepted
**Date**: 2025-12-22
**Deciders**: Architect
**Phase**: v0.3.0
## Context
Participants need to manage their own profiles and potentially withdraw from exchanges. We need to decide:
1. When can participants update their profiles (name, gift ideas)?
2. When can participants withdraw from an exchange?
3. How should withdrawals be implemented (hard delete vs soft delete)?
4. Should withdrawn participants be visible in any context?
5. Can withdrawn participants re-join the same exchange?
These decisions impact data integrity, user experience, and admin workflows.
## Decision
### 1. Profile Update Rules
**Profile updates are allowed until matching occurs.**
Participants can update their name and gift ideas when the exchange is in:
- `draft` state
- `registration_open` state
- `registration_closed` state
Profile updates are **locked** when the exchange is in:
- `matched` state
- `completed` state
**Rationale**:
- Gift ideas are sent to Secret Santas during match notification
- Allowing changes after matching would create inconsistency (giver sees old version in email)
- Name changes after matching could confuse participants about who they're buying for
- Registration close is admin's signal to finalize participant list, not to lock profiles
- Locking happens at matching, which is the point where profile data is "consumed" by the system
### 2. Withdrawal Rules
**Withdrawals are allowed until registration closes.**
Participants can withdraw when the exchange is in:
- `draft` state
- `registration_open` state
Withdrawals require admin intervention when the exchange is in:
- `registration_closed` state (admin may be configuring exclusions)
- `matched` state (would require re-matching)
- `completed` state (historical record)
**Rationale**:
- Before registration closes: minimal impact, just removes one participant
- After registration closes: admin is likely configuring exclusions or preparing to match
- After matching: re-matching is a significant operation that should be admin-controlled
- Clear deadline (registration close) sets expectations for participants
- Prevents last-minute dropouts that could disrupt matching
### 3. Withdrawal Implementation (Soft Delete)
**Withdrawals use soft delete via `withdrawn_at` timestamp.**
Technical implementation:
- Set `participant.withdrawn_at = datetime.utcnow()` on withdrawal
- Keep participant record in database
- Filter out withdrawn participants in queries: `Participant.withdrawn_at.is_(None)`
- Cascade rules remain unchanged (deleting exchange deletes all participants)
**Rationale**:
- **Audit trail**: Preserves record of who registered and when they withdrew
- **Email uniqueness**: Prevents re-registration with same email in same exchange (see Decision 5)
- **Admin visibility**: Admins can see withdrawal history for troubleshooting
- **Simplicity**: No cascade delete complexity or foreign key violations
- **Existing pattern**: Data model already includes `withdrawn_at` field (v0.2.0 design)
Alternative considered: Hard delete participants on withdrawal
- Rejected: Loses audit trail, allows immediate re-registration (see Decision 5)
- Rejected: Requires careful cascade handling for tokens, exclusions
- Rejected: Complicates participant count tracking
### 4. Withdrawn Participant Visibility
**Withdrawn participants are visible only to admin.**
Visibility rules:
- **Participant list (participant view)**: Withdrawn participants excluded
- **Participant list (admin view)**: Withdrawn participants shown with indicator (e.g., grayed out, "Withdrawn" badge)
- **Participant count**: Counts exclude withdrawn participants
- **Matching algorithm**: Withdrawn participants excluded from matching pool
**Rationale**:
- **Privacy**: Respects participant's decision to withdraw (no public record)
- **Admin needs**: Admin may need to see who withdrew (for follow-up, re-invites, etc.)
- **Clean UX**: Participants see only active participants (less confusing)
- **Data integrity**: Admin view preserves audit trail
### 5. Re-Registration After Withdrawal
**Withdrawn participants cannot re-join the same exchange (with same email).**
Technical enforcement:
- Unique constraint on `(exchange_id, email)` remains in place
- Soft delete doesn't remove the record, so email remains "taken"
- Participant must use a different email to re-register
**Rationale**:
- **Prevents gaming**: Stops participants from withdrawing to see participant list changes, then re-joining
- **Simplifies logic**: No need to handle "re-activation" of withdrawn participants
- **Clear consequence**: Withdrawal is final (as warned in UI)
- **Data integrity**: Each participant registration is a distinct record
Alternative considered: Allow re-activation of withdrawn participants
- Rejected: Complex state transitions (withdrawn → active → withdrawn → active)
- Rejected: Unclear UX (does re-joining restore old profile or create new?)
- Rejected: Enables abuse (withdraw/rejoin cycle)
If participant genuinely needs to rejoin:
- Use a different email address (e.g., alias like user+exchange@example.com)
- Or: Contact admin, who can manually delete the withdrawn record (future admin feature)
### 6. Reminder Preferences After Withdrawal
**Withdrawn participants do not receive reminder emails.**
Technical implementation:
- Reminder job queries exclude withdrawn participants: `withdrawn_at IS NULL`
- Reminder preference persists in database (for audit) but is not used
**Rationale**:
- Withdrawn participants have no match to be reminded about
- Sending reminders would be confusing and violate withdrawal expectations
- Simple filter in reminder job handles this naturally
## Consequences
### Positive
1. **Clear rules**: Participants know when they can update profiles or withdraw
2. **Data integrity**: Matching always uses consistent profile data
3. **Audit trail**: System preserves record of all registrations and withdrawals
4. **Simple implementation**: Soft delete is easier than hard delete + cascades
5. **Privacy**: Withdrawn participants not visible to other participants
6. **Admin control**: Admin retains visibility for troubleshooting
### Negative
1. **No re-join**: Participants who withdraw accidentally must use different email
2. **Email "wastage"**: Withdrawn participants' emails remain "taken" in that exchange
3. **Database growth**: Withdrawn participants remain in database (minimal impact given small datasets)
### Mitigations
1. **Clear warnings**: UI prominently warns that withdrawal is permanent and cannot be undone
2. **Confirmation required**: Withdrawal requires explicit checkbox confirmation
3. **Confirmation email**: Withdrawn participants receive email confirming withdrawal
4. **Admin override** (future): Admin can manually delete withdrawn participants if needed
## Implementation Notes
### State Check Function
```python
def can_update_profile(participant: Participant) -> bool:
"""Check if participant can update their profile."""
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."""
if participant.withdrawn_at is not None:
return False # Already withdrawn
exchange = participant.exchange
allowed_states = ['draft', 'registration_open']
return exchange.state in allowed_states
```
### Query Pattern for Active Participants
```python
# Get active participants only
active_participants = Participant.query.filter(
Participant.exchange_id == exchange_id,
Participant.withdrawn_at.is_(None)
).all()
# Count active participants
active_count = Participant.query.filter(
Participant.exchange_id == exchange_id,
Participant.withdrawn_at.is_(None)
).count()
```
### Admin View Enhancement (Future)
```python
# Admin can see all participants including withdrawn
all_participants = Participant.query.filter(
Participant.exchange_id == exchange_id
).all()
# Template can check: participant.withdrawn_at is not None
```
## Related Decisions
- [ADR-0002: Authentication Strategy](0002-authentication-strategy.md) - Participant session management
- [ADR-0003: Participant Session Scoping](0003-participant-session-scoping.md) - Session behavior on withdrawal
- [v0.2.0 Data Model](../designs/v0.2.0/data-model.md) - `withdrawn_at` field design
- [v0.3.0 Participant Self-Management](../designs/v0.3.0/participant-self-management.md) - Implementation details
## Future Considerations
### Phase 6: Admin Participant Management
When implementing admin participant removal (Epic 9):
- Admin should be able to hard delete withdrawn participants (cleanup)
- Admin should be able to remove active participants (sets withdrawn_at + sends notification)
- Admin should see withdrawal history in participant list
### Phase 8: Matching
Matching algorithm must:
- Filter participants by `withdrawn_at IS NULL`
- Validate participant count >= 3 (after filtering)
- Handle case where withdrawals reduce count below minimum
### Potential Future Enhancement: Re-Activation
If user demand requires allowing re-join:
- Add `reactivated_at` timestamp
- Track withdrawal/reactivation history (audit log)
- Clear `withdrawn_at` on re-activation
- Send re-activation email
- Complexity: High, defer until proven necessary
## References
- [Product Backlog](../BACKLOG.md) - Epic 6: Participant Self-Management
- [Project Overview](../PROJECT_OVERVIEW.md) - Self-management principles
- [v0.2.0 Data Model](../designs/v0.2.0/data-model.md) - Participant table schema
---
**Decision Date**: 2025-12-22
**Architect**: Claude Opus 4.5
**Status**: Accepted for v0.3.0

View File

@@ -0,0 +1,278 @@
# Phase 3 (v0.3.0) Design Documentation
**Version**: 0.3.0
**Date**: 2025-12-22
**Status**: Ready for Implementation
## Quick Start
Phase 3 implements participant self-management features, building on the authentication foundation from Phase 2.
**Core Features**:
- Participant list view (see who else registered)
- Profile updates (name and gift ideas)
- Reminder email preferences
- Participant withdrawal (before registration closes)
## Document Index
### 1. [System Overview](overview.md)
High-level architecture, goals, and design decisions for Phase 3.
**Read this first** to understand:
- What's in scope for v0.3.0
- Key design decisions (profile locks, withdrawal rules)
- Data flow diagrams
- State machine changes
**Key sections**:
- Phase Goals (page 1)
- Key Design Decisions (page 2)
- State Machine Impact (page 4)
### 2. [Participant Self-Management Component Design](participant-self-management.md)
Detailed component specifications for all Phase 3 features.
**Use this for**:
- Exact function signatures and implementations
- Form definitions
- Route specifications
- Template structures
- Email templates
**Key sections**:
- Business Logic Functions (page 1)
- Forms (page 6)
- Routes (page 8)
- Templates (page 11)
- Security Checklist (page 19)
### 3. [Test Plan](test-plan.md)
Comprehensive testing specifications for Phase 3.
**Use this for**:
- Unit test cases and fixtures
- Integration test scenarios
- Acceptance test procedures
- Manual QA steps
- Coverage requirements
**Key sections**:
- Unit Tests (page 1)
- Integration Tests (page 4)
- Acceptance Tests (page 9)
- Edge Cases (page 11)
### 4. [Implementation Guide](implementation-guide.md)
Step-by-step implementation instructions using TDD.
**Follow this to**:
- Implement features in correct order
- Write tests first, then code
- Verify each feature before moving on
- Create proper commits and PRs
**Key sections**:
- Implementation Order (page 1)
- Phase 3.1: Participant List (page 2)
- Phase 3.2: Profile Updates (page 4)
- Phase 3.3: Reminder Preferences (page 7)
- Phase 3.4: Withdrawal (page 9)
- Final Steps (page 14)
## Architecture Decision Records
### [ADR-0006: Participant State Management](../../decisions/0006-participant-state-management.md)
Documents key decisions about when participants can update profiles, withdraw, and how withdrawals are implemented.
**Key decisions**:
- Profile updates allowed until matching
- Withdrawals allowed until registration closes
- Soft delete implementation (withdrawn_at timestamp)
- Withdrawn participants visible only to admin
- No re-registration with same email
## User Stories Implemented
Phase 3 completes these backlog items:
### Epic 4: Participant Registration
-**Story 4.5**: View Participant List (Pre-Matching)
### Epic 6: Participant Self-Management
-**Story 6.1**: Update Profile
-**Story 6.2**: Withdraw from Exchange
-**Story 6.3**: Update Reminder Preferences
## Dependencies
### Prerequisites (from Phase 2)
- ✅ Participant model with `withdrawn_at` field
- ✅ Participant authentication (@participant_required decorator)
- ✅ Participant dashboard route
- ✅ Email service for sending emails
### No New Dependencies
Phase 3 requires **no new**:
- Python packages
- Database migrations
- Environment variables
- External services
## Technical Highlights
### New Files Created
```
src/
utils/participant.py # Business logic functions
services/withdrawal.py # Withdrawal service
templates/
participant/
profile_edit.html # Profile edit page
withdraw.html # Withdrawal confirmation
emails/
participant/
withdrawal_confirmation.html # Withdrawal email
withdrawal_confirmation.txt # Plain text version
tests/
unit/
test_participant_utils.py # Unit tests for business logic
test_withdrawal_service.py # Unit tests for withdrawal
integration/
test_profile_update.py # Profile update integration tests
test_withdrawal.py # Withdrawal integration tests
test_participant_list.py # Participant list tests
test_reminder_preferences.py # Preference update tests
```
### Modified Files
```
src/
routes/participant.py # New routes added
forms/participant.py # New forms added
services/email.py # Withdrawal email method added
templates/
participant/dashboard.html # Enhanced with participant list
```
## Design Principles Applied
Phase 3 adheres to project principles:
1. **Simplicity First**
- No new database tables (uses existing fields)
- No new external dependencies
- Soft delete instead of complex cascade handling
2. **State-Based Permissions**
- Clear rules about when operations are allowed
- Based on exchange state (draft, open, closed, matched)
- Easy to test and reason about
3. **TDD Approach**
- Implementation guide follows test-first methodology
- Every feature has unit and integration tests
- 80%+ coverage maintained
4. **Security by Design**
- All routes require authentication
- State validation prevents unauthorized operations
- CSRF protection on all POST operations
- Input sanitization via WTForms and Jinja2
5. **Privacy-Conscious**
- Participant list shows names only (no emails)
- Withdrawn participants hidden from other participants
- Gift ideas not revealed until matching
## Implementation Checklist
Use this to track progress:
- [ ] **Phase 3.1: Participant List View**
- [ ] Create `src/utils/participant.py`
- [ ] Write unit tests for utility functions
- [ ] Update dashboard route
- [ ] Update dashboard template
- [ ] Write integration tests
- [ ] Manual QA
- [ ] **Phase 3.2: Profile Updates**
- [ ] Add `can_update_profile()` function
- [ ] Write unit tests for state validation
- [ ] Create `ProfileUpdateForm`
- [ ] Create profile edit route
- [ ] Create profile edit template
- [ ] Update dashboard with edit link
- [ ] Write integration tests
- [ ] Manual QA
- [ ] **Phase 3.3: Reminder Preferences**
- [ ] Create `ReminderPreferenceForm`
- [ ] Create preference update route
- [ ] Update dashboard with preference form
- [ ] Write integration tests
- [ ] Manual QA
- [ ] **Phase 3.4: Withdrawal**
- [ ] Add `can_withdraw()` function
- [ ] Create `src/services/withdrawal.py`
- [ ] Write unit tests for withdrawal service
- [ ] Create `WithdrawForm`
- [ ] Create withdrawal route
- [ ] Create email templates
- [ ] Update email service
- [ ] Create withdrawal template
- [ ] Update dashboard with withdraw link
- [ ] Write integration tests
- [ ] Manual QA
- [ ] **Final Steps**
- [ ] Run all tests (≥ 80% coverage)
- [ ] Run linting and type checking
- [ ] Complete manual QA from test plan
- [ ] Update documentation if needed
- [ ] Create feature branch
- [ ] Commit changes
- [ ] Create pull request
## Success Criteria
Phase 3 is complete when:
1. ✅ All user stories have passing acceptance tests
2. ✅ Code coverage ≥ 80%
3. ✅ All linting and type checking passes
4. ✅ Manual QA completed
5. ✅ Security checklist verified
6. ✅ Accessibility tests pass
7. ✅ Browser compatibility verified
8. ✅ Phase 2 regression tests still pass
9. ✅ Documentation updated
10. ✅ Pull request approved and merged
## What's Next: Phase 4
After Phase 3 is complete, the next logical phase is:
**Phase 4 (v0.4.0)**: Post-Matching Participant Experience (Epic 11)
- View assigned recipient (match assignment)
- View recipient's gift ideas
- View exchange information post-matching
- Participant list (post-matching version)
This builds on Phase 3's participant dashboard foundation and enables the core Secret Santa experience after admin has matched participants.
## Questions?
- Review the [Project Overview](../../PROJECT_OVERVIEW.md) for product vision
- Check the [Backlog](../../BACKLOG.md) for user stories
- See [v0.2.0 Design](../v0.2.0/overview.md) for foundation architecture
- Consult existing [ADRs](../../decisions/) for architectural context
---
**Phase 3 Design Status**: ✅ Complete and Ready for Implementation

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,566 @@
# System Overview - v0.3.0
**Version**: 0.3.0
**Date**: 2025-12-22
**Status**: Phase 3 Design
## Introduction
This document describes the design for Phase 3 of Sneaky Klaus, building on the participant authentication foundation established in v0.2.0. This phase focuses on participant self-management capabilities and pre-matching participant experience.
**Phase 3 Scope**: Implement Epic 6 (Participant Self-Management) and Epic 4.5 (View Participant List Pre-Matching), enabling participants to manage their own profiles and see who else has registered before matching occurs.
## Phase Goals
The primary goals for v0.3.0 are:
1. **Enable participant self-service**: Allow participants to update their profiles without admin intervention
2. **Support graceful withdrawals**: Handle participants who need to drop out before matching
3. **Build social engagement**: Let participants see who else is participating (pre-matching only)
4. **Maintain data integrity**: Ensure profile changes and withdrawals don't break the exchange flow
## User Stories in Scope
### Epic 6: Participant Self-Management
- **6.1 Update Profile**: Participants can edit their name and gift ideas before matching
- **6.2 Withdraw from Exchange**: Participants can opt out before registration closes
- **6.3 Update Reminder Preferences**: Participants can toggle reminder emails on/off
### Epic 4: Participant Registration (Continuation)
- **4.5 View Participant List (Pre-Matching)**: Registered participants can see who else has joined
## Out of Scope for v0.3.0
The following features are explicitly **not** included in this phase:
- Post-matching participant experience (Epic 11) - deferred to Phase 4
- Matching system (Epic 8) - deferred to Phase 5
- Admin exchange management beyond what v0.1.0 provided
- Participant removal by admin (Epic 9) - deferred to Phase 6
- Notification emails beyond registration confirmation (Epic 10) - partial in Phase 2, remainder in Phase 7
## Architecture Overview
Phase 3 builds incrementally on the v0.2.0 architecture with no new infrastructure components:
```mermaid
graph TB
subgraph "Phase 3 Additions"
ProfileUpdate[Profile Update UI]
WithdrawUI[Withdraw UI]
ParticipantList[Participant List View]
ProfileUpdate --> ParticipantRoutes
WithdrawUI --> ParticipantRoutes
ParticipantList --> ParticipantRoutes
end
subgraph "Existing v0.2.0 Foundation"
ParticipantRoutes[Participant Routes]
ParticipantAuth[Participant Auth Decorator]
ParticipantModel[Participant Model]
ExchangeModel[Exchange Model]
Database[(SQLite)]
ParticipantRoutes --> ParticipantAuth
ParticipantRoutes --> ParticipantModel
ParticipantRoutes --> ExchangeModel
ParticipantModel --> Database
ExchangeModel --> Database
end
```
## Technical Design Principles
This phase adheres to the project's core principles established in v0.1.0 and v0.2.0:
1. **No new database tables**: All features use existing Participant and Exchange models
2. **No new external dependencies**: Pure Flask/SQLAlchemy implementation
3. **Soft deletes**: Withdrawals use `withdrawn_at` timestamp rather than hard deletes
4. **State-based permissions**: Operations restricted based on exchange state
5. **TDD approach**: Write tests first, implement to pass
## Key Design Decisions
### 1. Profile Update Restrictions
**Decision**: Profile updates are only allowed when exchange is in `draft`, `registration_open`, or `registration_closed` states. After matching, profiles are locked.
**Rationale**:
- Gift ideas are sent to givers during match notification - changes after matching would create inconsistency
- Name changes after matching could confuse participants
- Clear state transition prevents data inconsistency
**Implementation**:
```python
def can_update_profile(participant: Participant) -> bool:
"""Check if participant can update their profile."""
exchange = participant.exchange
return exchange.state in ['draft', 'registration_open', 'registration_closed']
```
### 2. Withdrawal Rules
**Decision**: Withdrawals are only allowed before registration closes. After registration closes, admin intervention required.
**Rationale**:
- Pre-closure: minimal impact, just removes one participant
- Post-closure: admin likely already configuring exclusions or has matched
- Post-matching: requires re-match, admin should make this decision
- Clear deadline prevents last-minute dropouts
**Implementation**:
```python
def can_withdraw(participant: Participant) -> bool:
"""Check if participant can withdraw themselves."""
exchange = participant.exchange
return exchange.state in ['draft', 'registration_open']
```
### 3. Withdrawal Implementation (Soft Delete)
**Decision**: Use existing `withdrawn_at` timestamp field, don't delete participant records.
**Rationale**:
- Data model already supports soft deletes (v0.2.0 design)
- Preserves audit trail of who registered
- Prevents re-use of email during same exchange
- Allows admin to see withdrawal history
- Simplifies database constraints (no cascade delete issues)
**Implementation**:
```python
def withdraw_participant(participant: Participant):
"""Mark participant as withdrawn."""
participant.withdrawn_at = datetime.utcnow()
db.session.commit()
```
### 4. Participant List Visibility
**Decision**: Show participant list to all registered participants, but only show names (not emails or gift ideas).
**Rationale**:
- Social aspect: participants want to know who's participating
- Privacy: emails are admin-only
- Security: gift ideas are for givers only (post-matching)
- Pre-matching only: post-matching handled in Phase 4
**Display Rules**:
- Show: participant names, count
- Hide: emails, gift ideas, withdrawn participants
- Filter: exclude withdrawn participants from list
### 5. Reminder Preference Changes
**Decision**: Allow reminder preference changes at any time before exchange completes.
**Rationale**:
- Low-impact change (doesn't affect matching or other participants)
- User preference should be flexible
- No technical reason to restrict after matching
- Allows opting out if circumstances change
**Implementation**: Simple boolean toggle, no state restrictions.
### 6. Form Validation Strategy
**Decision**: Use server-side validation with WTForms, add client-side hints for UX.
**Rationale**:
- Consistent with Phase 2 implementation decisions
- Security: never trust client-side validation
- UX: client-side provides immediate feedback
- Progressive enhancement: works without JavaScript
## Data Flow
### Profile Update Flow
```mermaid
sequenceDiagram
participant P as Participant Browser
participant F as Flask App
participant DB as SQLite Database
P->>F: GET /participant/profile/edit
F->>F: Check @participant_required
F->>DB: Load participant & exchange
F->>F: Check can_update_profile()
F-->>P: Render edit form (or error)
P->>F: POST /participant/profile/edit (name, gift_ideas)
F->>F: Validate form
F->>F: Check can_update_profile()
F->>DB: Update participant record
DB-->>F: Success
F-->>P: Redirect to dashboard with success message
```
### Withdrawal Flow
```mermaid
sequenceDiagram
participant P as Participant Browser
participant F as Flask App
participant DB as SQLite Database
participant E as Email Service
P->>F: POST /participant/withdraw (with confirmation)
F->>F: Check @participant_required
F->>DB: Load participant & exchange
F->>F: Check can_withdraw()
F->>DB: Set withdrawn_at timestamp
DB-->>F: Success
F->>E: Send withdrawal confirmation email
E-->>F: Email sent
F->>F: Clear session (log out participant)
F-->>P: Redirect to public page with confirmation
```
### Participant List Flow
```mermaid
sequenceDiagram
participant P as Participant Browser
participant F as Flask App
participant DB as SQLite Database
P->>F: GET /participant/dashboard
F->>F: Check @participant_required
F->>DB: Load participant's exchange
F->>DB: Query active participants (withdrawn_at IS NULL)
DB-->>F: Participant list (names only)
F-->>P: Render dashboard with participant list
```
## State Machine Impact
Phase 3 doesn't add new exchange states, but adds participant-level state:
```mermaid
stateDiagram-v2
[*] --> Registered: Registration
Registered --> UpdatedProfile: Edit profile
UpdatedProfile --> UpdatedProfile: Edit again
UpdatedProfile --> Withdrawn: Withdraw
Registered --> Withdrawn: Withdraw
UpdatedProfile --> Matched: Admin matches
Registered --> Matched: Admin matches
Withdrawn --> [*]
note right of Withdrawn
Soft delete: withdrawn_at set
Cannot re-activate
Must re-register with new email
end note
note right of UpdatedProfile
Only before matching
Name and gift_ideas editable
end note
```
## Component Design
### Routes (participant_bp)
New routes added to existing `src/routes/participant.py`:
| Route | Method | Auth | Description |
|-------|--------|------|-------------|
| `/participant/profile/edit` | GET, POST | @participant_required | Edit profile (name, gift ideas) |
| `/participant/preferences` | POST | @participant_required | Update reminder preference |
| `/participant/withdraw` | POST | @participant_required | Withdraw from exchange |
Existing routes used:
- `/participant/dashboard` - enhanced to show participant list
### Forms (src/forms/participant.py)
New forms:
**ProfileUpdateForm**:
```python
class ProfileUpdateForm(FlaskForm):
name = StringField('Name', validators=[DataRequired(), Length(max=255)])
gift_ideas = TextAreaField('Gift Ideas', validators=[Length(max=10000)])
submit = SubmitField('Save Changes')
```
**ReminderPreferenceForm**:
```python
class ReminderPreferenceForm(FlaskForm):
reminder_enabled = BooleanField('Send me reminders')
submit = SubmitField('Update Preferences')
```
**WithdrawForm**:
```python
class WithdrawForm(FlaskForm):
confirm = BooleanField('I understand this cannot be undone',
validators=[DataRequired()])
submit = SubmitField('Withdraw from Exchange')
```
### Templates
New templates in `templates/participant/`:
- `profile_edit.html` - Profile update form
- `withdraw.html` - Withdrawal confirmation page (with warnings)
- `participant_list.html` - Reusable component for displaying participant names
Enhanced templates:
- `dashboard.html` - Add participant list section
### Email Templates
New email template in `templates/emails/participant/`:
- `withdrawal_confirmation.html` - Sent when participant withdraws
- `withdrawal_confirmation.txt` - Plain text version
## Security Considerations
### 1. Authorization Checks
Every participant operation must verify:
- User is authenticated as participant (@participant_required)
- User owns the resource (participant_id matches session)
- Exchange state allows the operation (can_update_profile, can_withdraw)
### 2. CSRF Protection
All POST operations require CSRF tokens (WTForms automatic).
### 3. Input Validation
- Name: 1-255 characters, required
- Gift ideas: 0-10,000 characters, optional
- All inputs sanitized via Jinja2 auto-escaping
### 4. Privacy
Participant list shows:
- Names only (public within exchange)
- Not emails (admin-only)
- Not gift ideas (giver-only post-matching)
- Not withdrawn participants (respect withdrawal privacy)
### 5. Rate Limiting
No new rate limiting needed:
- Profile updates: legitimate user operation, no abuse vector
- Withdrawals: self-limiting (can only withdraw once)
- Participant list: read-only, authenticated
## Testing Strategy
### Unit Tests
Test business logic in isolation:
- `can_update_profile()` logic for each exchange state
- `can_withdraw()` logic for each exchange state
- Soft delete implementation (withdrawn_at timestamp)
- Participant list filtering (exclude withdrawn)
### Integration Tests
Test full request/response cycles:
- Profile update happy path
- Profile update when locked (post-matching)
- Withdrawal happy path
- Withdrawal when not allowed
- Participant list rendering
### Test Data Setup
Reuse existing test fixtures from Phase 2, add:
- Multiple participants in same exchange
- Participants in different states (registered, withdrawn)
- Exchanges in different states
### Coverage Target
Maintain 80%+ coverage established in previous phases.
## Error Handling
### Expected Errors
| Scenario | HTTP Status | User Message | Action |
|----------|-------------|--------------|--------|
| Update profile after matching | 400 | "Your profile is locked after matching" | Redirect to dashboard |
| Withdraw after registration closes | 400 | "Withdrawal deadline has passed. Contact admin." | Redirect to dashboard |
| Invalid form data | 400 | Field-specific errors | Re-render form with errors |
| Already withdrawn | 400 | "You have already withdrawn" | Redirect to public page |
### Unexpected Errors
- Database errors: Log, flash generic error, redirect safely
- Missing participant record: Clear session, redirect to landing page
- Orphaned session: Clear session, redirect to landing page
## Performance Considerations
### Database Queries
**Participant List Query**:
```python
# Efficient query with single DB hit
participants = Participant.query.filter(
Participant.exchange_id == exchange_id,
Participant.withdrawn_at.is_(None)
).order_by(Participant.name).all()
```
**Profile Load**:
- Already loaded by @participant_required decorator (uses `g.participant`)
- No additional query needed
### Caching
Not needed for v0.3.0:
- Participant counts are small (3-100 typical)
- Updates are infrequent
- Reads are authenticated (no public caching)
## Migration Requirements
**No database migrations required for v0.3.0.**
All fields already exist:
- `participant.name` - editable
- `participant.gift_ideas` - editable
- `participant.reminder_enabled` - editable
- `participant.withdrawn_at` - set on withdrawal
## Deployment Impact
### Breaking Changes
None. v0.3.0 is fully backward compatible with v0.2.0.
### Configuration Changes
None. No new environment variables or configuration needed.
### Data Migration
None. Existing data fully compatible.
## User Experience Flow
### Participant Journey (Pre-Matching)
1. **Register** (Phase 2)
- Receive confirmation email with magic link
2. **View Dashboard** (Phase 2 + Phase 3)
- See own information
- **NEW**: See list of other participants (names only)
3. **Update Profile** (Phase 3)
- Click "Edit Profile" from dashboard
- Update name or gift ideas
- Save changes
- See confirmation message
4. **Change Reminder Preference** (Phase 3)
- Toggle reminder checkbox on dashboard
- See confirmation message
5. **Withdraw (if needed)** (Phase 3)
- Click "Withdraw" from dashboard
- See warning about permanence
- Confirm withdrawal
- Receive confirmation email
- Logged out and redirected to public page
## Admin Experience Impact
Phase 3 adds visibility for admins:
### Exchange Detail View (Enhancement)
Add to existing exchange detail page:
- Show participant list with withdrawn indicator
- Display: "5 active, 1 withdrawn"
- Allow admin to see withdrawn participants (grayed out)
**Implementation Note**: This is a minor enhancement to existing admin routes, not a major feature. Can be implemented alongside participant features or deferred to Phase 6 (Admin Participant Management).
## Future Considerations
### Phase 4 Dependencies
Phase 3 sets up foundations for Phase 4 (Post-Matching Experience):
- Participant list already implemented (will be reused post-matching)
- Profile lock logic (can_update_profile) prevents changes after matching
- Dashboard structure ready to show match assignment
### Potential Enhancements (Out of Scope)
Not included in v0.3.0 but possible future additions:
- Email notification to admin when participant withdraws (Epic 10.5)
- Allow participants to indicate dietary restrictions or allergies
- Allow participants to add profile pictures
- Allow participants to message admin (anonymous contact form)
## Acceptance Criteria
Phase 3 is complete when:
1. ✅ Participants can update their name and gift ideas before matching
2. ✅ Participants cannot update profile after matching occurs
3. ✅ Participants can withdraw before registration closes
4. ✅ Participants cannot withdraw after registration closes
5. ✅ Withdrawn participants receive confirmation email
6. ✅ Withdrawn participants are logged out and removed from participant list
7. ✅ Participants can view list of other registered participants (names only)
8. ✅ Participant list excludes withdrawn participants
9. ✅ Participants can toggle reminder preferences at any time
10. ✅ All operations require participant authentication
11. ✅ All operations validate exchange state appropriately
12. ✅ All user stories have passing integration tests
13. ✅ Code coverage remains at 80%+
## Implementation Phases
Recommended implementation order (TDD, vertical slices):
### Phase 3.1: Participant List View
- Story 4.5: View Participant List (Pre-Matching)
- Simplest feature, no state changes
- Sets up dashboard enhancements
### Phase 3.2: Profile Updates
- Story 6.1: Update Profile
- Core self-management feature
- Tests state-based permissions
### Phase 3.3: Reminder Preferences
- Story 6.3: Update Reminder Preferences
- Simple toggle, low risk
- Quick win
### Phase 3.4: Withdrawal
- Story 6.2: Withdraw from Exchange
- Most complex (state changes, email, logout)
- Benefits from previous features being solid
## References
- [v0.2.0 System Overview](../v0.2.0/overview.md)
- [v0.2.0 Data Model](../v0.2.0/data-model.md)
- [Product Backlog](../../BACKLOG.md)
- [Project Overview](../../PROJECT_OVERVIEW.md)
- [ADR-0002: Authentication Strategy](../../decisions/0002-authentication-strategy.md)
- [ADR-0003: Participant Session Scoping](../../decisions/0003-participant-session-scoping.md)
---
**End of Phase 3 Design Overview**

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,545 @@
# Test Plan - v0.3.0
**Version**: 0.3.0
**Date**: 2025-12-22
**Status**: Test Specification
## Overview
This document defines the comprehensive test plan for Phase 3 (Participant Self-Management). It covers unit tests, integration tests, and acceptance criteria for all user stories in scope.
## Test Pyramid
```
╱╲
╲ E2E Tests (Manual QA)
╱────╲ - Full user journeys
╲ - Cross-browser testing
╱────────╲
╲ Integration Tests (pytest)
╱────────────╲ - Route handlers
╲ - Database operations
╱────────────────╲ - Email sending
╱──────────────────╲
Unit Tests (pytest)
- Business logic functions
- Form validation
- State checks
```
## Test Coverage Goals
- **Overall coverage**: 80%+ (maintain Phase 2 level)
- **Business logic**: 95%+ (pure functions)
- **Route handlers**: 80%+ (integration tests)
- **Templates**: Manual testing (not measured by coverage)
## 1. Unit Tests
### 1.1 Participant Utility Functions
**File**: `tests/unit/test_participant_utils.py`
| Test Case | Description | Assertions |
|-----------|-------------|------------|
| `test_can_update_profile_draft_state` | Profile updates allowed in draft | `can_update_profile() == True` |
| `test_can_update_profile_registration_open` | Profile updates allowed when open | `can_update_profile() == True` |
| `test_can_update_profile_registration_closed` | Profile updates allowed when closed | `can_update_profile() == True` |
| `test_can_update_profile_matched_state` | Profile updates blocked after matching | `can_update_profile() == False` |
| `test_can_update_profile_completed_state` | Profile updates blocked when completed | `can_update_profile() == False` |
| `test_can_withdraw_draft_state` | Withdrawal allowed in draft | `can_withdraw() == True` |
| `test_can_withdraw_registration_open` | Withdrawal allowed when open | `can_withdraw() == True` |
| `test_can_withdraw_registration_closed` | Withdrawal blocked when closed | `can_withdraw() == False` |
| `test_can_withdraw_matched_state` | Withdrawal blocked after matching | `can_withdraw() == False` |
| `test_can_withdraw_already_withdrawn` | Withdrawal blocked if already withdrawn | `can_withdraw() == False` |
| `test_get_active_participants` | Returns only non-withdrawn participants | Count and names match |
| `test_get_active_participants_empty` | Returns empty list when all withdrawn | `len(participants) == 0` |
| `test_get_active_participants_ordered` | Participants ordered by name | Names in alphabetical order |
| `test_is_withdrawn_true` | Detects withdrawn participant | `is_withdrawn() == True` |
| `test_is_withdrawn_false` | Detects active participant | `is_withdrawn() == False` |
**Fixtures needed**:
```python
@pytest.fixture
def exchange_factory(db):
"""Factory for creating exchanges in different states."""
def _create(state='draft'):
exchange = Exchange(
slug=generate_slug(),
name='Test Exchange',
budget='$25-50',
max_participants=50,
registration_close_date=datetime.utcnow() + timedelta(days=7),
exchange_date=datetime.utcnow() + timedelta(days=14),
timezone='UTC',
state=state
)
db.session.add(exchange)
db.session.commit()
return exchange
return _create
@pytest.fixture
def participant_factory(db):
"""Factory for creating participants."""
def _create(exchange=None, withdrawn_at=None):
if not exchange:
exchange = Exchange(...) # Create default exchange
db.session.add(exchange)
participant = Participant(
exchange_id=exchange.id,
name='Test Participant',
email='test@example.com',
gift_ideas='Test ideas',
reminder_enabled=True,
withdrawn_at=withdrawn_at
)
db.session.add(participant)
db.session.commit()
return participant
return _create
```
### 1.2 Withdrawal Service
**File**: `tests/unit/test_withdrawal_service.py`
| Test Case | Description | Assertions |
|-----------|-------------|------------|
| `test_withdraw_participant_success` | Happy path withdrawal | `withdrawn_at` is set, email called |
| `test_withdraw_participant_already_withdrawn` | Raises error if already withdrawn | `WithdrawalError` raised |
| `test_withdraw_participant_wrong_state_closed` | Raises error if registration closed | `WithdrawalError` with specific message |
| `test_withdraw_participant_wrong_state_matched` | Raises error if already matched | `WithdrawalError` with specific message |
| `test_withdraw_participant_database_error` | Handles DB error gracefully | `WithdrawalError` raised, rollback called |
| `test_withdraw_participant_email_failure` | Continues if email fails | `withdrawn_at` set, error logged |
**Mocking strategy**:
```python
@pytest.fixture
def mock_email_service(monkeypatch):
"""Mock EmailService for withdrawal tests."""
mock = Mock()
monkeypatch.setattr('src.services.withdrawal.EmailService', lambda: mock)
return mock
```
### 1.3 Form Validation
**File**: `tests/unit/test_participant_forms.py`
| Test Case | Description | Assertions |
|-----------|-------------|------------|
| `test_profile_form_valid_data` | Valid name and gift ideas | `form.validate() == True` |
| `test_profile_form_name_required` | Name is required | Validation error on name field |
| `test_profile_form_name_too_long` | Name max 255 chars | Validation error on name field |
| `test_profile_form_gift_ideas_optional` | Gift ideas can be empty | `form.validate() == True` |
| `test_profile_form_gift_ideas_too_long` | Gift ideas max 10,000 chars | Validation error on gift_ideas field |
| `test_reminder_form_boolean_field` | Accepts boolean value | `form.validate() == True` |
| `test_withdraw_form_confirmation_required` | Confirmation required | Validation error on confirm field |
| `test_withdraw_form_confirmation_true` | Accepts confirmation | `form.validate() == True` |
## 2. Integration Tests
### 2.1 Profile Update Tests
**File**: `tests/integration/test_profile_update.py`
| Test Case | Description | Setup | Action | Expected Result |
|-----------|-------------|-------|--------|-----------------|
| `test_profile_update_get_shows_form` | GET shows edit form | Auth'd participant | GET /participant/profile/edit | 200, form with current values |
| `test_profile_update_post_success` | POST updates profile | Auth'd participant | POST with valid data | 302 redirect, flash success, DB updated |
| `test_profile_update_name_change` | Name updates in DB | Auth'd participant | POST with new name | Participant.name updated |
| `test_profile_update_gift_ideas_change` | Gift ideas update in DB | Auth'd participant | POST with new ideas | Participant.gift_ideas updated |
| `test_profile_update_clears_whitespace` | Strips leading/trailing spaces | Auth'd participant | POST with " Name " | Stored as "Name" |
| `test_profile_update_locked_after_matching` | Blocked when matched | Matched exchange | GET profile edit | 302 redirect, flash error |
| `test_profile_update_form_validation_error` | Invalid data shows errors | Auth'd participant | POST with empty name | 200, form with errors |
| `test_profile_update_csrf_required` | CSRF token required | Auth'd participant | POST without CSRF | 400 error |
| `test_profile_update_requires_auth` | Auth required | No session | GET profile edit | 302 to login |
| `test_profile_update_database_error` | Handles DB failure | Auth'd participant, mock DB error | POST valid data | Flash error, no DB change |
**Test helpers**:
```python
@pytest.fixture
def participant_session(client, participant_factory):
"""Create authenticated participant session."""
participant = participant_factory()
with client.session_transaction() as session:
session['user_id'] = participant.id
session['user_type'] = 'participant'
session['exchange_id'] = participant.exchange_id
return participant
def get_csrf_token(client, url='/participant/dashboard'):
"""Extract CSRF token from page."""
response = client.get(url)
# Parse HTML and extract token
return extract_csrf_token(response.data)
```
### 2.2 Withdrawal Tests
**File**: `tests/integration/test_withdrawal.py`
| Test Case | Description | Setup | Action | Expected Result |
|-----------|-------------|-------|--------|-----------------|
| `test_withdrawal_get_shows_form` | GET shows confirmation page | Auth'd participant | GET /participant/withdraw | 200, form with warnings |
| `test_withdrawal_post_success` | POST withdraws participant | Auth'd participant | POST with confirmation | 302 redirect, withdrawn_at set, session cleared |
| `test_withdrawal_sends_email` | Email sent on withdrawal | Auth'd participant, mock email | POST with confirmation | Email service called |
| `test_withdrawal_clears_session` | Session cleared after withdrawal | Auth'd participant | POST with confirmation | Session empty |
| `test_withdrawal_redirects_to_public` | Redirects to registration page | Auth'd participant | POST with confirmation | Redirect to /exchange/{slug}/register |
| `test_withdrawal_already_withdrawn` | Detects already withdrawn | Withdrawn participant | GET withdraw | Flash info, redirect |
| `test_withdrawal_blocked_after_close` | Blocked when registration closed | Closed exchange | GET withdraw | Flash error, redirect to dashboard |
| `test_withdrawal_blocked_after_matching` | Blocked when matched | Matched exchange | GET withdraw | Flash error, redirect to dashboard |
| `test_withdrawal_requires_confirmation` | Confirmation checkbox required | Auth'd participant | POST without confirm=True | Form errors |
| `test_withdrawal_csrf_required` | CSRF token required | Auth'd participant | POST without CSRF | 400 error |
| `test_withdrawal_requires_auth` | Auth required | No session | GET withdraw | 302 to login |
| `test_withdrawal_database_error` | Handles DB failure gracefully | Auth'd participant, mock DB error | POST valid data | Flash error, not withdrawn |
### 2.3 Reminder Preference Tests
**File**: `tests/integration/test_reminder_preferences.py`
| Test Case | Description | Setup | Action | Expected Result |
|-----------|-------------|-------|--------|-----------------|
| `test_update_preferences_enable` | Enable reminders | Auth'd participant (disabled) | POST reminder_enabled=True | Flash success, DB updated |
| `test_update_preferences_disable` | Disable reminders | Auth'd participant (enabled) | POST reminder_enabled=False | Flash success, DB updated |
| `test_update_preferences_csrf_required` | CSRF token required | Auth'd participant | POST without CSRF | 400 error |
| `test_update_preferences_requires_auth` | Auth required | No session | POST preferences | 302 to login |
| `test_update_preferences_database_error` | Handles DB failure | Auth'd participant, mock DB error | POST valid data | Flash error, no change |
### 2.4 Participant List Tests
**File**: `tests/integration/test_participant_list.py`
| Test Case | Description | Setup | Action | Expected Result |
|-----------|-------------|-------|--------|-----------------|
| `test_participant_list_shows_all_active` | Shows all active participants | 3 active participants | GET dashboard | All 3 names displayed |
| `test_participant_list_excludes_withdrawn` | Hides withdrawn participants | 2 active, 1 withdrawn | GET dashboard | Only 2 names displayed |
| `test_participant_list_shows_self_badge` | Marks current user | Auth'd participant | GET dashboard | "You" badge on own name |
| `test_participant_list_ordered_by_name` | Alphabetical order | Participants: Zoe, Alice, Bob | GET dashboard | Order: Alice, Bob, Zoe |
| `test_participant_list_count_excludes_withdrawn` | Count shows active only | 3 active, 1 withdrawn | GET dashboard | "Participants (3)" |
| `test_participant_list_empty_state` | Message when alone | Only current participant | GET dashboard | "No other participants yet" |
| `test_participant_list_requires_auth` | Auth required | No session | GET dashboard | 302 to login |
## 3. Acceptance Tests
### 3.1 Story 4.5: View Participant List (Pre-Matching)
**Acceptance Criteria**:
- ✅ Participant list visible after logging in via magic link
- ✅ Shows display names only
- ✅ Does not show email addresses
- ✅ Does not indicate any match information
- ✅ Updates as new participants register
**Manual Test Steps**:
1. **Setup**: Create exchange, register 3 participants (Alice, Bob, Charlie)
2. **Login as Alice**: Request magic link, login
3. **View Dashboard**: Participant list shows "Bob" and "Charlie" (not Alice's own name in list)
4. **Verify No Emails**: Inspect HTML, confirm no email addresses visible
5. **Register New Participant (Dave)**: Have Dave register
6. **Refresh Dashboard**: Dave now appears in Alice's participant list
7. **Bob Withdraws**: Have Bob withdraw
8. **Refresh Dashboard**: Bob no longer appears in participant list
**Expected Results**: Pass all criteria
### 3.2 Story 6.1: Update Profile
**Acceptance Criteria**:
- ✅ Edit option available when logged in
- ✅ Can update name and gift ideas
- ✅ Cannot change email (request admin help)
- ✅ Only available before matching occurs
- ✅ Confirmation after save
**Manual Test Steps**:
1. **Login as Participant**: Use magic link to access dashboard
2. **Click "Edit Profile"**: Navigate to profile edit page
3. **Verify Pre-Population**: Name and gift ideas fields show current values
4. **Update Name**: Change name from "Alice" to "Alice Smith"
5. **Update Gift Ideas**: Add new gift idea
6. **Verify Email Not Editable**: Email field not present in form
7. **Save Changes**: Submit form
8. **Verify Success Message**: "Your profile has been updated successfully"
9. **Verify Dashboard Updated**: Dashboard shows new name and gift ideas
10. **Admin Matches Exchange**: Admin triggers matching
11. **Try to Edit Profile**: Click "Edit Profile" (if visible)
12. **Verify Locked**: Error message "Your profile is locked after matching"
**Expected Results**: Pass all criteria
### 3.3 Story 6.2: Withdraw from Exchange
**Acceptance Criteria**:
- ✅ "Withdraw" option available before registration closes
- ✅ Confirmation required
- ✅ Participant removed from exchange
- ✅ Confirmation email sent
- ✅ Admin notified (if notifications enabled) - **Deferred to Phase 7**
**Manual Test Steps**:
1. **Login as Participant**: Use magic link
2. **Click "Withdraw from Exchange"**: Navigate to withdrawal page
3. **Verify Warning**: Warning box shows consequences
4. **Try Submit Without Confirmation**: Leave checkbox unchecked, submit
5. **Verify Error**: Form error requires confirmation
6. **Check Confirmation Box**: Check "I understand..." box
7. **Submit Withdrawal**: Click "Withdraw from Exchange"
8. **Verify Success Message**: "You have been withdrawn..."
9. **Verify Logged Out**: Session cleared, redirected to public page
10. **Check Email**: Withdrawal confirmation email received
11. **Login as Different Participant**: Login as Bob
12. **Check Participant List**: Withdrawn participant (Alice) not in list
13. **Admin Closes Registration**: Admin closes registration
14. **Try to Withdraw as Bob**: Navigate to withdrawal page
15. **Verify Blocked**: Error message "Registration has closed"
**Expected Results**: Pass all criteria (except admin notification - Phase 7)
### 3.4 Story 6.3: Update Reminder Preferences
**Acceptance Criteria**:
- ✅ Option to enable/disable reminder emails
- ✅ Available before exchange completes
- ✅ Changes take effect immediately
**Manual Test Steps**:
1. **Login as Participant**: Use magic link
2. **View Dashboard**: Reminder preference checkbox visible
3. **Verify Current State**: Checkbox checked (enabled by default)
4. **Uncheck Reminder Box**: Uncheck "Send me reminders"
5. **Click "Update Preferences"**: Submit form
6. **Verify Success Message**: "Reminder emails disabled"
7. **Refresh Page**: Checkbox remains unchecked
8. **Re-Enable Reminders**: Check box, submit
9. **Verify Success Message**: "Reminder emails enabled"
10. **Admin Matches Exchange**: Trigger matching
11. **Verify Still Available**: Reminder preference form still available post-matching
**Expected Results**: Pass all criteria
## 4. Edge Cases and Error Scenarios
### 4.1 Race Conditions
| Scenario | Expected Behavior | Test Method |
|----------|-------------------|-------------|
| Participant withdraws while admin is matching | Withdrawal succeeds if submitted before matching, blocked if submitted after | Manual timing test |
| Two participants update profiles simultaneously | Both succeed (no conflicts, different records) | Concurrent requests test |
| Participant updates profile while admin closes registration | Both succeed (profile lock is at matching, not close) | Manual timing test |
### 4.2 Data Validation
| Scenario | Expected Behavior | Test Method |
|----------|-------------------|-------------|
| Name with only whitespace | Validation error "Name is required" | Integration test |
| Gift ideas exactly 10,000 characters | Accepted | Integration test |
| Gift ideas 10,001 characters | Validation error | Integration test |
| Name with special characters (é, ñ, 中) | Accepted, displayed correctly | Manual test |
| XSS attempt in gift ideas | Escaped in display | Manual test |
### 4.3 Session Handling
| Scenario | Expected Behavior | Test Method |
|----------|-------------------|-------------|
| Session expires during profile edit | Redirect to login on submit | Manual test (wait for expiry) |
| Withdraw from different browser tab | Both tabs see withdrawal (one succeeds, one sees "already withdrawn") | Manual test |
| Participant deleted by admin while logged in | Next request clears session, redirects to login | Integration test |
## 5. Performance Tests
### 5.1 Query Optimization
| Test | Query Count | Execution Time |
|------|-------------|----------------|
| Load dashboard with 50 participants | 3 queries max (exchange, participant, participant list) | < 100ms |
| Update profile | 2 queries (load participant, update) | < 50ms |
| Withdraw participant | 3 queries (load, update, email) | < 100ms |
**Testing Method**: Use Flask-DebugToolbar or SQLAlchemy query logging
### 5.2 Concurrent Operations
| Test | Concurrent Requests | Expected Result |
|------|---------------------|-----------------|
| 10 participants updating profiles | 10 simultaneous | All succeed, no deadlocks |
| 5 participants viewing participant list | 5 simultaneous | All succeed, consistent data |
**Testing Method**: Use locust or manual concurrent curl requests
## 6. Browser Compatibility
**Supported Browsers** (per project requirements):
- Chrome (last 2 versions)
- Firefox (last 2 versions)
- Safari (last 2 versions)
- Edge (last 2 versions)
**Manual Tests**:
- Profile edit form renders correctly
- Character counter works (progressive enhancement)
- Withdrawal confirmation checkbox works
- CSRF tokens submitted correctly
- Flash messages display correctly
## 7. Accessibility Tests
**WCAG 2.1 AA Compliance**:
| Test | Tool | Expected Result |
|------|------|-----------------|
| Form labels | axe DevTools | All inputs have associated labels |
| Keyboard navigation | Manual | All actions accessible via keyboard |
| Screen reader | NVDA/JAWS | Forms and messages announced correctly |
| Color contrast | axe DevTools | All text meets 4.5:1 contrast ratio |
| Error messages | Manual | Errors linked to fields via ARIA |
## 8. Security Tests
### 8.1 Authentication
| Test | Method | Expected Result |
|------|--------|-----------------|
| Access profile edit without login | GET /participant/profile/edit | 302 redirect to login |
| Access withdrawal without login | GET /participant/withdraw | 302 redirect to login |
| Use expired session | Set session to past timestamp | Session cleared, redirect to login |
### 8.2 Authorization
| Test | Method | Expected Result |
|------|--------|-----------------|
| Edit another participant's profile | Manipulate user_id in session | Decorator uses session, not URL param - not vulnerable |
| Withdraw another participant | Manipulate user_id in session | Decorator uses session, not URL param - not vulnerable |
### 8.3 Input Validation
| Test | Method | Expected Result |
|------|--------|-----------------|
| SQL injection in name field | Submit `'; DROP TABLE participants; --` | Escaped, no SQL execution |
| XSS in gift ideas | Submit `<script>alert('XSS')</script>` | Escaped, rendered as text |
| CSRF attack | POST without token | 400 error |
## 9. Test Automation
### 9.1 CI Pipeline
```yaml
# .github/workflows/test.yml (example)
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install uv
run: pip install uv
- name: Install dependencies
run: uv sync
- name: Run unit tests
run: uv run pytest tests/unit -v --cov
- name: Run integration tests
run: uv run pytest tests/integration -v --cov --cov-append
- name: Coverage report
run: uv run coverage report --fail-under=80
```
### 9.2 Pre-Commit Hooks
```yaml
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: pytest
name: pytest
entry: uv run pytest tests/unit
language: system
pass_filenames: false
always_run: true
```
## 10. Test Data Management
### 10.1 Fixtures
**Location**: `tests/conftest.py`
```python
@pytest.fixture
def app():
"""Create test Flask app."""
app = create_app('testing')
with app.app_context():
db.create_all()
yield app
db.drop_all()
@pytest.fixture
def client(app):
"""Create test client."""
return app.test_client()
@pytest.fixture
def exchange(db):
"""Create test exchange."""
exchange = Exchange(...)
db.session.add(exchange)
db.session.commit()
return exchange
@pytest.fixture
def participant(db, exchange):
"""Create test participant."""
participant = Participant(exchange=exchange, ...)
db.session.add(participant)
db.session.commit()
return participant
```
### 10.2 Test Database
- Use in-memory SQLite for speed: `sqlite:///:memory:`
- Reset between tests: `db.drop_all()` + `db.create_all()`
- Factories for generating test data with variations
## 11. Regression Tests
Tests from Phase 2 that must still pass:
- ✅ Participant registration still works
- ✅ Magic link authentication still works
- ✅ Participant dashboard loads (now with participant list)
- ✅ Admin login still works
- ✅ Exchange creation still works
## 12. Success Criteria
Phase 3 tests are complete when:
1. ✅ All unit tests pass (100% pass rate)
2. ✅ All integration tests pass (100% pass rate)
3. ✅ Code coverage ≥ 80%
4. ✅ All acceptance criteria manually verified
5. ✅ No security vulnerabilities found
6. ✅ Accessibility tests pass
7. ✅ All edge cases handled gracefully
8. ✅ Performance benchmarks met
9. ✅ Browser compatibility verified
10. ✅ Phase 2 regression tests pass
---
**Test Plan Version**: 1.0
**Last Updated**: 2025-12-22
**Status**: Ready for Implementation

View File

@@ -4,6 +4,22 @@ set -e # Exit on any error
# Ensure data directory exists
mkdir -p /app/data
echo "=== Sneaky Klaus Container Startup ==="
echo "Data directory: /app/data"
echo "Database path: /app/data/sneaky-klaus.db"
# Check if database exists
if [ -f /app/data/sneaky-klaus.db ]; then
echo "Existing database found ($(ls -lh /app/data/sneaky-klaus.db | awk '{print $5}'))"
else
echo "No existing database - will be created by migrations"
fi
# List contents of data directory
echo "Data directory contents:"
ls -la /app/data/ || echo "(empty)"
echo ""
echo "Running database migrations..."
if uv run alembic upgrade head; then
echo "Database migrations completed successfully"
@@ -13,5 +29,6 @@ else
exit 1
fi
echo ""
echo "Starting application server..."
exec gunicorn --bind 0.0.0.0:8000 --workers 2 --threads 4 main:app

View File

@@ -24,7 +24,8 @@ if config.config_file_name is not None:
# Create minimal Flask app for migrations (without session initialization)
# This avoids Flask-Session trying to create tables before migrations run
app = Flask(__name__)
BASE_DIR = Path(__file__).parent.parent
# Use resolve() to ensure absolute paths - critical for database persistence
BASE_DIR = Path(__file__).parent.parent.resolve()
DATA_DIR = BASE_DIR / "data"
DATA_DIR.mkdir(parents=True, exist_ok=True)

View File

@@ -144,6 +144,9 @@ def register_setup_check(app: Flask) -> None:
"participant.magic_login",
"participant.dashboard",
"participant.logout",
"participant.profile_edit",
"participant.update_preferences",
"participant.withdraw",
]:
return

View File

@@ -12,8 +12,8 @@ from pathlib import Path
class Config:
"""Base configuration class with common settings."""
# Base paths
BASE_DIR = Path(__file__).parent.parent
# Base paths - use resolve() to ensure absolute paths
BASE_DIR = Path(__file__).parent.parent.resolve()
DATA_DIR = BASE_DIR / "data"
# Security

View File

@@ -1,7 +1,7 @@
"""Forms for participant registration and management."""
from flask_wtf import FlaskForm
from wtforms import BooleanField, EmailField, StringField, TextAreaField
from wtforms import BooleanField, EmailField, StringField, SubmitField, TextAreaField
from wtforms.validators import DataRequired, Email, Length
@@ -48,3 +48,48 @@ class MagicLinkRequestForm(FlaskForm):
Length(max=255, message="Email must be less than 255 characters"),
],
)
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},
)
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",
)
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")

View File

@@ -17,7 +17,13 @@ from flask import (
from src.app import db
from src.decorators.auth import participant_required
from src.forms.participant import MagicLinkRequestForm, ParticipantRegistrationForm
from src.forms.participant import (
MagicLinkRequestForm,
ParticipantRegistrationForm,
ProfileUpdateForm,
ReminderPreferenceForm,
WithdrawForm,
)
from src.models.exchange import Exchange
from src.models.magic_token import MagicToken
from src.models.participant import Participant
@@ -341,10 +347,198 @@ def dashboard(id: int): # noqa: A002
if not participant:
abort(404)
# Get list of active participants
from src.utils.participant import (
can_update_profile,
can_withdraw,
get_active_participants,
)
participants = get_active_participants(exchange.id)
can_edit = can_update_profile(participant)
can_leave = can_withdraw(participant)
# Create reminder preference form
reminder_form = ReminderPreferenceForm(
reminder_enabled=participant.reminder_enabled
)
return render_template(
"participant/dashboard.html",
exchange=exchange,
participant=participant,
participants=participants,
participant_count=len(participants),
can_edit_profile=can_edit,
can_withdraw=can_leave,
reminder_form=reminder_form,
)
@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
"""
from flask import current_app
# Get participant from session
participant = db.session.query(Participant).filter_by(id=session["user_id"]).first()
if not participant:
abort(404)
# 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", id=participant.exchange_id))
# 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", id=participant.exchange_id)
)
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,
)
@participant_bp.route("/participant/preferences", methods=["POST"])
@participant_required
def update_preferences():
"""Update participant reminder email preferences.
Returns:
Redirect to dashboard
"""
from flask import current_app
participant = db.session.query(Participant).filter_by(id=session["user_id"]).first()
if not participant:
abort(404)
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", id=participant.exchange_id))
@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
"""
from flask import current_app
participant = db.session.query(Participant).filter_by(id=session["user_id"]).first()
if not participant:
abort(404)
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", id=exchange.id))
# Create withdrawal confirmation form
form = WithdrawForm()
if form.validate_on_submit():
try:
# Perform withdrawal
from src.services.withdrawal import WithdrawalError, withdraw_participant
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", id=exchange.id))
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,
)

View File

@@ -174,3 +174,53 @@ class EmailService:
"""
return self.send_email(to=to, subject=subject, html_body=html_body)
def send_withdrawal_confirmation(self, participant: Any) -> Any:
"""Send withdrawal confirmation email to participant.
Args:
participant: The participant who withdrew
Returns:
Response from email service
"""
exchange = participant.exchange
subject = f"Withdrawal Confirmed - {exchange.name}"
html_body = f"""
<html>
<body>
<h2>Withdrawal Confirmed</h2>
<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>
</body>
</html>
"""
return self.send_email(
to=participant.email, subject=subject, html_body=html_body
)

View File

@@ -0,0 +1,73 @@
"""Participant withdrawal service."""
from datetime import UTC, datetime
from flask import current_app
from src.app import db
from src.models.participant import Participant
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.now(UTC)
try:
db.session.commit()
current_app.logger.info(
f"Participant {participant.id} withdrawn from "
f"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.") from e
# 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

View File

@@ -51,7 +51,7 @@
<tr>
<td>{{ exchange.name }}</td>
<td><mark>{{ exchange.state }}</mark></td>
<td>0 / {{ exchange.max_participants }}</td>
<td>{{ exchange.participants|selectattr('withdrawn_at', 'none')|list|length }} / {{ exchange.max_participants }}</td>
<td>{{ exchange.exchange_date.strftime('%Y-%m-%d') }}</td>
<td>
<a href="{{ url_for('admin.view_exchange', exchange_id=exchange.id) }}">View</a>

View File

@@ -37,8 +37,30 @@
</div>
<div class="detail-section">
<h2>Participants</h2>
<h2>Participants ({{ exchange.participants|selectattr('withdrawn_at', 'none')|list|length }} / {{ exchange.max_participants }})</h2>
{% set active_participants = exchange.participants|selectattr('withdrawn_at', 'none')|list %}
{% if active_participants %}
<table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Registered</th>
</tr>
</thead>
<tbody>
{% for participant in active_participants|sort(attribute='name') %}
<tr>
<td>{{ participant.name }}</td>
<td>{{ participant.email }}</td>
<td>{{ participant.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No participants yet.</p>
{% endif %}
</div>
<div class="actions">

View File

@@ -37,8 +37,58 @@
<dd>{{ participant.gift_ideas }}</dd>
{% endif %}
</dl>
{% if can_edit_profile %}
<a href="{{ url_for('participant.profile_edit') }}">Edit Profile</a>
{% endif %}
</section>
<section>
<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>
<section>
<h2>Email Reminders</h2>
<form method="POST" action="{{ url_for('participant.update_preferences') }}">
{{ reminder_form.hidden_tag() }}
<div>
{{ reminder_form.reminder_enabled() }}
{{ reminder_form.reminder_enabled.label }}
</div>
{% if reminder_form.reminder_enabled.description %}
<small>{{ reminder_form.reminder_enabled.description }}</small>
{% endif %}
<button type="submit">Update Preferences</button>
</form>
</section>
{% if can_withdraw %}
<section>
<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') }}" role="button" class="secondary">
Withdraw from Exchange
</a>
</section>
{% endif %}
<section>
<form method="POST" action="{{ url_for('participant.logout') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">

View File

@@ -0,0 +1,64 @@
{% extends "layouts/base.html" %}
{% block title %}Edit Profile - {{ exchange.name }}{% endblock %}
{% block content %}
<article>
<header>
<h1>Edit Your Profile</h1>
<p>Update your display name and gift ideas. Your Secret Santa will see this information after matching.</p>
</header>
<form method="POST" action="{{ url_for('participant.profile_edit') }}">
{{ form.hidden_tag() }}
<div>
{{ 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>{{ form.name.description }}</small>
</div>
<div>
{{ 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>{{ form.gift_ideas.description }}</small>
<small id="gift-ideas-count">
{{ (form.gift_ideas.data or '')|length }} / 10,000 characters
</small>
</div>
<div>
<button type="submit">Save Changes</button>
<a href="{{ url_for('participant.dashboard', id=exchange.id) }}">Cancel</a>
</div>
</form>
</article>
<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 %}

View File

@@ -0,0 +1,47 @@
{% extends "layouts/base.html" %}
{% block title %}Withdraw from {{ exchange.name }}{% endblock %}
{% block content %}
<article>
<header>
<h1>Withdraw from Exchange</h1>
</header>
<div role="alert" style="background-color: #fff3cd; border-left: 4px solid #ffc107; padding: 1rem; margin: 1rem 0;">
<h2 style="margin-top: 0;">⚠️ 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>
<label>
{{ form.confirm() }}
{{ form.confirm.label.text }}
</label>
{% if form.confirm.errors %}
<ul style="color: #dc3545; list-style: none; padding: 0;">
{% for error in form.confirm.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
<div>
{{ form.submit(class="contrast") }}
<a href="{{ url_for('participant.dashboard', id=exchange.id) }}" role="button" class="secondary">
Cancel
</a>
</div>
</form>
</article>
{% endblock %}

76
src/utils/participant.py Normal file
View File

@@ -0,0 +1,76 @@
"""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.participant import Participant
result: list[Participant] = (
Participant.query.filter(
Participant.exchange_id == exchange_id, Participant.withdrawn_at.is_(None)
)
.order_by(Participant.name)
.all()
)
return result
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
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

View File

@@ -75,3 +75,97 @@ def admin(db):
db.session.add(admin)
db.session.commit()
return admin
@pytest.fixture
def exchange_factory(db):
"""Factory for creating test exchanges.
Args:
db: Database instance.
Returns:
Function that creates and returns Exchange instances.
"""
from datetime import UTC, datetime, timedelta
from src.models.exchange import Exchange
def _create(state="draft", **kwargs):
exchange = Exchange(
slug=kwargs.get("slug", Exchange.generate_slug()),
name=kwargs.get("name", "Test Exchange"),
budget=kwargs.get("budget", "$25-50"),
max_participants=kwargs.get("max_participants", 50),
registration_close_date=kwargs.get(
"registration_close_date", datetime.now(UTC) + timedelta(days=7)
),
exchange_date=kwargs.get(
"exchange_date", datetime.now(UTC) + timedelta(days=14)
),
timezone=kwargs.get("timezone", "UTC"),
state=state,
)
db.session.add(exchange)
db.session.commit()
return exchange
return _create
@pytest.fixture
def participant_factory(db, exchange_factory):
"""Factory for creating test participants.
Args:
db: Database instance.
exchange_factory: Exchange factory fixture.
Returns:
Function that creates and returns Participant instances.
"""
from src.models.participant import Participant
counter = {"value": 0}
def _create(exchange=None, **kwargs):
if not exchange:
exchange = exchange_factory()
counter["value"] += 1
participant = Participant(
exchange_id=exchange.id,
name=kwargs.get("name", f"Test Participant {counter['value']}"),
email=kwargs.get("email", f"test{counter['value']}@example.com"),
gift_ideas=kwargs.get("gift_ideas", "Test ideas"),
reminder_enabled=kwargs.get("reminder_enabled", True),
withdrawn_at=kwargs.get("withdrawn_at"),
)
db.session.add(participant)
db.session.commit()
return participant
return _create
@pytest.fixture
def auth_participant(client, exchange_factory, participant_factory):
"""Create an authenticated participant session.
Args:
client: Flask test client.
exchange_factory: Exchange factory fixture.
participant_factory: Participant factory fixture.
Returns:
Authenticated participant instance.
"""
exchange = exchange_factory(state="registration_open")
participant = participant_factory(exchange=exchange)
with client.session_transaction() as session:
session["user_id"] = participant.id
session["user_type"] = "participant"
session["exchange_id"] = exchange.id
return participant

View File

@@ -0,0 +1,135 @@
"""Integration tests for participant list functionality."""
from datetime import UTC, datetime
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", id=exchange.id))
assert response.status_code == 200
assert b"Bob" in response.data
assert b"Charlie" in response.data
assert b"Participants (3)" 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.now(UTC)
)
response = client.get(url_for("participant.dashboard", id=exchange.id))
assert response.status_code == 200
assert b"Active" in response.data
assert b"Withdrawn" not in response.data
# Count should be 2 (auth_participant + Active)
assert b"Participants (2)" in response.data
def test_participant_list_shows_self_badge(client, auth_participant):
"""Test participant list marks current user with badge."""
exchange = auth_participant.exchange
response = client.get(url_for("participant.dashboard", id=exchange.id))
assert response.status_code == 200
# Should show the participant's name
assert auth_participant.name.encode() in response.data
# Should show "You" badge
assert b"You" in response.data
def test_participant_list_ordered_by_name(
client, auth_participant, participant_factory
):
"""Test participants are shown in alphabetical order."""
exchange = auth_participant.exchange
# Create participants with names that should be sorted
participant_factory(exchange=exchange, name="Zoe")
participant_factory(exchange=exchange, name="Alice")
participant_factory(exchange=exchange, name="Bob")
response = client.get(url_for("participant.dashboard", id=exchange.id))
assert response.status_code == 200
# Check order by finding positions in response
data = response.data.decode()
alice_pos = data.find("Alice")
bob_pos = data.find("Bob")
zoe_pos = data.find("Zoe")
# All should be present
assert alice_pos > 0
assert bob_pos > 0
assert zoe_pos > 0
# Should be in alphabetical order
assert alice_pos < bob_pos < zoe_pos
def test_participant_list_empty_state(client, exchange_factory, participant_factory):
"""Test message shown when only one participant."""
exchange = exchange_factory(state="registration_open")
participant = participant_factory(exchange=exchange)
# Create session for the only participant
with client.session_transaction() as session:
session["user_id"] = participant.id
session["user_type"] = "participant"
session["exchange_id"] = exchange.id
response = client.get(url_for("participant.dashboard", id=exchange.id))
assert response.status_code == 200
assert b"Participants (1)" in response.data
# Should show participant's own name
assert participant.name.encode() in response.data
def test_participant_list_requires_auth(client, exchange_factory):
"""Test participant list requires authentication."""
exchange = exchange_factory()
response = client.get(
url_for("participant.dashboard", id=exchange.id), follow_redirects=False
)
# Should redirect to login (or show error)
assert response.status_code in [302, 403]
def test_participant_list_different_exchange(
client, auth_participant, exchange_factory, participant_factory
):
"""Test participants filtered by exchange."""
exchange1 = auth_participant.exchange
exchange2 = exchange_factory(state="registration_open")
# Create participant in different exchange
participant_factory(exchange=exchange2, name="Other Exchange")
response = client.get(url_for("participant.dashboard", id=exchange1.id))
assert response.status_code == 200
# Should not show participant from other exchange
assert b"Other Exchange" not in response.data

View File

@@ -0,0 +1,125 @@
"""Integration tests for profile update functionality."""
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"},
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_name_change(client, auth_participant, db):
"""Name updates in database."""
original_name = auth_participant.name
client.post(
url_for("participant.profile_edit"),
data={"name": "New Name", "gift_ideas": ""},
follow_redirects=True,
)
db.session.refresh(auth_participant)
assert auth_participant.name == "New Name"
assert auth_participant.name != original_name
def test_profile_update_locked_after_matching(
client, exchange_factory, participant_factory
):
"""Profile edit blocked after matching."""
exchange = exchange_factory(state="matched")
participant = participant_factory(exchange=exchange)
with client.session_transaction() as session:
session["user_id"] = participant.id
session["user_type"] = "participant"
session["exchange_id"] = exchange.id
response = client.get(url_for("participant.profile_edit"), follow_redirects=True)
assert b"profile is locked" in response.data
def test_profile_update_form_validation_empty_name(client, auth_participant):
"""Empty name shows validation error."""
# auth_participant fixture sets up the session
_ = auth_participant # Mark as used
response = client.post(
url_for("participant.profile_edit"), data={"name": "", "gift_ideas": "Test"}
)
assert response.status_code == 200
assert (
b"Name is required" in response.data
or b"This field is required" in response.data
)
def test_profile_update_requires_auth(client):
"""Profile edit requires authentication."""
response = client.get(url_for("participant.profile_edit"), follow_redirects=False)
assert response.status_code == 403
def test_profile_update_strips_whitespace(client, auth_participant, db):
"""Whitespace is stripped from name and gift ideas."""
client.post(
url_for("participant.profile_edit"),
data={"name": " Spaces ", "gift_ideas": " Gift "},
follow_redirects=True,
)
db.session.refresh(auth_participant)
assert auth_participant.name == "Spaces"
assert auth_participant.gift_ideas == "Gift"
def test_dashboard_shows_edit_link_when_allowed(client, auth_participant):
"""Dashboard shows edit profile link when editing is allowed."""
response = client.get(
url_for("participant.dashboard", id=auth_participant.exchange_id)
)
assert response.status_code == 200
assert b"Edit Profile" in response.data
def test_dashboard_hides_edit_link_after_matching(
client, exchange_factory, participant_factory
):
"""Dashboard hides edit profile link after matching."""
exchange = exchange_factory(state="matched")
participant = participant_factory(exchange=exchange)
with client.session_transaction() as session:
session["user_id"] = participant.id
session["user_type"] = "participant"
session["exchange_id"] = exchange.id
response = client.get(url_for("participant.dashboard", id=exchange.id))
assert response.status_code == 200
# Edit link should not be present
assert b"Edit Profile" not in response.data

View File

@@ -0,0 +1,83 @@
"""Integration tests for reminder preferences."""
from flask import url_for
def get_csrf_token(client, url):
"""Extract CSRF token from a page.
Args:
client: Flask test client.
url: URL to fetch the CSRF token from.
Returns:
CSRF token string.
"""
response = client.get(url)
# Extract CSRF token from the form
data = response.data.decode()
start = data.find('name="csrf_token" value="') + len('name="csrf_token" value="')
end = data.find('"', start)
return data[start:end]
def test_dashboard_shows_reminder_preference_form(client, auth_participant):
"""Test that dashboard shows reminder preference form."""
response = client.get(
url_for("participant.dashboard", id=auth_participant.exchange_id)
)
assert response.status_code == 200
assert b"Email Reminders" in response.data or b"Send me reminders" in response.data
def test_update_preferences_enable(client, auth_participant, db):
"""Enable reminder emails."""
auth_participant.reminder_enabled = False
db.session.commit()
csrf_token = get_csrf_token(
client, url_for("participant.dashboard", id=auth_participant.exchange_id)
)
response = client.post(
url_for("participant.update_preferences"),
data={"reminder_enabled": True, "csrf_token": csrf_token},
follow_redirects=True,
)
assert response.status_code == 200
assert b"Reminder emails enabled" in response.data
db.session.refresh(auth_participant)
assert auth_participant.reminder_enabled is True
def test_update_preferences_disable(client, auth_participant, db):
"""Disable reminder emails."""
auth_participant.reminder_enabled = True
db.session.commit()
csrf_token = get_csrf_token(
client, url_for("participant.dashboard", id=auth_participant.exchange_id)
)
response = client.post(
url_for("participant.update_preferences"),
data={"csrf_token": csrf_token},
follow_redirects=True,
)
assert response.status_code == 200
assert b"Reminder emails disabled" in response.data
db.session.refresh(auth_participant)
assert auth_participant.reminder_enabled is False
def test_update_preferences_requires_login(client):
"""Test that update_preferences requires login."""
response = client.post(url_for("participant.update_preferences"))
# Should redirect to login or show error
assert response.status_code in [302, 401, 403]

View File

@@ -0,0 +1,172 @@
"""Integration tests for withdrawal functionality."""
from datetime import datetime
from flask import url_for
from src.models.participant import Participant
def get_csrf_token(client, url):
"""Extract CSRF token from a page.
Args:
client: Flask test client.
url: URL to fetch the CSRF token from.
Returns:
CSRF token string.
"""
response = client.get(url)
# Extract CSRF token from the form
data = response.data.decode()
start = data.find('name="csrf_token" value="') + len('name="csrf_token" value="')
end = data.find('"', start)
return data[start:end]
def test_withdrawal_get_shows_confirmation_page(client, auth_participant): # noqa: ARG001
"""Test GET shows withdrawal confirmation page."""
response = client.get(url_for("participant.withdraw"))
assert response.status_code == 200
assert b"Withdraw from Exchange" in response.data
assert b"Are you sure" in response.data or b"cannot be undone" in response.data
def test_withdrawal_post_success(client, auth_participant, db):
"""Test successful withdrawal flow."""
participant_id = auth_participant.id
csrf_token = get_csrf_token(client, url_for("participant.withdraw"))
response = client.post(
url_for("participant.withdraw"),
data={"confirm": True, "csrf_token": csrf_token},
follow_redirects=True,
)
assert response.status_code == 200
assert b"withdrawn from the exchange" in response.data
# Verify database
participant = db.session.query(Participant).filter_by(id=participant_id).first()
assert participant.withdrawn_at is not None
# Verify session cleared
with client.session_transaction() as session:
assert "user_id" not in session
def test_withdrawal_requires_confirmation(client, auth_participant): # noqa: ARG001
"""Test withdrawal requires checkbox confirmation."""
csrf_token = get_csrf_token(client, url_for("participant.withdraw"))
response = client.post(
url_for("participant.withdraw"),
data={"csrf_token": csrf_token},
follow_redirects=False,
)
# Should re-render form with validation error
assert response.status_code == 200
assert b"confirm" in response.data.lower()
def test_withdrawal_blocked_after_registration_closes(
client, exchange_factory, participant_factory
):
"""Test withdrawal blocked after registration closes."""
exchange = exchange_factory(state="registration_closed")
participant = participant_factory(exchange=exchange)
with client.session_transaction() as session:
session["user_id"] = participant.id
session["user_type"] = "participant"
session["exchange_id"] = exchange.id
response = client.get(url_for("participant.withdraw"), follow_redirects=True)
assert (
b"Registration has closed" in response.data
or b"Contact the admin" in response.data
)
def test_withdrawal_blocked_after_matching(
client, exchange_factory, participant_factory
):
"""Test withdrawal blocked after matching occurs."""
exchange = exchange_factory(state="matched")
participant = participant_factory(exchange=exchange)
with client.session_transaction() as session:
session["user_id"] = participant.id
session["user_type"] = "participant"
session["exchange_id"] = exchange.id
response = client.get(url_for("participant.withdraw"), follow_redirects=True)
assert (
b"Withdrawal is no longer available" in response.data
or b"Contact the admin" in response.data
)
def test_withdrawal_requires_login(client):
"""Test that withdrawal requires login."""
response = client.get(url_for("participant.withdraw"))
# Should redirect or show error
assert response.status_code in [302, 401, 403]
def test_dashboard_shows_withdraw_link_when_allowed(client, auth_participant):
"""Dashboard shows withdraw link when withdrawal is allowed."""
response = client.get(
url_for("participant.dashboard", id=auth_participant.exchange_id)
)
assert response.status_code == 200
assert b"Withdraw" in response.data
def test_dashboard_hides_withdraw_link_after_close(
client, exchange_factory, participant_factory
):
"""Dashboard hides withdraw link after registration closes."""
exchange = exchange_factory(state="registration_closed")
participant = participant_factory(exchange=exchange)
with client.session_transaction() as session:
session["user_id"] = participant.id
session["user_type"] = "participant"
session["exchange_id"] = exchange.id
response = client.get(url_for("participant.dashboard", id=exchange.id))
assert response.status_code == 200
# Withdraw link should not be present
assert b"Withdraw from Exchange" not in response.data
def test_already_withdrawn_redirects_to_register(
client,
exchange_factory,
participant_factory,
db, # noqa: ARG001
):
"""Test that already withdrawn participants are redirected to register page."""
exchange = exchange_factory(state="registration_open")
participant = participant_factory(exchange=exchange, withdrawn_at=datetime.utcnow())
with client.session_transaction() as session:
session["user_id"] = participant.id
session["user_type"] = "participant"
session["exchange_id"] = exchange.id
response = client.get(url_for("participant.withdraw"), follow_redirects=True)
assert b"already withdrawn" in response.data
# Should be on registration page
assert exchange.name.encode() in response.data

View File

@@ -0,0 +1,158 @@
"""Unit tests for participant utility functions."""
from datetime import UTC, datetime
from src.utils.participant import (
can_update_profile,
can_withdraw,
get_active_participants,
is_withdrawn,
)
def test_get_active_participants_excludes_withdrawn(
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.now(UTC)
)
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(exchange_factory, participant_factory):
"""Test that participants are ordered alphabetically."""
exchange = exchange_factory()
participant_factory(exchange=exchange, name="Zoe")
participant_factory(exchange=exchange, name="Alice")
participant_factory(exchange=exchange, name="Bob")
participants = get_active_participants(exchange.id)
assert len(participants) == 3
assert participants[0].name == "Alice"
assert participants[1].name == "Bob"
assert participants[2].name == "Zoe"
def test_get_active_participants_empty_when_all_withdrawn(
exchange_factory, participant_factory
):
"""Test that empty list returned when all participants withdrawn."""
exchange = exchange_factory()
participant_factory(exchange=exchange, withdrawn_at=datetime.now(UTC))
participant_factory(exchange=exchange, withdrawn_at=datetime.now(UTC))
participants = get_active_participants(exchange.id)
assert len(participants) == 0
def test_get_active_participants_different_exchanges(
exchange_factory, participant_factory
):
"""Test that participants are filtered by exchange_id."""
exchange1 = exchange_factory()
exchange2 = exchange_factory()
participant_factory(exchange=exchange1, name="Alice")
participant_factory(exchange=exchange2, name="Bob")
participants = get_active_participants(exchange1.id)
assert len(participants) == 1
assert participants[0].name == "Alice"
def test_is_withdrawn_true(participant_factory):
"""Test is_withdrawn returns True for withdrawn participant."""
participant = participant_factory(withdrawn_at=datetime.now(UTC))
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
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
def test_can_withdraw_draft_state(participant_factory, exchange_factory):
"""Withdrawal allowed in draft state."""
exchange = exchange_factory(state="draft")
participant = participant_factory(exchange=exchange)
assert can_withdraw(participant) is True
def test_can_withdraw_registration_open(participant_factory, exchange_factory):
"""Withdrawal allowed when registration open."""
exchange = exchange_factory(state="registration_open")
participant = participant_factory(exchange=exchange)
assert can_withdraw(participant) is True
def test_can_withdraw_registration_closed(participant_factory, exchange_factory):
"""Withdrawal blocked when registration closed."""
exchange = exchange_factory(state="registration_closed")
participant = participant_factory(exchange=exchange)
assert can_withdraw(participant) is False
def test_can_withdraw_matched_state(participant_factory, exchange_factory):
"""Withdrawal blocked after matching."""
exchange = exchange_factory(state="matched")
participant = participant_factory(exchange=exchange)
assert can_withdraw(participant) is False
def test_can_withdraw_already_withdrawn(participant_factory):
"""Withdrawal blocked if already withdrawn."""
participant = participant_factory(withdrawn_at=datetime.now(UTC))
assert can_withdraw(participant) is False

View File

@@ -0,0 +1,67 @@
"""Unit tests for withdrawal service."""
from datetime import datetime
from unittest.mock import patch
import pytest
from src.services.withdrawal import WithdrawalError, withdraw_participant
def test_withdraw_participant_success(participant_factory, db, app): # noqa: ARG001
"""Test successful withdrawal."""
with app.app_context():
participant = participant_factory()
with patch("src.services.withdrawal.EmailService") as mock_email_service:
withdraw_participant(participant)
assert participant.withdrawn_at is not None
mock_email_service.return_value.send_withdrawal_confirmation.assert_called_once()
def test_withdraw_participant_already_withdrawn(participant_factory, app):
"""Test error when already withdrawn."""
with app.app_context():
participant = participant_factory(withdrawn_at=datetime.utcnow())
with pytest.raises(WithdrawalError, match="already withdrawn"):
withdraw_participant(participant)
def test_withdraw_participant_registration_closed(
exchange_factory, participant_factory, app
):
"""Test error when registration is closed."""
with app.app_context():
exchange = exchange_factory(state="registration_closed")
participant = participant_factory(exchange=exchange)
with pytest.raises(WithdrawalError, match="Registration has closed"):
withdraw_participant(participant)
def test_withdraw_participant_after_matching(
exchange_factory, participant_factory, app
):
"""Test error when matching has occurred."""
with app.app_context():
exchange = exchange_factory(state="matched")
participant = participant_factory(exchange=exchange)
with pytest.raises(WithdrawalError, match="Matching has already occurred"):
withdraw_participant(participant)
def test_withdraw_participant_sets_timestamp(participant_factory, db, app): # noqa: ARG001
"""Test that withdrawal sets timestamp correctly."""
with app.app_context():
participant = participant_factory()
with patch("src.services.withdrawal.EmailService"):
before = datetime.utcnow()
withdraw_participant(participant)
after = datetime.utcnow()
assert participant.withdrawn_at is not None
assert before <= participant.withdrawn_at <= after