Skip to content

Commit 34eecb7

Browse files
Merge pull request #91 from jonathanstelman/feature/61-get-meta-endpoint
Add GET /meta endpoint
2 parents 44cdbf9 + 18fdbf4 commit 34eecb7

3 files changed

Lines changed: 196 additions & 1 deletion

File tree

backend/main.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from typing import Optional
55

66
from data import load_resorts
7-
from models import Resort, ResortSummary
7+
from models import Resort, ResortSummary, MetaResponse, RangeField, DateRangeField
88

99
_resorts: list[Resort] = []
1010

@@ -34,6 +34,40 @@ def _parse_date_list(value: Optional[str]) -> set[str]:
3434
return set()
3535

3636

37+
@app.get("/meta", response_model=MetaResponse)
38+
def get_meta():
39+
def _range(field: str) -> RangeField:
40+
vals = [v for r in _resorts if (v := getattr(r, field)) is not None]
41+
return RangeField(min=min(vals) if vals else None, max=max(vals) if vals else None)
42+
43+
def _date_range(field: str) -> DateRangeField:
44+
dates = {d for r in _resorts for d in _parse_date_list(getattr(r, field))}
45+
return DateRangeField(min=min(dates) if dates else None, max=max(dates) if dates else None)
46+
47+
return MetaResponse(
48+
regions=sorted({r.region for r in _resorts if r.region}),
49+
countries=sorted({r.country for r in _resorts if r.country}),
50+
states=sorted({r.state for r in _resorts if r.state}),
51+
vertical=_range('vertical'),
52+
num_trails=_range('num_trails'),
53+
num_lifts=_range('num_lifts'),
54+
trail_length_mi=_range('trail_length_mi'),
55+
pr_total=_range('pr_total'),
56+
pr_snow=_range('pr_snow'),
57+
pr_resiliency=_range('pr_resiliency'),
58+
pr_size=_range('pr_size'),
59+
pr_terrain_diversity=_range('pr_terrain_diversity'),
60+
pr_challenge=_range('pr_challenge'),
61+
pr_lifts=_range('pr_lifts'),
62+
pr_crowd_flow=_range('pr_crowd_flow'),
63+
pr_facilities=_range('pr_facilities'),
64+
pr_navigation=_range('pr_navigation'),
65+
pr_mountain_aesthetic=_range('pr_mountain_aesthetic'),
66+
blackout_date_range=_date_range('blackout_all_dates'),
67+
ltt_date_range=_date_range('ltt_blackout_all_dates'),
68+
)
69+
70+
3771
@app.get("/resorts/{resort_id}", response_model=Resort)
3872
def get_resort(resort_id: str):
3973
for r in _resorts:

backend/models.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,39 @@
22
from pydantic import BaseModel
33

44

