diff --git a/src/app.py b/src/app.py index 5bb5ec3..0cee4f3 100644 --- a/src/app.py +++ b/src/app.py @@ -145,6 +145,8 @@ def register_setup_check(app: Flask) -> None: "participant.dashboard", "participant.logout", "participant.profile_edit", + "participant.update_preferences", + "participant.withdraw", ]: return diff --git a/src/forms/participant.py b/src/forms/participant.py index 9a1cb9f..c9a7bca 100644 --- a/src/forms/participant.py +++ b/src/forms/participant.py @@ -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 @@ -70,3 +70,26 @@ class ProfileUpdateForm(FlaskForm): 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") diff --git a/src/routes/participant.py b/src/routes/participant.py index c592c60..e794e46 100644 --- a/src/routes/participant.py +++ b/src/routes/participant.py @@ -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. diff --git a/src/services/email.py b/src/services/email.py index 539ddcc..febb696 100644 --- a/src/services/email.py +++ b/src/services/email.py @@ -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""" + +
+Hello {participant.name},
++ This email confirms that you have withdrawn from the + Secret Santa exchange {exchange.name}. +
+What happens now:
++ If you withdrew by mistake, you can re-register using a + different email address while registration is still open. +
++ This is an automated message from Sneaky Klaus. +
+ + + """ + + return self.send_email( + to=participant.email, subject=subject, html_body=html_body + ) diff --git a/src/services/withdrawal.py b/src/services/withdrawal.py new file mode 100644 index 0000000..d4f206f --- /dev/null +++ b/src/services/withdrawal.py @@ -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 diff --git a/src/templates/participant/dashboard.html b/src/templates/participant/dashboard.html index 2927ae7..b720c79 100644 --- a/src/templates/participant/dashboard.html +++ b/src/templates/participant/dashboard.html @@ -61,6 +61,34 @@ {% endif %} ++ If you can no longer participate, you can withdraw from this exchange. + This cannot be undone. +
+ + Withdraw from Exchange + +