Merge feature/2.1-create-exchange into release/v0.1.0

This commit is contained in:
2025-12-22 12:41:49 -07:00
12 changed files with 837 additions and 7 deletions

View File

@@ -91,3 +91,15 @@ warn_return_any = true
warn_unused_configs = true warn_unused_configs = true
disallow_untyped_defs = false disallow_untyped_defs = false
ignore_missing_imports = true 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",
]

View File

@@ -57,10 +57,12 @@ def create_app(config_name: str | None = None) -> Flask:
# Register blueprints # Register blueprints
from src.routes.admin import admin_bp from src.routes.admin import admin_bp
from src.routes.participant import participant_bp
from src.routes.setup import setup_bp from src.routes.setup import setup_bp
app.register_blueprint(setup_bp) app.register_blueprint(setup_bp)
app.register_blueprint(admin_bp) app.register_blueprint(admin_bp)
app.register_blueprint(participant_bp)
# Register error handlers # Register error handlers
register_error_handlers(app) register_error_handlers(app)

View File

@@ -1,6 +1,7 @@
"""Forms for Sneaky Klaus application.""" """Forms for Sneaky Klaus application."""
from src.forms.exchange import ExchangeForm
from src.forms.login import LoginForm from src.forms.login import LoginForm
from src.forms.setup import SetupForm from src.forms.setup import SetupForm
__all__ = ["LoginForm", "SetupForm"] __all__ = ["ExchangeForm", "LoginForm", "SetupForm"]

145
src/forms/exchange.py Normal file
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.")

View File