5+
class RangeField(BaseModel):
6+
min: Optional[float] = None
7+
max: Optional[float] = None
8+
9+
10+
class DateRangeField(BaseModel):
11+
min: Optional[str] = None # ISO date string YYYY-MM-DD
12+
max: Optional[str] = None
13+
14+
15+
class MetaResponse(BaseModel):
16+
regions: list[str]
17+
countries: list[str]
18+
states: list[str]
19+
vertical: RangeField
20+
num_trails: RangeField
21+
num_lifts: RangeField
22+
trail_length_mi: RangeField
23+
pr_total: RangeField
24+
pr_snow: RangeField
25+
pr_resiliency: RangeField
26+
pr_size: RangeField
27+
pr_terrain_diversity: RangeField
28+
pr_challenge: RangeField
29+
pr_lifts: RangeField
30+
pr_crowd_flow: RangeField
31+
pr_facilities: RangeField
32+
pr_navigation: RangeField
33+
pr_mountain_aesthetic: RangeField
34+
blackout_date_range: DateRangeField
35+
ltt_date_range: DateRangeField
36+
37+
538
class ResortSummary(BaseModel):
639
"""Lean projection returned by GET /resorts — fields needed for the map and table."""
740

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import sys
2+
import os
3+
import json
4+
5+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
6+
7+
from fastapi.testclient import TestClient
8+
from unittest.mock import patch
9+
10+
from main import app
11+
from models import Resort
12+
13+
FAKE_RESORTS = [
14+
Resort(
15+
resort_id='id-1',
16+
name='Alpine Resort',
17+
region='West',
18+
city='Vail',
19+
state='CO',
20+
country='USA',
21+
reservation_status='none',
22+
indy_page='https://example.com/alpine',
23+
vertical=2000.0,
24+
num_trails=100.0,
25+
num_lifts=10.0,
26+
trail_length_mi=50.0,
27+
pr_total=85.0,
28+
pr_snow=90.0,
29+
pr_resiliency=80.0,
30+
pr_size=75.0,
31+
pr_terrain_diversity=70.0,
32+
pr_challenge=65.0,
33+
pr_lifts=60.0,
34+
pr_crowd_flow=55.0,
35+
pr_facilities=50.0,
36+
pr_navigation=45.0,
37+
pr_mountain_aesthetic=95.0,
38+
blackout_all_dates=json.dumps(['2025-12-25', '2025-12-26']),
39+
ltt_blackout_all_dates=json.dumps(['2026-01-01']),
40+
),
41+
Resort(
42+
resort_id='id-2',
43+
name='Nordic Lodge',
44+
region='Northeast',
45+
city='Stowe',
46+
state='VT',
47+
country='USA',
48+
reservation_status='none',
49+
indy_page='https://example.com/nordic',
50+
vertical=1000.0,
51+
num_trails=50.0,
52+
num_lifts=5.0,
53+
trail_length_mi=25.0,
54+
pr_total=70.0,
55+
pr_snow=75.0,
56+
pr_resiliency=65.0,
57+
pr_size=60.0,
58+
pr_terrain_diversity=55.0,
59+
pr_challenge=50.0,
60+
pr_lifts=45.0,
61+
pr_crowd_flow=40.0,
62+
pr_facilities=35.0,
63+
pr_navigation=30.0,
64+
pr_mountain_aesthetic=80.0,
65+
blackout_all_dates=json.dumps(['2025-12-24', '2026-01-02']),
66+
ltt_blackout_all_dates=json.dumps(['2026-01-15']),
67+
),
68+
Resort(
69+
resort_id='id-3',
70+
name='Tremblant',
71+
region='Northeast',
72+
city='Mont-Tremblant',
73+
state=None,
74+
country='Canada',
75+
reservation_status='none',
76+
indy_page='https://example.com/tremblant',
77+
# no numeric fields — tests that nulls are excluded from ranges
78+
),
79+
]
80+
81+
client = TestClient(app)
82+
83+
84+
def test_meta_regions_countries_states():
85+
with patch('main._resorts', FAKE_RESORTS):
86+
response = client.get('/meta')
87+
assert response.status_code == 200
88+
data = response.json()
89+
assert data['regions'] == ['Northeast', 'West']
90+
assert data['countries'] == ['Canada', 'USA']
91+
assert data['states'] == ['CO', 'VT']
92+
93+
94+
def test_meta_numeric_ranges():
95+
with patch('main._resorts', FAKE_RESORTS):
96+
response = client.get('/meta')
97+
data = response.json()
98+
assert data['vertical'] == {'min': 1000.0, 'max': 2000.0}
99+
assert data['num_trails'] == {'min': 50.0, 'max': 100.0}
100+
assert data['num_lifts'] == {'min': 5.0, 'max': 10.0}
101+
assert data['trail_length_mi'] == {'min': 25.0, 'max': 50.0}
102+
assert data['pr_total'] == {'min': 70.0, 'max': 85.0}
103+
104+
105+
def test_meta_null_values_excluded_from_ranges():
106+
with patch('main._resorts', FAKE_RESORTS):
107+
response = client.get('/meta')
108+
data = response.json()
109+
# id-3 has no numeric fields; ranges should still be computed from id-1 and id-2
110+
assert data['pr_mountain_aesthetic'] == {'min': 80.0, 'max': 95.0}
111+
112+
113+
def test_meta_blackout_date_ranges():
114+
with patch('main._resorts', FAKE_RESORTS):
115+
response = client.get('/meta')
116+
data = response.json()
117+
assert data['blackout_date_range'] == {'min': '2025-12-24', 'max': '2026-01-02'}
118+
assert data['ltt_date_range'] == {'min': '2026-01-01', 'max': '2026-01-15'}
119+
120+
121+
def test_meta_empty_resorts():
122+
with patch('main._resorts', []):
123+
response = client.get('/meta')
124+
assert response.status_code == 200
125+
data = response.json()
126+
assert data['regions'] == []
127+
assert data['vertical'] == {'min': None, 'max': None}
128+
assert data['blackout_date_range'] == {'min': None, 'max': None}

0 commit comments

Comments
 (0)