diff --git a/backend/main.py b/backend/main.py index cdae08e..8b2ade0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,17 +1,18 @@ +import json from contextlib import asynccontextmanager from fastapi import FastAPI, Query from typing import Optional from data import load_resorts -from models import ResortSummary +from models import Resort, ResortSummary -_resorts: list[ResortSummary] = [] +_resorts: list[Resort] = [] @asynccontextmanager async def lifespan(app: FastAPI): global _resorts - _resorts = [ResortSummary(**r.model_dump()) for r in load_resorts()] + _resorts = load_resorts() yield @@ -23,6 +24,16 @@ def health(): return {"status": "ok"} +def _parse_date_list(value: Optional[str]) -> set[str]: + """Parse a JSON-encoded date array from the CSV into a set of date strings.""" + if not value: + return set() + try: + return set(json.loads(value)) + except (json.JSONDecodeError, TypeError): + return set() + + @app.get("/resorts", response_model=list[ResortSummary]) def get_resorts( search: Optional[str] = Query(default=None), @@ -37,6 +48,7 @@ def get_resorts( is_dog_friendly: Optional[bool] = Query(default=None), has_snowshoeing: Optional[bool] = Query(default=None), is_allied: Optional[bool] = Query(default=None), + ltt_available: Optional[bool] = Query(default=None), reservation_required: Optional[bool] = Query(default=None), # Numeric range filters (inclusive) min_vertical: Optional[float] = Query(default=None), @@ -47,6 +59,9 @@ def get_resorts( max_lifts: Optional[float] = Query(default=None), min_trail_length: Optional[float] = Query(default=None), max_trail_length: Optional[float] = Query(default=None), + # Blackout date filters (comma-separated YYYY-MM-DD dates) + blackout_dates: Optional[str] = Query(default=None), + ltt_dates: Optional[str] = Query(default=None), ): results = _resorts @@ -73,7 +88,7 @@ def get_resorts( s = state.lower() results = [r for r in results if (r.state or '').lower() == s] - # Boolean feature flags — only filter when explicitly set to True + # Boolean feature flags bool_filters = [ ('has_alpine', has_alpine), ('has_cross_country', has_cross_country), @@ -82,6 +97,7 @@ def get_resorts( ('is_dog_friendly', is_dog_friendly), ('has_snowshoeing', has_snowshoeing), ('is_allied', is_allied), + ('ltt_available', ltt_available), ] for field, value in bool_filters: if value is not None: @@ -93,7 +109,7 @@ def get_resorts( else: results = [r for r in results if r.reservation_status != 'Required'] - # Numeric range filters (skip resorts with no data for the field) + # Numeric range filters (resorts with null values are excluded) range_filters = [ ('vertical', min_vertical, max_vertical), ('num_trails', min_trails, max_trails), @@ -110,4 +126,17 @@ def get_resorts( r for r in results if getattr(r, field) is not None and getattr(r, field) <= hi ] - return results + # Blackout date filters — exclude resorts blacked out on any of the given dates + if blackout_dates: + selected = {d.strip() for d in blackout_dates.split(',')} + results = [ + r for r in results if selected.isdisjoint(_parse_date_list(r.blackout_all_dates)) + ] + + if ltt_dates: + selected = {d.strip() for d in ltt_dates.split(',')} + results = [ + r for r in results if selected.isdisjoint(_parse_date_list(r.ltt_blackout_all_dates)) + ] + + return [ResortSummary(**r.model_dump()) for r in results] diff --git a/backend/models.py b/backend/models.py index 0d66659..ee51cd2 100644 --- a/backend/models.py +++ b/backend/models.py @@ -23,6 +23,7 @@ class ResortSummary(BaseModel): has_terrain_parks: Optional[bool] = None is_dog_friendly: Optional[bool] = None has_snowshoeing: Optional[bool] = None + ltt_available: Optional[bool] = None vertical: Optional[float] = None acres: Optional[float] = None num_trails: Optional[float] = None diff --git a/backend/tests/test_resorts_blackout_filters.py b/backend/tests/test_resorts_blackout_filters.py new file mode 100644 index 0000000..75932ed --- /dev/null +++ b/backend/tests/test_resorts_blackout_filters.py @@ -0,0 +1,172 @@ +import sys +import os +import json + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +import pytest +from fastapi.testclient import TestClient +from unittest.mock import patch + +from main import app +from models import Resort + + +# Helper to build a minimal Resort with the fields we care about +def make_resort(resort_id, name, blackout_dates=None, ltt_blackout_dates=None, ltt_available=False): + return Resort( + resort_id=resort_id, + name=name, + region='West', + reservation_status='Not Required', + indy_page=f'https://example.com/{resort_id}', + ltt_available=ltt_available, + blackout_all_dates=json.dumps(blackout_dates or []), + ltt_blackout_all_dates=json.dumps(ltt_blackout_dates or []), + ) + + +FAKE_RESORTS = [ + make_resort( + 'id-1', + 'Alpine Peak', + blackout_dates=['2025-12-25', '2025-12-26', '2026-01-01'], + ltt_blackout_dates=['2025-12-25'], + ltt_available=True, + ), + make_resort( + 'id-2', + 'Nordic Valley', + blackout_dates=['2026-02-14', '2026-02-15'], + ltt_blackout_dates=[], + ltt_available=True, + ), + make_resort( + 'id-3', + 'Powder Ridge', + blackout_dates=[], + ltt_blackout_dates=[], + ltt_available=False, + ), +] + + +@pytest.fixture(autouse=True) +def patch_resorts(): + with patch('main._resorts', FAKE_RESORTS): + yield + + +@pytest.fixture +def client(): + return TestClient(app) + + +# --- blackout_dates --- + + +def test_blackout_dates_excludes_blacked_out_resorts(client): + # Christmas day blacks out Alpine Peak + response = client.get('/resorts?blackout_dates=2025-12-25') + assert response.status_code == 200 + names = {r['name'] for r in response.json()} + assert 'Alpine Peak' not in names + assert 'Nordic Valley' in names + assert 'Powder Ridge' in names + + +def test_blackout_dates_multiple_dates(client): + # Dec 26 hits Alpine Peak; Feb 14 hits Nordic Valley + response = client.get('/resorts?blackout_dates=2025-12-26,2026-02-14') + names = {r['name'] for r in response.json()} + assert names == {'Powder Ridge'} + + +def test_blackout_dates_no_overlap_returns_all(client): + response = client.get('/resorts?blackout_dates=2025-07-04') + assert len(response.json()) == 3 + + +def test_blackout_dates_ignores_whitespace(client): + response = client.get('/resorts?blackout_dates=2025-12-25, 2026-02-14') + names = {r['name'] for r in response.json()} + assert names == {'Powder Ridge'} + + +def test_blackout_dates_resort_with_empty_list_always_passes(client): + response = client.get('/resorts?blackout_dates=2025-12-25') + names = {r['name'] for r in response.json()} + assert 'Powder Ridge' in names + + +# --- ltt_dates --- + + +def test_ltt_dates_excludes_ltt_blacked_out_resorts(client): + # Dec 25 is in Alpine Peak's LTT blackout list + response = client.get('/resorts?ltt_dates=2025-12-25') + names = {r['name'] for r in response.json()} + assert 'Alpine Peak' not in names + assert 'Nordic Valley' in names + assert 'Powder Ridge' in names + + +def test_ltt_dates_no_overlap_returns_all(client): + response = client.get('/resorts?ltt_dates=2025-07-04') + assert len(response.json()) == 3 + + +def test_ltt_dates_resort_with_empty_ltt_blackout_always_passes(client): + # Nordic Valley has empty ltt_blackout — should never be excluded by ltt_dates + response = client.get('/resorts?ltt_dates=2025-12-25') + names = {r['name'] for r in response.json()} + assert 'Nordic Valley' in names + + +# --- ltt_available --- + + +def test_ltt_available_true(client): + response = client.get('/resorts?ltt_available=true') + names = {r['name'] for r in response.json()} + assert names == {'Alpine Peak', 'Nordic Valley'} + + +def test_ltt_available_false(client): + response = client.get('/resorts?ltt_available=false') + names = {r['name'] for r in response.json()} + assert names == {'Powder Ridge'} + + +def test_ltt_available_in_response_shape(client): + response = client.get('/resorts') + assert all('ltt_available' in r for r in response.json()) + + +# --- composability --- + + +def test_ltt_available_and_ltt_dates_combined(client): + # Only LTT resorts, but not blacked out on Dec 25 + response = client.get('/resorts?ltt_available=true<t_dates=2025-12-25') + names = {r['name'] for r in response.json()} + # Alpine Peak is LTT but blacked out on Dec 25 via ltt_dates + assert names == {'Nordic Valley'} + + +def test_blackout_and_ltt_dates_combined(client): + response = client.get('/resorts?blackout_dates=2025-12-25<t_dates=2025-12-25') + names = {r['name'] for r in response.json()} + assert names == {'Nordic Valley', 'Powder Ridge'} + + +def test_all_filter_types_combined(client): + # LTT available, not blacked out on Dec 25 (regular), not blacked out on Feb 14 (LTT) + response = client.get( + '/resorts?ltt_available=true&blackout_dates=2025-12-25<t_dates=2026-02-14' + ) + # Alpine Peak: LTT available ✓, but blacked out Dec 25 via blackout_dates ✗ + # Nordic Valley: LTT available ✓, not blacked out Dec 25 ✓, but Feb 14 is only in + # its regular blackout list (not ltt_blackout) — so ltt_dates=2026-02-14 passes ✓ + names = {r['name'] for r in response.json()} + assert names == {'Nordic Valley'} diff --git a/backend/tests/test_resorts_endpoint.py b/backend/tests/test_resorts_endpoint.py index 52bd39a..a83c796 100644 --- a/backend/tests/test_resorts_endpoint.py +++ b/backend/tests/test_resorts_endpoint.py @@ -8,10 +8,10 @@ from unittest.mock import patch from main import app -from models import ResortSummary +from models import Resort FAKE_RESORTS = [ - ResortSummary( + Resort( resort_id='id-1', name='Vail', region='West', @@ -21,7 +21,7 @@ reservation_status='required', indy_page='https://example.com/vail', ), - ResortSummary( + Resort( resort_id='id-2', name='Stowe', region='Northeast', @@ -31,7 +31,7 @@ reservation_status='none', indy_page='https://example.com/stowe', ), - ResortSummary( + Resort( resort_id='id-3', name='Tremblant', region='Northeast', diff --git a/backend/tests/test_resorts_feature_filters.py b/backend/tests/test_resorts_feature_filters.py index bc246c0..0828c5f 100644 --- a/backend/tests/test_resorts_feature_filters.py +++ b/backend/tests/test_resorts_feature_filters.py @@ -8,10 +8,10 @@ from unittest.mock import patch from main import app -from models import ResortSummary +from models import Resort FAKE_RESORTS = [ - ResortSummary( + Resort( resort_id='id-1', name='Alpine Peak', region='West', @@ -32,7 +32,7 @@ num_lifts=15.0, trail_length_mi=80.0, ), - ResortSummary( + Resort( resort_id='id-2', name='Nordic Valley', region='Northeast', @@ -53,7 +53,7 @@ num_lifts=5.0, trail_length_mi=30.0, ), - ResortSummary( + Resort( resort_id='id-3', name='Mid Mountain', region='West',