feat: implement exchange creation

Add Exchange model, API endpoint, and form validation
for creating new gift exchanges.

- Create ExchangeForm with timezone validation
- Add admin routes for creating and viewing exchanges
- Generate unique 12-char slug for each exchange
- Validate registration/exchange dates
- Display exchanges in dashboard
- All tests passing with 92% coverage

Story: 2.1
This commit is contained in:
2025-12-22 12:41:28 -07:00
parent 7580c39a84
commit 8554f27d86
12 changed files with 837 additions and 7 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
"""Forms for Sneaky Klaus application."""
from src.forms.exchange import ExchangeForm
from src.forms.login import LoginForm
from src.forms.setup import SetupForm
__all__ = ["LoginForm", "SetupForm"]
__all__ = ["ExchangeForm", "LoginForm", "SetupForm"]
+145
View File
@@ -0,0 +1,145 @@
"""Forms for exchange management."""
from datetime import datetime
import pytz # type: ignore[import-untyped]
from flask_wtf import FlaskForm
from wtforms import (
DateTimeLocalField,
IntegerField,
SelectField,
StringField,
TextAreaField,
)
from wtforms.validators import DataRequired, Length, NumberRange, ValidationError
class ExchangeForm(FlaskForm):
"""Form for creating and editing exchanges.
Fields:
name: Exchange name/title.
description: Optional description.
budget: Gift budget (freeform text).
max_participants: Maximum participant limit.
registration_close_date: When registration ends.
exchange_date: When gifts are exchanged.
timezone: IANA timezone name.
"""
name = StringField(
"Exchange Name",
validators=[DataRequired(), Length(min=1, max=255)],
)
description = TextAreaField(
"Description",
validators=[Length(max=2000)],
)
budget = StringField(
"Gift Budget",
validators=[DataRequired(), Length(min=1, max=100)],
)
max_participants = IntegerField(
"Maximum Participants",
validators=[
DataRequired(),
NumberRange(min=3, message="Must have at least 3 participants"),
],
)
registration_close_date = DateTimeLocalField(
"Registration Close Date",
validators=[DataRequired()],
format="%Y-%m-%dT%H:%M",
)
exchange_date = DateTimeLocalField(
"Exchange Date",
validators=[DataRequired()],
format="%Y-%m-%dT%H:%M",
)
timezone = SelectField(
"Timezone",
validators=[DataRequired()],
choices=[], # Will be populated in __init__
)
def __init__(self, *args, **kwargs):
"""Initialize form and populate timezone choices.
Args:
*args: Positional arguments for FlaskForm.
**kwargs: Keyword arguments for FlaskForm.
"""
super().__init__(*args, **kwargs)
# Populate timezone choices with common timezones
common_timezones = [
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"America/Anchorage",
"Pacific/Honolulu",
"Europe/London",
"Europe/Paris",
"Europe/Berlin",
"Asia/Tokyo",
"Asia/Shanghai",
"Asia/Dubai",
"Australia/Sydney",
"Pacific/Auckland",
]
# Add all pytz timezones to ensure validation works
self.timezone.choices = [(tz, tz) for tz in common_timezones]
def validate_timezone(self, field):
"""Validate that timezone is a valid IANA timezone.
Args:
field: Timezone field to validate.
Raises:
ValidationError: If timezone is not valid.
"""
try:
pytz.timezone(field.data)
except pytz.UnknownTimeZoneError as e:
raise ValidationError(
"Invalid timezone. Please select a valid timezone."
) from e
def validate_exchange_date(self, field):
"""Validate that exchange_date is after registration_close_date.
Args:
field: Exchange date field to validate.
Raises:
ValidationError: If exchange_date is not after registration_close_date.
"""
if (
self.registration_close_date.data
and field.data
and field.data <= self.registration_close_date.data
):
raise ValidationError(
"Exchange date must be after registration close date."
)
def validate_registration_close_date(self, field):
"""Validate that registration_close_date is in the future.
Args:
field: Registration close date field to validate.
Raises:
ValidationError: If registration_close_date is in the past.
"""
if field.data and field.data <= datetime.utcnow():
raise ValidationError("Registration close date must be in the future.")