Skip to content

Commit 0487d50

Browse files
Merge pull request #86 from jonathanstelman/feature/57-resorts-feature-filters
GET /resorts feature and range filters (Issue #57)
2 parents 4fff541 + 7850f01 commit 0487d50

3 files changed

Lines changed: 299 additions & 0 deletions

File tree

backend/main.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,24 @@ def get_resorts(
2929
region: list[str] = Query(default=[]),
3030
country: Optional[str] = Query(default=None),
3131
state: Optional[str] = Query(default=None),
32+
# Boolean feature flags
33+
has_alpine: Optional[bool] = Query(default=None),
34+
has_cross_country: Optional[bool] = Query(default=None),
35+
has_night_skiing: Optional[bool] = Query(default=None),
36+
has_terrain_parks: Optional[bool] = Query(default=None),
37+
is_dog_friendly: Optional[bool] = Query(default=None),
38+
has_snowshoeing: Optional[bool] = Query(default=None),
39+
is_allied: Optional[bool] = Query(default=None),
40+
reservation_required: Optional[bool] = Query(default=None),
41+
# Numeric range filters (inclusive)
42+
min_vertical: Optional[float] = Query(default=None),
43+
max_vertical: Optional[float] = Query(default=None),
44+
min_trails: Optional[float] = Query(default=None),
45+
max_trails: Optional[float] = Query(default=None),
46+
min_lifts: Optional[float] = Query(default=None),
47+
max_lifts: Optional[float] = Query(default=None),
48+
min_trail_length: Optional[float] = Query(default=None),
49+
max_trail_length: Optional[float] = Query(default=None),
3250
):
3351
results = _resorts
3452

@@ -55,4 +73,41 @@ def get_resorts(
5573
s = state.lower()
5674
results = [r for r in results if (r.state or '').lower() == s]
5775

76+
# Boolean feature flags — only filter when explicitly set to True
77+
bool_filters = [
78+
('has_alpine', has_alpine),
79+
('has_cross_country', has_cross_country),
80+
('has_night_skiing', has_night_skiing),
81+
('has_terrain_parks', has_terrain_parks),
82+
('is_dog_friendly', is_dog_friendly),
83+
('has_snowshoeing', has_snowshoeing),
84+
('is_allied', is_allied),
85+
]
86+
for field, value in bool_filters:
87+
if value is not None:
88+
results = [r for r in results if getattr(r, field) == value]
89+
90+
if reservation_required is not None:
91+
if reservation_required:
92+
results = [r for r in results if r.reservation_status == 'Required']
93+
else:
94+
results = [r for r in results if r.reservation_status != 'Required']
95+
96+
# Numeric range filters (skip resorts with no data for the field)
97+
range_filters = [
98+
('vertical', min_vertical, max_vertical),
99+
('num_trails', min_trails, max_trails),
100+
('num_lifts', min_lifts, max_lifts),
101+
('trail_length_mi', min_trail_length, max_trail_length),
102+
]
103+
for field, lo, hi in range_filters:
104+
if lo is not None:
105+
results = [
106+
r for r in results if getattr(r, field) is not None and getattr(r, field) >= lo
107+
]
108+
if hi is not None:
109+
results = [
110+
r for r in results if getattr(r, field) is not None and getattr(r, field) <= hi
111+
]
112+
58113
return results

backend/models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,15 @@ class ResortSummary(BaseModel):
1919
is_allied: Optional[bool] = None
2020
has_alpine: Optional[bool] = None
2121
has_cross_country: Optional[bool] = None
22+
has_night_skiing: Optional[bool] = None
23+
has_terrain_parks: Optional[bool] = None
24+
is_dog_friendly: Optional[bool] = None
25+
has_snowshoeing: Optional[bool] = None
2226
vertical: Optional[float] = None
2327
acres: Optional[float] = None
2428
num_trails: Optional[float] = None
2529
num_lifts: Optional[float] = None
30+
trail_length_mi: Optional[float] = None
2631

2732

2833
class Resort(BaseModel):
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import sys
2+
import os
3+
4+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
5+
6+
import pytest
7+
from fastapi.testclient import TestClient
8+
from unittest.mock import patch
9+
10+
from main import app
11+
from models import ResortSummary
12+
13+
FAKE_RESORTS = [
14+
ResortSummary(
15+
resort_id='id-1',
16+
name='Alpine Peak',
17+
region='West',
18+
city='Denver',
19+
state='CO',
20+
country='USA',
21+
reservation_status='Required',
22+
indy_page='https://example.com/alpine-peak',
23+
has_alpine=True,
24+
has_cross_country=False,
25+
has_night_skiing=True,
26+
has_terrain_parks=True,
27+
is_dog_friendly=False,
28+
has_snowshoeing=False,
29+
is_allied=False,
30+
vertical=3000.0,
31+
num_trails=100.0,
32+
num_lifts=15.0,
33+
trail_length_mi=80.0,
34+
),
35+
ResortSummary(
36+
resort_id='id-2',
37+
name='Nordic Valley',
38+
region='Northeast',
39+
city='Stowe',
40+
state='VT',
41+
country='USA',
42+
reservation_status='Not Required',
43+
indy_page='https://example.com/nordic-valley',
44+
has_alpine=False,
45+
has_cross_country=True,
46+
has_night_skiing=False,
47+
has_terrain_parks=False,
48+
is_dog_friendly=True,
49+
has_snowshoeing=True,
50+
is_allied=True,
51+
vertical=1200.0,
52+
num_trails=40.0,
53+
num_lifts=5.0,
54+
trail_length_mi=30.0,
55+
),
56+
ResortSummary(
57+
resort_id='id-3',
58+
name='Mid Mountain',
59+
region='West',
60+
city='Salt Lake City',
61+
state='UT',
62+
country='USA',
63+
reservation_status='Not Required',
64+
indy_page='https://example.com/mid-mountain',
65+
has_alpine=True,
66+
has_cross_country=True,
67+
has_night_skiing=False,
68+
has_terrain_parks=True,
69+
is_dog_friendly=False,
70+
has_snowshoeing=True,
71+
is_allied=False,
72+
vertical=2000.0,
73+
num_trails=70.0,
74+
num_lifts=10.0,
75+
trail_length_mi=None,
76+
),
77+
]
78+
79+
80+
@pytest.fixture(autouse=True)
81+
def patch_resorts():
82+
with patch('main._resorts', FAKE_RESORTS):
83+
yield
84+
85+
86+
@pytest.fixture
87+
def client():
88+
return TestClient(app)
89+
90+
91+
# --- Boolean feature flag filters ---
92+
93+
94+
def test_filter_has_alpine_true(client):
95+
response = client.get('/resorts?has_alpine=true')
96+
assert response.status_code == 200
97+
names = {r['name'] for r in response.json()}
98+
assert names == {'Alpine Peak', 'Mid Mountain'}
99+
100+
101+
def test_filter_has_alpine_false(client):
102+
response = client.get('/resorts?has_alpine=false')
103+
assert response.status_code == 200
104+
names = {r['name'] for r in response.json()}
105+
assert names == {'Nordic Valley'}
106+
107+
108+
def test_filter_has_cross_country(client):
109+
response = client.get('/resorts?has_cross_country=true')
110+
names = {r['name'] for r in response.json()}
111+
assert names == {'Nordic Valley', 'Mid Mountain'}
112+
113+
114+
def test_filter_has_night_skiing(client):
115+
response = client.get('/resorts?has_night_skiing=true')
116+
names = {r['name'] for r in response.json()}
117+
assert names == {'Alpine Peak'}
118+
119+
120+
def test_filter_has_terrain_parks(client):
121+
response = client.get('/resorts?has_terrain_parks=true')
122+
names = {r['name'] for r in response.json()}
123+
assert names == {'Alpine Peak', 'Mid Mountain'}
124+
125+
126+
def test_filter_is_dog_friendly(client):
127+
response = client.get('/resorts?is_dog_friendly=true')
128+
names = {r['name'] for r in response.json()}
129+
assert names == {'Nordic Valley'}
130+
131+
132+
def test_filter_has_snowshoeing(client):
133+
response = client.get('/resorts?has_snowshoeing=true')
134+
names = {r['name'] for r in response.json()}
135+
assert names == {'Nordic Valley', 'Mid Mountain'}
136+
137+
138+
def test_filter_is_allied(client):
139+
response = client.get('/resorts?is_allied=true')
140+
names = {r['name'] for r in response.json()}
141+
assert names == {'Nordic Valley'}
142+
143+
144+
# --- reservation_required ---
145+
146+
147+
def test_reservation_required_true(client):
148+
response = client.get('/resorts?reservation_required=true')
149+
assert response.status_code == 200
150+
data = response.json()
151+
assert len(data) == 1
152+
assert data[0]['name'] == 'Alpine Peak'
153+
154+
155+
def test_reservation_required_false(client):
156+
response = client.get('/resorts?reservation_required=false')
157+
assert response.status_code == 200
158+
names = {r['name'] for r in response.json()}
159+
assert names == {'Nordic Valley', 'Mid Mountain'}
160+
161+
162+
# --- Numeric range filters ---
163+
164+
165+
def test_min_vertical(client):
166+
response = client.get('/resorts?min_vertical=2000')
167+
names = {r['name'] for r in response.json()}
168+
assert names == {'Alpine Peak', 'Mid Mountain'}
169+
170+
171+
def test_max_vertical(client):
172+
response = client.get('/resorts?max_vertical=2000')
173+
names = {r['name'] for r in response.json()}
174+
assert names == {'Nordic Valley', 'Mid Mountain'}
175+
176+
177+
def test_vertical_range(client):
178+
response = client.get('/resorts?min_vertical=1500&max_vertical=2500')
179+
names = {r['name'] for r in response.json()}
180+
assert names == {'Mid Mountain'}
181+
182+
183+
def test_min_trails(client):
184+
response = client.get('/resorts?min_trails=70')
185+
names = {r['name'] for r in response.json()}
186+
assert names == {'Alpine Peak', 'Mid Mountain'}
187+
188+
189+
def test_max_trails(client):
190+
response = client.get('/resorts?max_trails=40')
191+
names = {r['name'] for r in response.json()}
192+
assert names == {'Nordic Valley'}
193+
194+
195+
def test_min_lifts(client):
196+
response = client.get('/resorts?min_lifts=10')
197+
names = {r['name'] for r in response.json()}
198+
assert names == {'Alpine Peak', 'Mid Mountain'}
199+
200+
201+
def test_max_lifts(client):
202+
response = client.get('/resorts?max_lifts=5')
203+
names = {r['name'] for r in response.json()}
204+
assert names == {'Nordic Valley'}
205+
206+
207+
def test_min_trail_length(client):
208+
response = client.get('/resorts?min_trail_length=50')
209+
names = {r['name'] for r in response.json()}
210+
# Mid Mountain has None trail_length_mi — excluded
211+
assert names == {'Alpine Peak'}
212+
213+
214+
def test_max_trail_length(client):
215+
response = client.get('/resorts?max_trail_length=30')
216+
names = {r['name'] for r in response.json()}
217+
assert names == {'Nordic Valley'}
218+
219+
220+
def test_range_excludes_null_values(client):
221+
# Mid Mountain has no trail_length_mi — should be excluded from range filter
222+
response = client.get('/resorts?min_trail_length=1')
223+
names = {r['name'] for r in response.json()}
224+
assert 'Mid Mountain' not in names
225+
226+
227+
# --- Composability ---
228+
229+
230+
def test_bool_and_range_combined(client):
231+
response = client.get('/resorts?has_alpine=true&max_vertical=2500')
232+
names = {r['name'] for r in response.json()}
233+
assert names == {'Mid Mountain'}
234+
235+
236+
def test_bool_and_reservation_combined(client):
237+
response = client.get('/resorts?has_alpine=true&reservation_required=false')
238+
names = {r['name'] for r in response.json()}
239+
assert names == {'Mid Mountain'}

0 commit comments

Comments
 (0)