Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 35 additions & 6 deletions backend/main.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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),
Expand All @@ -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),
Expand All @@ -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

Expand All @@ -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),
Expand All @@ -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:
Expand All @@ -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),
Expand All @@ -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]
1 change: 1 addition & 0 deletions backend/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
172 changes: 172 additions & 0 deletions backend/tests/test_resorts_blackout_filters.py
Original file line number Diff line number Diff line change
@@ -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&ltt_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&ltt_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&ltt_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'}
8 changes: 4 additions & 4 deletions backend/tests/test_resorts_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -21,7 +21,7 @@
reservation_status='required',
indy_page='https://example.com/vail',
),
ResortSummary(
Resort(
resort_id='id-2',
name='Stowe',
region='Northeast',
Expand All @@ -31,7 +31,7 @@
reservation_status='none',
indy_page='https://example.com/stowe',
),
ResortSummary(
Resort(
resort_id='id-3',
name='Tremblant',
region='Northeast',
Expand Down
8 changes: 4 additions & 4 deletions backend/tests/test_resorts_feature_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -32,7 +32,7 @@
num_lifts=15.0,
trail_length_mi=80.0,
),
ResortSummary(
Resort(
resort_id='id-2',
name='Nordic Valley',
region='Northeast',
Expand All @@ -53,7 +53,7 @@
num_lifts=5.0,
trail_length_mi=30.0,
),
ResortSummary(
Resort(
resort_id='id-3',
name='Mid Mountain',
region='West',
Expand Down
Loading