diff --git a/pyproject.toml b/pyproject.toml index e0ba307..695aaff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,3 +91,15 @@ warn_return_any = true warn_unused_configs = true disallow_untyped_defs = false ignore_missing_imports = true + +[dependency-groups] +dev = [ + "mypy>=1.19.1", + "pre-commit>=4.5.1", + "pytest>=9.0.2", + "pytest-cov>=7.0.0", + "pytest-flask>=1.3.0", + "ruff>=0.14.10", + "types-flask>=1.1.6", + "types-pytz>=2025.2.0.20251108", +] diff --git a/src/app.py b/src/app.py index cf3f8a4..ff19271 100644 --- a/src/app.py +++ b/src/app.py @@ -57,10 +57,12 @@ def create_app(config_name: str | None = None) -> Flask: # Register blueprints from src.routes.admin import admin_bp + from src.routes.participant import participant_bp from src.routes.setup import setup_bp app.register_blueprint(setup_bp) app.register_blueprint(admin_bp) + app.register_blueprint(participant_bp) # Register error handlers register_error_handlers(app) diff --git a/src/forms/__init__.py b/src/forms/__init__.py index eba08a8..2cb2b2e 100644 --- a/src/forms/__init__.py +++ b/src/forms/__init__.py @@ -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"] diff --git a/src/forms/exchange.py b/src/forms/exchange.py new file mode 100644 index 0000000..14fb83d --- /dev/null +++ b/src/forms/exchange.py @@ -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.") diff --git a/src/routes/__init__.py b/src/routes/__init__.py index c68bff9..d5bf62a 100644 --- a/src/routes/__init__.py +++ b/src/routes/__init__.py @@ -1,5 +1,7 @@ """Route blueprints for Sneaky Klaus application.""" +from src.routes.admin import admin_bp +from src.routes.participant import participant_bp from src.routes.setup import setup_bp -__all__ = ["setup_bp"] +__all__ = ["admin_bp", "participant_bp", "setup_bp"] diff --git a/src/routes/admin.py b/src/routes/admin.py index e9ca3dd..bec5e3f 100644 --- a/src/routes/admin.py +++ b/src/routes/admin.py @@ -6,8 +6,8 @@ from flask import Blueprint, flash, redirect, render_template, session, url_for from src.app import bcrypt, db from src.decorators import admin_required -from src.forms import LoginForm -from src.models import Admin +from src.forms import ExchangeForm, LoginForm +from src.models import Admin, Exchange from src.utils import check_rate_limit, increment_rate_limit, reset_rate_limit admin_bp = Blueprint("admin", __name__, url_prefix="/admin") @@ -100,4 +100,83 @@ def dashboard(): Returns: Rendered admin dashboard template. """ - return render_template("admin/dashboard.html") + # Get all exchanges ordered by exchange_date + exchanges = db.session.query(Exchange).order_by(Exchange.exchange_date.asc()).all() + + # Count exchanges by state + active_count = sum( + 1 + for e in exchanges + if e.state + in [ + Exchange.STATE_REGISTRATION_OPEN, + Exchange.STATE_REGISTRATION_CLOSED, + Exchange.STATE_MATCHED, + ] + ) + completed_count = sum(1 for e in exchanges if e.state == Exchange.STATE_COMPLETED) + draft_count = sum(1 for e in exchanges if e.state == Exchange.STATE_DRAFT) + + return render_template( + "admin/dashboard.html", + exchanges=exchanges, + active_count=active_count, + completed_count=completed_count, + draft_count=draft_count, + ) + + +@admin_bp.route("/exchange/new", methods=["GET", "POST"]) +@admin_required +def create_exchange(): + """Create a new exchange. + + GET: Display exchange creation form. + POST: Process form and create exchange. + + Returns: + On GET: Rendered form template. + On POST success: Redirect to exchange detail page. + On POST error: Re-render form with errors. + """ + form = ExchangeForm() + + if form.validate_on_submit(): + # Generate unique slug + slug = Exchange.generate_slug() + + # Create new exchange + exchange = Exchange( + slug=slug, + name=form.name.data, + description=form.description.data or None, + budget=form.budget.data, + max_participants=form.max_participants.data, + registration_close_date=form.registration_close_date.data, + exchange_date=form.exchange_date.data, + timezone=form.timezone.data, + state=Exchange.STATE_DRAFT, + ) + + db.session.add(exchange) + db.session.commit() + + flash("Exchange created successfully!", "success") + return redirect(url_for("admin.view_exchange", exchange_id=exchange.id)) + + return render_template("admin/exchange_form.html", form=form, is_edit=False) + + +@admin_bp.route("/exchange/") +@admin_required +def view_exchange(exchange_id): + """View exchange details. + + Args: + exchange_id: ID of the exchange to view. + + Returns: + Rendered exchange detail template. + """ + exchange = db.session.query(Exchange).get_or_404(exchange_id) + return render_template("admin/exchange_detail.html", exchange=exchange) diff --git a/src/routes/participant.py b/src/routes/participant.py new file mode 100644 index 0000000..d1ef6ac --- /dev/null +++ b/src/routes/participant.py @@ -0,0 +1,18 @@ +"""Participant routes for Sneaky Klaus application.""" + +from flask import Blueprint + +participant_bp = Blueprint("participant", __name__, url_prefix="") + + +@participant_bp.route("/exchange//register") +def register(slug): + """Participant registration page (stub for now). + + Args: + slug: Exchange registration slug. + + Returns: + Rendered registration page template. + """ + return f"Registration page for exchange: {slug}" diff --git a/src/templates/admin/dashboard.html b/src/templates/admin/dashboard.html index 947c0d9..41585ea 100644 --- a/src/templates/admin/dashboard.html +++ b/src/templates/admin/dashboard.html @@ -12,8 +12,56 @@ -

