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:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
src/forms/exchange.py
Normal file
145
src/forms/exchange.py
Normal 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.")
|
||||
@@ -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"]
|
||||
|
||||
@@ -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/<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
18
src/routes/participant.py
Normal 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}"
|
||||
@@ -12,8 +12,56 @@
|
||||
</form>
|
||||
</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>
|
||||
{% endblock %}
|
||||
|
||||
58
src/templates/admin/exchange_detail.html
Normal file
58
src/templates/admin/exchange_detail.html
Normal 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 %}
|
||||
104
src/templates/admin/exchange_form.html
Normal file
104
src/templates/admin/exchange_form.html
Normal 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 %}
|
||||
337
tests/integration/test_create_exchange.py
Normal file
337
tests/integration/test_create_exchange.py
Normal 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
24
uv.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user