@@ -1,5 +1,7 @@
"""Route blueprints for Sneaky Klaus application.""" """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 from src.routes.setup import setup_bp
__all__ = ["setup_bp"] __all__ = ["admin_bp", "participant_bp", "setup_bp"]

View File

@@ -6,8 +6,8 @@ from flask import Blueprint, flash, redirect, render_template, session, url_for
from src.app import bcrypt, db from src.app import bcrypt, db
from src.decorators import admin_required from src.decorators import admin_required
from src.forms import LoginForm from src.forms import ExchangeForm, LoginForm
from src.models import Admin from src.models import Admin, Exchange
from src.utils import check_rate_limit, increment_rate_limit, reset_rate_limit from src.utils import check_rate_limit, increment_rate_limit, reset_rate_limit
admin_bp = Blueprint("admin", __name__, url_prefix="/admin") admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
@@ -100,4 +100,83 @@ def dashboard():
Returns: Returns:
Rendered admin dashboard template. 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/<int:exchange_id>")
@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)

18
src/routes/participant.py Normal file
View File

@@ -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/<slug>/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}"

View File

@@ -12,8 +12,56 @@
</form> </form>
</header> </header>
<p>Welcome to the Sneaky Klaus admin dashboard!</p> <div class="dashboard-summary">
<div class="grid">
<div>
<h3>Draft</h3>
<p><strong>{{ draft_count }}</strong></p>
</div>
<div>
<h3>Active</h3>
<p><strong>{{ active_count }}</strong></p>
</div>
<div>
<h3>Completed</h3>
<p><strong>{{ completed_count }}</strong></p>
</div>
</div>
</div>
<p>This is a placeholder for the admin dashboard. More features coming soon.</p> <div style="margin: 2rem 0;">
<a href="{{ url_for('admin.create_exchange') }}" role="button">Create New Exchange</a>
</div>
<h2>All Exchanges</h2>
{% if exchanges %}
<table>
<thead>
<tr>
<th>Name</th>
<th>State</th>
<th>Participants</th>
<th>Exchange Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for exchange in exchanges %}
<tr>
<td>{{ exchange.name }}</td>
<td><mark>{{ exchange.state }}</mark></td>
<td>0 / {{ exchange.max_participants }}</td>
<td>{{ exchange.exchange_date.strftime('%Y-%m-%d') }}</td>
<td>
<a href="{{ url_for('admin.view_exchange', exchange_id=exchange.id) }}">View</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No exchanges yet. <a href="{{ url_for('admin.create_exchange') }}">Create your first exchange</a>!</p>
{% endif %}
</article> </article>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,58 @@
{% extends "layouts/base.html" %}
{% block title %}{{ exchange.name }}{% endblock %}
{% block content %}
<div class="container">
<h1>{{ exchange.name }}</h1>
<div class="exchange-details">
<div class="detail-section">
<h2>Details</h2>
<dl>
<dt>State</dt>
<dd><span class="badge badge-{{ exchange.state }}">{{ exchange.state }}</span></dd>
<dt>Description</dt>
<dd>{{ exchange.description or "No description" }}</dd>
<dt>Budget</dt>
<dd>{{ exchange.budget }}</dd>
<dt>Max Participants</dt>
<dd>{{ exchange.max_participants }}</dd>
<dt>Registration Close Date</dt>
<dd>{{ exchange.registration_close_date.strftime('%Y-%m-%d %H:%M') }} {{ exchange.timezone }}</dd>
<dt>Exchange Date</dt>
<dd>{{ exchange.exchange_date.strftime('%Y-%m-%d %H:%M') }} {{ exchange.timezone }}</dd>
<dt>Registration Link</dt>
<dd>
<code id="registration-link">{{ url_for('participant.register', slug=exchange.slug, _external=True) }}</code>
<button type="button" onclick="copyToClipboard()">Copy</button>
</dd>
</dl>
</div>
<div class="detail-section">
<h2>Participants</h2>
<p>No participants yet.</p>
</div>
<div class="actions">
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-secondary">Back to Dashboard</a>
</div>
</div>
</div>
<script>
function copyToClipboard() {
const link = document.getElementById('registration-link').textContent;
navigator.clipboard.writeText(link).then(() => {
alert('Link copied to clipboard!');
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,104 @@
{% extends "layouts/base.html" %}
{% block title %}{% if is_edit %}Edit Exchange{% else %}Create Exchange{% endif %}{% endblock %}
{% block content %}
<div class="container">
<h1>{% if is_edit %}Edit Exchange{% else %}Create New Exchange{% endif %}</h1>
<form method="POST" novalidate>
{{ form.hidden_tag() }}
<div class="form-group">
{{ form.name.label }}
{{ form.name(class="form-control") }}
{% if form.name.errors %}
<div class="error">
{% for error in form.name.errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% endif %}
</div>
<div class="form-group">
{{ form.description.label }}
{{ form.description(class="form-control", rows=4) }}
{% if form.description.errors %}
<div class="error">
{% for error in form.description.errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% endif %}
</div>
<div class="form-group">
{{ form.budget.label }}
{{ form.budget(class="form-control", placeholder="e.g., $20-30") }}
{% if form.budget.errors %}
<div class="error">
{% for error in form.budget.errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% endif %}
</div>
<div class="form-group">
{{ form.max_participants.label }}
{{ form.max_participants(class="form-control", min=3) }}
{% if form.max_participants.errors %}
<div class="error">
{% for error in form.max_participants.errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% endif %}
</div>
<div class="form-group">
{{ form.registration_close_date.label }}
{{ form.registration_close_date(class="form-control") }}
{% if form.registration_close_date.errors %}
<div class="error">
{% for error in form.registration_close_date.errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% endif %}
</div>
<div class="form-group">
{{ form.exchange_date.label }}
{{ form.exchange_date(class="form-control") }}
{% if form.exchange_date.errors %}
<div class="error">
{% for error in form.exchange_date.errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% endif %}
</div>
<div class="form-group">
{{ form.timezone.label }}
{{ form.timezone(class="form-control") }}
{% if form.timezone.errors %}
<div class="error">
{% for error in form.timezone.errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% endif %}
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
{% if is_edit %}Update Exchange{% else %}Create Exchange{% endif %}
</button>
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
{% endblock %}

View File

@@ -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()

24
uv.lock generated
View File

@@ -1046,6 +1046,18 @@ dev = [
{ name = "types-pytz" }, { 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] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "alembic", specifier = ">=1.12" }, { name = "alembic", specifier = ">=1.12" },
@@ -1071,6 +1083,18 @@ requires-dist = [
] ]
provides-extras = ["dev"] 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]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "2.0.45" version = "2.0.45"