Welcome to the Sneaky Klaus admin dashboard!

+
+
+
+

Draft

+

{{ draft_count }}

+
+
+

Active

+

{{ active_count }}

+
+
+

Completed

+

{{ completed_count }}

+
+
+
-

This is a placeholder for the admin dashboard. More features coming soon.

+
+ Create New Exchange +
+ +

All Exchanges

+ + {% if exchanges %} + + + + + + + + + + + + {% for exchange in exchanges %} + + + + + + + + {% endfor %} + +
NameStateParticipantsExchange DateActions
{{ exchange.name }}{{ exchange.state }}0 / {{ exchange.max_participants }}{{ exchange.exchange_date.strftime('%Y-%m-%d') }} + View +
+ {% else %} +

No exchanges yet. Create your first exchange!

+ {% endif %} {% endblock %} diff --git a/src/templates/admin/exchange_detail.html b/src/templates/admin/exchange_detail.html new file mode 100644 index 0000000..1548f14 --- /dev/null +++ b/src/templates/admin/exchange_detail.html @@ -0,0 +1,58 @@ +{% extends "layouts/base.html" %} + +{% block title %}{{ exchange.name }}{% endblock %} + +{% block content %} +
+

{{ exchange.name }}

+ +
+
+

Details

+
+
State
+
{{ exchange.state }}
+ +
Description
+
{{ exchange.description or "No description" }}
+ +
Budget
+
{{ exchange.budget }}
+ +
Max Participants
+
{{ exchange.max_participants }}
+ +
Registration Close Date
+
{{ exchange.registration_close_date.strftime('%Y-%m-%d %H:%M') }} {{ exchange.timezone }}
+ +
Exchange Date
+
{{ exchange.exchange_date.strftime('%Y-%m-%d %H:%M') }} {{ exchange.timezone }}
+ +
Registration Link
+
+ {{ url_for('participant.register', slug=exchange.slug, _external=True) }} + +
+
+
+ +
+

Participants

+

No participants yet.

+
+ + +
+
+ + +{% endblock %} diff --git a/src/templates/admin/exchange_form.html b/src/templates/admin/exchange_form.html new file mode 100644 index 0000000..f3efc4f --- /dev/null +++ b/src/templates/admin/exchange_form.html @@ -0,0 +1,104 @@ +{% extends "layouts/base.html" %} + +{% block title %}{% if is_edit %}Edit Exchange{% else %}Create Exchange{% endif %}{% endblock %} + +{% block content %} +
+

