diff --git a/src/app.py b/src/app.py index 0b33386..0cee4f3 100644 --- a/src/app.py +++ b/src/app.py @@ -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 diff --git a/src/forms/participant.py b/src/forms/participant.py index 91df24d..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 @@ -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") diff --git a/src/routes/participant.py b/src/routes/participant.py index 7fa5d80..e794e46 100644 --- a/src/routes/participant.py +++ b/src/routes/participant.py @@ -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, ) 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 1a4b6e1..b720c79 100644 --- a/src/templates/participant/dashboard.html +++ b/src/templates/participant/dashboard.html @@ -37,8 +37,58 @@No other participants yet. Share the registration link!
+ {% endif %} ++ If you can no longer participate, you can withdraw from this exchange. + This cannot be undone. +
+ + Withdraw from Exchange + +