Skip to content

Commit 4cc79d8

Browse files
Add blackout date and LTT filters to GET /resorts (Issue #58)
- blackout_dates: comma-separated YYYY-MM-DD list; excludes resorts blacked out on any of the given dates - ltt_dates: same logic against ltt_blackout_all_dates column - ltt_available: boolean filter on LTT program participation - ltt_available added to ResortSummary response projection - Internal store switched to list[Resort] so filters can access full-model fields (blackout columns) without bloating ResortSummary; projection to ResortSummary happens at return time - Updated existing test fixtures to use Resort instead of ResortSummary - 14 new tests covering all params and composability Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0487d50 commit 4cc79d8

5 files changed

Lines changed: 216 additions & 14 deletions

File tree

backend/main.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
1+
import json
12
from contextlib import asynccontextmanager
23
from fastapi import FastAPI, Query
34
from typing import Optional
45

56
from data import load_resorts
6-
from models import ResortSummary
7+
from models import Resort, ResortSummary
78

8-
_resorts: list[ResortSummary] = []
9+
_resorts: list[Resort] = []
910

1011

1112
@asynccontextmanager
1213
async def lifespan(app: FastAPI):
1314
global _resorts
14-
_resorts = [ResortSummary(**r.model_dump()) for r in load_resorts()]
15+
_resorts = load_resorts()
1516
yield
1617

1718

@@ -23,6 +24,16 @@ def health():
2324
return {"status": "ok"}
2425

2526

27+
def _parse_date_list(value: Optional[str]) -> set[str]:
28+
"""Parse a JSON-encoded date array from the CSV into a set of date strings."""
29+
if not value:
30+
return set()
31+
try:
32+
return set(json.loads(value))
33+
except (json.JSONDecodeError, TypeError):
34+
return set()
35+
36+
2637
@app.get("/resorts", response_model=list[ResortSummary])
2738
def get_resorts(
2839
search: Optional[str] = Query(default=None),
@@ -37,6 +48,7 @@ def get_resorts(
3748
is_dog_friendly: Optional[bool] = Query(default=None),
3849
has_snowshoeing: Optional[bool] = Query(default=None),
3950
is_allied: Optional[bool] = Query(default=None),
51+
ltt_available: Optional[bool] = Query(default=None),
4052
reservation_required: Optional[bool] = Query(default=None),
4153
# Numeric range filters (inclusive)
4254
min_vertical: Optional[float] = Query(default=None),
@@ -47,6 +59,9 @@ def get_resorts(
4759
max_lifts: Optional[float] = Query(default=None),
4860
min_trail_length: Optional[float] = Query(default=None),
4961
max_trail_length: Optional[float] = Query(default=None),
62+
# Blackout date filters (comma-separated YYYY-MM-DD dates)
63+
blackout_dates: Optional[str] = Query(default=None),
64+
ltt_dates: Optional[str] = Query(default=None),
5065
):
5166
results = _resorts
5267

@@ -73,7 +88,7 @@ def get_resorts(
7388
s = state.lower()
7489
results = [r for r in results if (r.state or '').lower() == s]
7590

76-
# Boolean feature flags — only filter when explicitly set to True
91+
# Boolean feature flags
7792
bool_filters = [
7893
('has_alpine', has_alpine),
7994
('has_cross_country', has_cross_country),
@@ -82,6 +97,7 @@ def get_resorts(
8297
('is_dog_friendly', is_dog_friendly),
8398
('has_snowshoeing', has_snowshoeing),
8499
('is_allied', is_allied),
100+
('ltt_available', ltt_available),
85101
]
86102
for field, value in bool_filters:
87103
if value is not None:
@@ -93,7 +109,7 @@ def get_resorts(
93109
else:
94110
results = [r for r in results if r.reservation_status != 'Required']
95111

96-
# Numeric range filters (skip resorts with no data for the field)
112+
# Numeric range filters (resorts with null values are excluded)
97113
range_filters = [
98114
('vertical', min_vertical, max_vertical),
99115
('num_trails', min_trails, max_trails),
@@ -110,4 +126,17 @@ def get_resorts(
110126
r for r in results if getattr(r, field) is not None and getattr(r, field) <= hi
111127
]
112128

113-
return results
129+
# Blackout date filters — exclude resorts blacked out on any of the given dates
130+
if blackout_dates:
131+
selected = {d.strip() for d in blackout_dates.split(',')}
132+
results = [
133+
r for r in results if selected.isdisjoint(_parse_date_list(r.blackout_all_dates))
134+
]
135+
136+
if ltt_dates:
137+
selected = {d.strip() for d in ltt_dates.split(',')}
138+
results = [
139+
r for r in results if selected.isdisjoint(_parse_date_list(r.ltt_blackout_all_dates))
140+
]
141+
142+
return [ResortSummary(**r.model_dump()) for r in results]

backend/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class ResortSummary(BaseModel):
2323
has_terrain_parks: Optional[bool] = None
2424
is_dog_friendly: Optional[bool] = None
2525
has_snowshoeing: Optional[bool] = None
26+
ltt_available: Optional[bool] = None
2627
vertical: Optional[float] = None
2728
acres: Optional[float] = None
2829
num_trails: Optional[float] = None
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import sys
2+
import os
3+
import json
4+
5+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
6+
7+
import pytest
8+
from fastapi.testclient import TestClient
9+
from unittest.mock import patch
10+
11+
from main import app
12+
from models import Resort
13+
14+
15+
# Helper to build a minimal Resort with the fields we care about
16+
def make_resort(resort_id, name, blackout_dates=None, ltt_blackout_dates=None, ltt_available=False):
17+
return Resort(
18+
resort_id=resort_id,
19+
name=name,
20+
region='West',
21+
reservation_status='Not Required',
22+
indy_page=f'https://example.com/{resort_id}',
23+
ltt_available=ltt_available,
24+
blackout_all_dates=json.dumps(blackout_dates or []),
25+
ltt_blackout_all_dates=json.dumps(ltt_blackout_dates or []),
26+
)
27+
28+
29+
FAKE_RESORTS = [
30+
make_resort(
31+
'id-1',
32+
'Alpine Peak',
33+
blackout_dates=['2025-12-25', '2025-12-26', '2026-01-01'],
34+
ltt_blackout_dates=['2025-12-25'],
35+
ltt_available=True,
36+
),
37+
make_resort(
38+
'id-2',
39+
'Nordic Valley',
40+
blackout_dates=['2026-02-14', '2026-02-15'],
41+
ltt_blackout_dates=[],
42+
ltt_available=True,
43+
),
44+
make_resort(
45+
'id-3',
46+
'Powder Ridge',
47+
blackout_dates=[],
48+
ltt_blackout_dates=[],
49+
ltt_available=False,
50+
),
51+
]
52+
53+
54+
@pytest.fixture(autouse=True)
55+
def patch_resorts():
56+
with patch('main._resorts', FAKE_RESORTS):
57+
yield
58+
59+
60+
@pytest.fixture
61+
def client():
62+
return TestClient(app)
63+
64+
65+
# --- blackout_dates ---
66+
67+
68+
def test_blackout_dates_excludes_blacked_out_resorts(client):
69+
# Christmas day blacks out Alpine Peak
70+
response = client.get('/resorts?blackout_dates=2025-12-25')
71+
assert response.status_code == 200
72+
names = {r['name'] for r in response.json()}
73+
assert 'Alpine Peak' not in names
74+
assert 'Nordic Valley' in names
75+
assert 'Powder Ridge' in names
76+
77+
78+
def test_blackout_dates_multiple_dates(client):
79+
# Dec 26 hits Alpine Peak; Feb 14 hits Nordic Valley
80+
response = client.get('/resorts?blackout_dates=2025-12-26,2026-02-14')
81+
names = {r['name'] for r in response.json()}
82+
assert names == {'Powder Ridge'}
83+
84+
85+
def test_blackout_dates_no_overlap_returns_all(client):
86+
response = client.get('/resorts?blackout_dates=2025-07-04')
87+
assert len(response.json()) == 3
88+
89+
90+
def test_blackout_dates_ignores_whitespace(client):
91+
response = client.get('/resorts?blackout_dates=2025-12-25, 2026-02-14')
92+
names = {r['name'] for r in response.json()}
93+
assert names == {'Powder Ridge'}
94+
95+
96+
def test_blackout_dates_resort_with_empty_list_always_passes(client):
97+
response = client.get('/resorts?blackout_dates=2025-12-25')
98+
names = {r['name'] for r in response.json()}
99+
assert 'Powder Ridge' in names
100+
101+
102+
# --- ltt_dates ---
103+
104+
105+
def test_ltt_dates_excludes_ltt_blacked_out_resorts(client):
106+
# Dec 25 is in Alpine Peak's LTT blackout list
107+
response = client.get('/resorts?ltt_dates=2025-12-25')
108+
names = {r['name'] for r in response.json()}
109+
assert 'Alpine Peak' not in names
110+
assert 'Nordic Valley' in names
111+
assert 'Powder Ridge' in names
112+
113+
114+
def test_ltt_dates_no_overlap_returns_all(client):
115+
response = client.get('/resorts?ltt_dates=2025-07-04')
116+
assert len(response.json()) == 3
117+
118+
119+
def test_ltt_dates_resort_with_empty_ltt_blackout_always_passes(client):
120+
# Nordic Valley has empty ltt_blackout — should never be excluded by ltt_dates
121+
response = client.get('/resorts?ltt_dates=2025-12-25')
122+
names = {r['name'] for r in response.json()}
123+
assert 'Nordic Valley' in names
124+
125+
126+
# --- ltt_available ---
127+
128+
129+
def test_ltt_available_true(client):
130+
response = client.get('/resorts?ltt_available=true')
131+
names = {r['name'] for r in response.json()}
132+
assert names == {'Alpine Peak', 'Nordic Valley'}
133+
134+
135+
def test_ltt_available_false(client):
136+
response = client.get('/resorts?ltt_available=false')
137+
names = {r['name'] for r in response.json()}
138+
assert names == {'Powder Ridge'}
139+
140+
141+
def test_ltt_available_in_response_shape(client):
142+
response = client.get('/resorts')
143+
assert all('ltt_available' in r for r in response.json())
144+
145+
146+
# --- composability ---
147+
148+
149+
def test_ltt_available_and_ltt_dates_combined(client):
150+
# Only LTT resorts, but not blacked out on Dec 25
151+
response = client.get('/resorts?ltt_available=true&ltt_dates=2025-12-25')
152+
names = {r['name'] for r in response.json()}
153+
# Alpine Peak is LTT but blacked out on Dec 25 via ltt_dates
154+
assert names == {'Nordic Valley'}
155+
156+
157+
def test_blackout_and_ltt_dates_combined(client):
158+
response = client.get('/resorts?blackout_dates=2025-12-25&ltt_dates=2025-12-25')
159+
names = {r['name'] for r in response.json()}
160+
assert names == {'Nordic Valley', 'Powder Ridge'}
161+
162+
163+
def test_all_filter_types_combined(client):
164+
# LTT available, not blacked out on Dec 25 (regular), not blacked out on Feb 14 (LTT)
165+
response = client.get(
166+
'/resorts?ltt_available=true&blackout_dates=2025-12-25&ltt_dates=2026-02-14'
167+
)
168+
# Alpine Peak: LTT available ✓, but blacked out Dec 25 via blackout_dates ✗
169+
# Nordic Valley: LTT available ✓, not blacked out Dec 25 ✓, but Feb 14 is only in
170+
# its regular blackout list (not ltt_blackout) — so ltt_dates=2026-02-14 passes ✓
171+
names = {r['name'] for r in response.json()}
172+
assert names == {'Nordic Valley'}

backend/tests/test_resorts_endpoint.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88
from unittest.mock import patch
99

1010
from main import app
11-
from models import ResortSummary
11+
from models import Resort
1212

1313
FAKE_RESORTS = [
14-
ResortSummary(
14+
Resort(
1515
resort_id='id-1',
1616
name='Vail',
1717
region='West',
@@ -21,7 +21,7 @@
2121
reservation_status='required',
2222
indy_page='https://example.com/vail',
2323
),
24-
ResortSummary(
24+
Resort(
2525
resort_id='id-2',
2626
name='Stowe',
2727
region='Northeast',
@@ -31,7 +31,7 @@
3131
reservation_status='none',
3232
indy_page='https://example.com/stowe',
3333
),
34-
ResortSummary(
34+
Resort(
3535
resort_id='id-3',
3636
name='Tremblant',
3737
region='Northeast',

backend/tests/test_resorts_feature_filters.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88
from unittest.mock import patch
99

1010
from main import app
11-
from models import ResortSummary
11+
from models import Resort
1212

1313
FAKE_RESORTS = [
14-
ResortSummary(
14+
Resort(
1515
resort_id='id-1',
1616
name='Alpine Peak',
1717
region='West',
@@ -32,7 +32,7 @@
3232
num_lifts=15.0,
3333
trail_length_mi=80.0,
3434
),
35-
ResortSummary(
35+
Resort(
3636
resort_id='id-2',
3737
name='Nordic Valley',
3838
region='Northeast',
@@ -53,7 +53,7 @@
5353
num_lifts=5.0,
5454
trail_length_mi=30.0,
5555
),
56-
ResortSummary(
56+
Resort(
5757
resort_id='id-3',
5858
name='Mid Mountain',
5959
region='West',

0 commit comments

Comments
 (0)