{% if is_edit %}Edit Exchange{% else %}Create New Exchange{% endif %}

+ +
+ {{ form.hidden_tag() }} + +
+ {{ form.name.label }} + {{ form.name(class="form-control") }} + {% if form.name.errors %} +
+ {% for error in form.name.errors %} + {{ error }} + {% endfor %} +
+ {% endif %} +
+ +
+ {{ form.description.label }} + {{ form.description(class="form-control", rows=4) }} + {% if form.description.errors %} +
+ {% for error in form.description.errors %} + {{ error }} + {% endfor %} +
+ {% endif %} +
+ +
+ {{ form.budget.label }} + {{ form.budget(class="form-control", placeholder="e.g., $20-30") }} + {% if form.budget.errors %} +
+ {% for error in form.budget.errors %} + {{ error }} + {% endfor %} +
+ {% endif %} +
+ +
+ {{ form.max_participants.label }} + {{ form.max_participants(class="form-control", min=3) }} + {% if form.max_participants.errors %} +
+ {% for error in form.max_participants.errors %} + {{ error }} + {% endfor %} +
+ {% endif %} +
+ +
+ {{ form.registration_close_date.label }} + {{ form.registration_close_date(class="form-control") }} + {% if form.registration_close_date.errors %} +
+ {% for error in form.registration_close_date.errors %} + {{ error }} + {% endfor %} +
+ {% endif %} +
+ +
+ {{ form.exchange_date.label }} + {{ form.exchange_date(class="form-control") }} + {% if form.exchange_date.errors %} +
+ {% for error in form.exchange_date.errors %} + {{ error }} + {% endfor %} +
+ {% endif %} +
+ +
+ {{ form.timezone.label }} + {{ form.timezone(class="form-control") }} + {% if form.timezone.errors %} +
+ {% for error in form.timezone.errors %} + {{ error }} + {% endfor %} +
+ {% endif %} +
+ +
+ + Cancel +
+
+
+{% endblock %} diff --git a/tests/integration/test_create_exchange.py b/tests/integration/test_create_exchange.py new file mode 100644 index 0000000..9082998 --- /dev/null +++ b/tests/integration/test_create_exchange.py @@ -0,0 +1,337 @@ +"""Integration tests for Story 2.1: Create Exchange.""" + +from datetime import datetime, timedelta + +from src.models import Exchange + + +class TestCreateExchange: + """Test cases for exchange creation flow (Story 2.1).""" + + def test_create_exchange_form_renders(self, client, db, admin): # noqa: ARG002 + """Test that create exchange form renders correctly. + + Acceptance Criteria: + - Form to create exchange with all required fields + """ + # Login first + client.post( + "/admin/login", + data={ + "email": "admin@example.com", + "password": "testpassword123", + }, + follow_redirects=True, + ) + + response = client.get("/admin/exchange/new") + assert response.status_code == 200 + + # Check for all required form fields + assert b"name" in response.data.lower() + assert b"description" in response.data.lower() + assert b"budget" in response.data.lower() + assert b"max_participants" in response.data.lower() + assert b"registration_close_date" in response.data.lower() + assert b"exchange_date" in response.data.lower() + assert b"timezone" in response.data.lower() + + def test_create_exchange_with_valid_data(self, client, db, admin): # noqa: ARG002 + """Test creating exchange with valid data. + + Acceptance Criteria: + - Exchange created in "Draft" state + - Exchange appears in admin dashboard after creation + - Generate unique 12-char slug + """ + # Login first + client.post( + "/admin/login", + data={ + "email": "admin@example.com", + "password": "testpassword123", + }, + follow_redirects=True, + ) + + # Create exchange + future_close_date = (datetime.utcnow() + timedelta(days=7)).strftime( + "%Y-%m-%dT%H:%M" + ) + future_exchange_date = (datetime.utcnow() + timedelta(days=14)).strftime( + "%Y-%m-%dT%H:%M" + ) + + response = client.post( + "/admin/exchange/new", + data={ + "name": "Test Exchange", + "description": "This is a test exchange", + "budget": "$20-30", + "max_participants": 10, + "registration_close_date": future_close_date, + "exchange_date": future_exchange_date, + "timezone": "America/New_York", + }, + follow_redirects=False, + ) + + # Should redirect to exchange detail page + assert response.status_code == 302 + + # Verify exchange was created + exchange = db.session.query(Exchange).filter_by(name="Test Exchange").first() + assert exchange is not None + assert exchange.slug is not None + assert len(exchange.slug) == 12 + assert exchange.state == Exchange.STATE_DRAFT + assert exchange.description == "This is a test exchange" + assert exchange.budget == "$20-30" + assert exchange.max_participants == 10 + assert exchange.timezone == "America/New_York" + + def test_create_exchange_minimum_participants_validation(self, client, db, admin): # noqa: ARG002 + """Test that max_participants must be at least 3. + + Acceptance Criteria: + - Maximum participants (required, minimum 3) + """ + # Login first + client.post( + "/admin/login", + data={ + "email": "admin@example.com", + "password": "testpassword123", + }, + follow_redirects=True, + ) + + future_close_date = (datetime.utcnow() + timedelta(days=7)).strftime( + "%Y-%m-%dT%H:%M" + ) + future_exchange_date = (datetime.utcnow() + timedelta(days=14)).strftime( + "%Y-%m-%dT%H:%M" + ) + + # Try to create with less than 3 participants + response = client.post( + "/admin/exchange/new", + data={ + "name": "Test Exchange", + "budget": "$20-30", + "max_participants": 2, # Invalid: less than 3 + "registration_close_date": future_close_date, + "exchange_date": future_exchange_date, + "timezone": "America/New_York", + }, + follow_redirects=True, + ) + + # Should show error + assert response.status_code == 200 + assert ( + b"at least 3" in response.data.lower() + or b"minimum" in response.data.lower() + ) + + # Verify no exchange was created + exchange = db.session.query(Exchange).filter_by(name="Test Exchange").first() + assert exchange is None + + def test_create_exchange_date_validation(self, client, db, admin): # noqa: ARG002 + """Test that exchange_date must be after registration_close_date. + + Acceptance Criteria: + - Registration close date (required) + - Exchange date (required) + - Dates must be properly ordered + """ + # Login first + client.post( + "/admin/login", + data={ + "email": "admin@example.com", + "password": "testpassword123", + }, + follow_redirects=True, + ) + + future_close_date = (datetime.utcnow() + timedelta(days=14)).strftime( + "%Y-%m-%dT%H:%M" + ) + earlier_exchange_date = (datetime.utcnow() + timedelta(days=7)).strftime( + "%Y-%m-%dT%H:%M" + ) + + # Try to create with exchange date before close date + response = client.post( + "/admin/exchange/new", + data={ + "name": "Test Exchange", + "budget": "$20-30", + "max_participants": 10, + "registration_close_date": future_close_date, + "exchange_date": earlier_exchange_date, # Before close date + "timezone": "America/New_York", + }, + follow_redirects=True, + ) + + # Should show error + assert response.status_code == 200 + assert ( + b"exchange date" in response.data.lower() + and b"after" in response.data.lower() + ) or b"must be after" in response.data.lower() + + def test_create_exchange_timezone_validation(self, client, db, admin): # noqa: ARG002 + """Test that timezone must be valid. + + Acceptance Criteria: + - Timezone (required) + - Validate timezone with pytz + """ + # Login first + client.post( + "/admin/login", + data={ + "email": "admin@example.com", + "password": "testpassword123", + }, + follow_redirects=True, + ) + + future_close_date = (datetime.utcnow() + timedelta(days=7)).strftime( + "%Y-%m-%dT%H:%M" + ) + future_exchange_date = (datetime.utcnow() + timedelta(days=14)).strftime( + "%Y-%m-%dT%H:%M" + ) + + # Try to create with invalid timezone + response = client.post( + "/admin/exchange/new", + data={ + "name": "Test Exchange", + "budget": "$20-30", + "max_participants": 10, + "registration_close_date": future_close_date, + "exchange_date": future_exchange_date, + "timezone": "Invalid/Timezone", + }, + follow_redirects=True, + ) + + # Should show error + assert response.status_code == 200 + assert ( + b"timezone" in response.data.lower() or b"invalid" in response.data.lower() + ) + + def test_create_exchange_slug_is_unique(self, client, db, admin): # noqa: ARG002 + """Test that each exchange gets a unique slug. + + Acceptance Criteria: + - Generate unique 12-char slug + """ + # Login first + client.post( + "/admin/login", + data={ + "email": "admin@example.com", + "password": "testpassword123", + }, + follow_redirects=True, + ) + + future_close_date = (datetime.utcnow() + timedelta(days=7)).strftime( + "%Y-%m-%dT%H:%M" + ) + future_exchange_date = (datetime.utcnow() + timedelta(days=14)).strftime( + "%Y-%m-%dT%H:%M" + ) + + # Create first exchange + client.post( + "/admin/exchange/new", + data={ + "name": "Exchange 1", + "budget": "$20-30", + "max_participants": 10, + "registration_close_date": future_close_date, + "exchange_date": future_exchange_date, + "timezone": "America/New_York", + }, + follow_redirects=True, + ) + + # Create second exchange + client.post( + "/admin/exchange/new", + data={ + "name": "Exchange 2", + "budget": "$20-30", + "max_participants": 10, + "registration_close_date": future_close_date, + "exchange_date": future_exchange_date, + "timezone": "America/New_York", + }, + follow_redirects=True, + ) + + # Verify both have unique slugs + exchanges = db.session.query(Exchange).all() + assert len(exchanges) == 2 + assert exchanges[0].slug != exchanges[1].slug + + def test_create_exchange_required_fields(self, client, db, admin): # noqa: ARG002 + """Test that all required fields are validated. + + Acceptance Criteria: + - Name (required) + - Budget (required) + - All other required fields + """ + # Login first + client.post( + "/admin/login", + data={ + "email": "admin@example.com", + "password": "testpassword123", + }, + follow_redirects=True, + ) + + # Try to create without required fields + response = client.post( + "/admin/exchange/new", + data={}, + follow_redirects=True, + ) + + # Should show errors + assert response.status_code == 200 + # Verify no exchange was created + exchange_count = db.session.query(Exchange).count() + assert exchange_count == 0 + + def test_create_exchange_slug_generation(self): + """Test the slug generation method. + + Acceptance Criteria: + - Generate unique 12-char slug + - Slug should be URL-safe alphanumeric + """ + slug1 = Exchange.generate_slug() + slug2 = Exchange.generate_slug() + + # Verify length + assert len(slug1) == 12 + assert len(slug2) == 12 + + # Verify uniqueness (statistically very likely) + assert slug1 != slug2 + + # Verify alphanumeric (letters and digits only) + assert slug1.isalnum() + assert slug2.isalnum() diff --git a/uv.lock b/uv.lock index e97ff27..c29e592 100644 --- a/uv.lock +++ b/uv.lock @@ -1046,6 +1046,18 @@ dev = [ { name = "types-pytz" }, ] +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-flask" }, + { name = "ruff" }, + { name = "types-flask" }, + { name = "types-pytz" }, +] + [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.12" }, @@ -1071,6 +1083,18 @@ requires-dist = [ ] provides-extras = ["dev"] +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.19.1" }, + { name = "pre-commit", specifier = ">=4.5.1" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "pytest-flask", specifier = ">=1.3.0" }, + { name = "ruff", specifier = ">=0.14.10" }, + { name = "types-flask", specifier = ">=1.1.6" }, + { name = "types-pytz", specifier = ">=2025.2.0.20251108" }, +] + [[package]] name = "sqlalchemy" version = "2.0.45"