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>
This commit is contained in:
2025-12-22 21:18:18 -07:00
parent 4fbb681e03
commit c2b3641d74
12 changed files with 725 additions and 2 deletions

View File

@@ -21,6 +21,8 @@ from src.forms.participant import (
MagicLinkRequestForm,
ParticipantRegistrationForm,
ProfileUpdateForm,
ReminderPreferenceForm,
WithdrawForm,
)
from src.models.exchange import Exchange
from src.models.magic_token import MagicToken
@@ -346,10 +348,20 @@ def dashboard(id: int): # noqa: A002
abort(404)
# Get list of active participants
from src.utils.participant import can_update_profile, get_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",
@@ -358,6 +370,8 @@ def dashboard(id: int): # noqa: A002
participants=participants,
participant_count=len(participants),
can_edit_profile=can_edit,
can_withdraw=can_leave,
reminder_form=reminder_form,
)
@@ -420,6 +434,114 @@ def profile_edit():
)
@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,
)
@participant_bp.route("/participant/logout", methods=["POST"])
def logout():
"""Participant logout.