Skip to content

Commit 979b170

Browse files
Merge pull request #133 from jonathanstelman/feature/83-resort-data-validation
Add data validation to Resort Pydantic model (#83)
2 parents 65831bf + c13b566 commit 979b170

5 files changed

Lines changed: 237 additions & 27 deletions

File tree

backend/models.py

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,84 @@
1+
import logging
2+
import re
13
from typing import Optional
2-
from pydantic import BaseModel
4+
from pydantic import BaseModel, Field, field_validator
5+
6+
logger = logging.getLogger(__name__)
7+
8+
URL_PATTERN = re.compile(r'^https?://')
9+
10+
# (lo, hi) bounds for fields where an out-of-range value most likely means a scraping
11+
# glitch (e.g. an extra digit) rather than a real resort. Ceilings are set well above the
12+
# largest real value currently in data/resorts.csv, not at some theoretical world-record max.
13+
_NONNEG_BOUNDS: dict[str, tuple[float, Optional[float]]] = {
14+
'acres': (0, 50_000),
15+
'num_trails': (0, 1_000),
16+
'num_trails_xc': (0, 500),
17+
'num_lifts': (0, 500),
18+
'vertical': (0, 15_000),
19+
'vertical_meters': (0, 5_000),
20+
'vertical_base_ft': (0, 20_000),
21+
'vertical_summit_ft': (0, 25_000),
22+
'vertical_elevation_ft': (0, 15_000),
23+
'snowfall_average_in': (0, 2_000),
24+
'snowfall_high_in': (0, 2_500),
25+
'trail_length_mi': (0, 200),
26+
'trail_length_km': (0, 320),
27+
}
28+
29+
_DIFFICULTY_FIELDS = (
30+
'difficulty_beginner',
31+
'difficulty_intermediate',
32+
'difficulty_advanced',
33+
'difficulty_beginner_xc',
34+
'difficulty_intermediate_xc',
35+
'difficulty_advanced_xc',
36+
)
37+
38+
# Peak Rankings has, at least once, scored a resort an 11 (Spinal Tap style) — the ceiling
39+
# accommodates that rather than treating it as a data error.
40+
_PR_SUBSCORE_FIELDS = (
41+
'pr_snow',
42+
'pr_resiliency',
43+
'pr_size',
44+
'pr_terrain_diversity',
45+
'pr_challenge',
46+
'pr_lifts',
47+
'pr_crowd_flow',
48+
'pr_facilities',
49+
'pr_navigation',
50+
'pr_mountain_aesthetic',
51+
)
52+
53+
54+
def _null_if_out_of_range(value, lo, hi, field_name, resort_id):
55+
if value is None:
56+
return None
57+
if (lo is not None and value < lo) or (hi is not None and value > hi):
58+
logger.warning(
59+
'resort=%s field=%s value=%r out of range [%s, %s] — nulling',
60+
resort_id,
61+
field_name,
62+
value,
63+
lo,
64+
hi,
65+
)
66+
return None
67+
return value
68+
69+
70+
def _null_if_bad_url(value, field_name, resort_id):
71+
if value is None:
72+
return None
73+
if not URL_PATTERN.match(value):
74+
logger.warning(
75+
'resort=%s field=%s value=%r is not a valid http(s) URL — nulling',
76+
resort_id,
77+
field_name,
78+
value,
79+
)
80+
return None
81+
return value
382

483

584
class RangeField(BaseModel):
@@ -113,7 +192,7 @@ class Resort(BaseModel):
113192
city: Optional[str] = None
114193
state: Optional[str] = None
115194
country: Optional[str] = None
116-
indy_page: str
195+
indy_page: str = Field(pattern=URL_PATTERN)
117196
website: Optional[str] = None
118197
reservation_status: str
119198
reservation_url: Optional[str] = None
@@ -185,3 +264,34 @@ class Resort(BaseModel):
185264
pr_nearest_cities: Optional[str] = None
186265
pr_pass_affiliation: Optional[str] = None
187266
pr_total_tt: Optional[str] = None
267+
268+
@field_validator(*_NONNEG_BOUNDS.keys(), mode='after')
269+
@classmethod
270+
def _validate_nonneg_bounded(cls, v, info):
271+
lo, hi = _NONNEG_BOUNDS[info.field_name]
272+
return _null_if_out_of_range(v, lo, hi, info.field_name, info.data.get('resort_id'))
273+
274+
@field_validator(*_DIFFICULTY_FIELDS, mode='after')
275+
@classmethod
276+
def _validate_difficulty(cls, v, info):
277+
return _null_if_out_of_range(v, 0, 100, info.field_name, info.data.get('resort_id'))
278+
279+
@field_validator(*_PR_SUBSCORE_FIELDS, mode='after')
280+
@classmethod
281+
def _validate_pr_subscore(cls, v, info):
282+
return _null_if_out_of_range(v, 0, 11, info.field_name, info.data.get('resort_id'))
283+
284+
@field_validator('pr_total', mode='after')
285+
@classmethod
286+
def _validate_pr_total(cls, v, info):
287+
return _null_if_out_of_range(v, 0, None, info.field_name, info.data.get('resort_id'))
288+
289+
@field_validator('pr_overall_rank', 'pr_regional_rank', mode='after')
290+
@classmethod
291+
def _validate_pr_rank(cls, v, info):
292+
return _null_if_out_of_range(v, 1, None, info.field_name, info.data.get('resort_id'))
293+
294+
@field_validator('website', 'reservation_url', mode='after')
295+
@classmethod
296+
def _validate_optional_url(cls, v, info):
297+
return _null_if_bad_url(v, info.field_name, info.data.get('resort_id'))

backend/tests/test_meta_endpoint.py

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,16 @@
2727
num_lifts=10.0,
2828
trail_length_mi=50.0,
2929
pr_total=85.0,
30-
pr_snow=90.0,
31-
pr_resiliency=80.0,
32-
pr_size=75.0,
33-
pr_terrain_diversity=70.0,
34-
pr_challenge=65.0,
35-
pr_lifts=60.0,
36-
pr_crowd_flow=55.0,
37-
pr_facilities=50.0,
38-
pr_navigation=45.0,
39-
pr_mountain_aesthetic=95.0,
30+
pr_snow=9.0,
31+
pr_resiliency=8.0,
32+
pr_size=7.5,
33+
pr_terrain_diversity=7.0,
34+
pr_challenge=6.5,
35+
pr_lifts=6.0,
36+
pr_crowd_flow=5.5,
37+
pr_facilities=5.0,
38+
pr_navigation=4.5,
39+
pr_mountain_aesthetic=9.5,
4040
blackout_all_dates=json.dumps(['2025-12-25', '2025-12-26']),
4141
ltt_blackout_all_dates=json.dumps(['2026-01-01']),
4242
),
@@ -55,16 +55,16 @@
5555
num_lifts=5.0,
5656
trail_length_mi=25.0,
5757
pr_total=70.0,
58-
pr_snow=75.0,
59-
pr_resiliency=65.0,
60-
pr_size=60.0,
61-
pr_terrain_diversity=55.0,
62-
pr_challenge=50.0,
63-
pr_lifts=45.0,
64-
pr_crowd_flow=40.0,
65-
pr_facilities=35.0,
66-
pr_navigation=30.0,
67-
pr_mountain_aesthetic=80.0,
58+
pr_snow=7.5,
59+
pr_resiliency=6.5,
60+
pr_size=6.0,
61+
pr_terrain_diversity=5.5,
62+
pr_challenge=5.0,
63+
pr_lifts=4.5,
64+
pr_crowd_flow=4.0,
65+
pr_facilities=3.5,
66+
pr_navigation=3.0,
67+
pr_mountain_aesthetic=8.0,
6868
blackout_all_dates=json.dumps(['2025-12-24', '2026-01-02']),
6969
ltt_blackout_all_dates=json.dumps(['2026-01-15']),
7070
),
@@ -111,7 +111,7 @@ def test_meta_null_values_excluded_from_ranges():
111111
response = client.get('/meta')
112112
data = response.json()
113113
# id-3 has no numeric fields; ranges should still be computed from id-1 and id-2
114-
assert data['pr_mountain_aesthetic'] == {'min': 80.0, 'max': 95.0}
114+
assert data['pr_mountain_aesthetic'] == {'min': 8.0, 'max': 9.5}
115115

116116

117117
def test_meta_blackout_date_ranges():
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
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 pydantic import ValidationError
8+
9+
from data import load_resorts
10+
from models import Resort
11+
12+
13+
BASE_KWARGS = dict(
14+
resort_id='r1',
15+
name='Test Resort',
16+
region='Test Region',
17+
indy_page='https://example.com/test',
18+
reservation_status='Not Required',
19+
)
20+
21+
22+
def test_negative_acres_is_nulled(caplog):
23+
with caplog.at_level('WARNING'):
24+
resort = Resort(**BASE_KWARGS, acres=-5)
25+
assert resort.acres is None
26+
assert any('acres' in r.message for r in caplog.records)
27+
28+
29+
def test_acres_within_bounds_is_kept():
30+
resort = Resort(**BASE_KWARGS, acres=500)
31+
assert resort.acres == 500
32+
33+
34+
def test_acres_absurdly_large_is_nulled():
35+
# simulates a scraped value with an extra digit tacked on
36+
resort = Resort(**BASE_KWARGS, acres=500000)
37+
assert resort.acres is None
38+
39+
40+
def test_difficulty_over_100_is_nulled():
41+
resort = Resort(**BASE_KWARGS, difficulty_beginner=150)
42+
assert resort.difficulty_beginner is None
43+
44+
45+
def test_difficulty_within_bounds_is_kept():
46+
resort = Resort(**BASE_KWARGS, difficulty_beginner=40)
47+
assert resort.difficulty_beginner == 40
48+
49+
50+
def test_pr_subscore_of_11_is_kept():
51+
# Peak Rankings has actually given a resort an 11 (Spinal Tap style)
52+
resort = Resort(**BASE_KWARGS, pr_snow=11)
53+
assert resort.pr_snow == 11
54+
55+
56+
def test_pr_subscore_over_11_is_nulled():
57+
resort = Resort(**BASE_KWARGS, pr_snow=15)
58+
assert resort.pr_snow is None
59+
60+
61+
def test_pr_rank_of_zero_is_nulled():
62+
resort = Resort(**BASE_KWARGS, pr_overall_rank=0)
63+
assert resort.pr_overall_rank is None
64+
65+
66+
def test_pr_total_negative_is_nulled():
67+
resort = Resort(**BASE_KWARGS, pr_total=-1)
68+
assert resort.pr_total is None
69+
70+
71+
def test_malformed_optional_url_is_nulled():
72+
resort = Resort(**BASE_KWARGS, website='not-a-url')
73+
assert resort.website is None
74+
75+
76+
def test_valid_optional_url_is_kept():
77+
resort = Resort(**BASE_KWARGS, website='https://example.com')
78+
assert resort.website == 'https://example.com'
79+
80+
81+
def test_malformed_indy_page_raises():
82+
kwargs = dict(BASE_KWARGS)
83+
kwargs['indy_page'] = 'not-a-url'
84+
with pytest.raises(ValidationError):
85+
Resort(**kwargs)
86+
87+
88+
def test_load_resorts_still_loads_real_data_without_hard_failures():
89+
resorts = load_resorts()
90+
assert len(resorts) > 0

docs/decisions.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,4 +273,11 @@ Format for new entries:
273273
**Issue:** #128
274274
**Decision:** Discussed design up front and posted the resolution as a comment on #128 before writing code (full rationale there). Landed on: not a locale selector — manual, uniform, explicit toggle only, since locale doesn't reliably predict preference and ski culture already has cross-locale exceptions (vertical drop commonly stays in feet even at metric resorts). Control shape is a single compact button showing the *current* unit as a short text glyph (`ft`/`m`, no flags/globe icons — flags don't render reliably on Windows and are both US-centric and inaccurate as a unit-system metaphor), not a two-option `Segmented`, because mobile screen real estate is too tight for showing both options at once (the search bar already spans full width on mobile). Same shared state (`hooks/useUnits.js` context + `localStorage` persistence, split from `components/common/UnitsProvider.jsx` to satisfy the `react-refresh/only-export-components` lint rule) renders in two places — header (next to "?") and sidebar top row (left-aligned, next to "Reset all filters" which stays right-aligned) — reading/writing the same state directly, no read-only mirror. Implementation is formatter-based (`utils/units.js`: `convert*` for plain numbers, `format*` for numbers with a unit suffix) over the canonical imperial value from the API, not the pre-computed parallel-column pattern (`vertical_meters`, `trail_length_km` in the CSV are now unused by the frontend) — new unit-bearing fields never need a pipeline-side `_meters`/`_km` companion column. `ResortTable.jsx`'s `vertical`/`vertical_meters` and `trail_length_mi`/`trail_length_km` collapsed into single toggle-driven columns (AG Grid `context={{ unit }}` + `headerValueGetter`/`valueFormatter`, refreshed via `api.refreshHeader()`/`refreshCells({force:true})` on toggle) rather than staying as two independent always-optional columns — removes the redundancy the toggle exists to solve. `StatsFilters.jsx`'s Vertical/Trail Length XC sliders convert their displayed labels/values while filtering continues against canonical ft/mi server-side (display-only layer, filter contract unchanged). `HowToUseModal.jsx` documents the toggle; its `Btn` chip helper gained a `variant="primary"` (teal, matching the real button) since the default variant is pink and was misrepresenting the actual toggle color.
275275
**Rationale:** Two live-review fixes applied after initial implementation: the sidebar toggle was grouped with "Reset all filters" via `marginLeft: auto`, reading as right-aligned in a narrow sidebar — moved to its own left-hand flex group so the row reads left-to-right (toggle+close-button, then Reset). Hectares confirmed correct as the metric acreage unit (not a typo/mix-up with "hectacre") — 1 ha = 10,000 m² = 2.471 ac, the standard metric land-area unit paired with acres.
276+
277+
---
278+
## 2026-07-13 — Data validation added to `Resort` Pydantic model
279+
**Issue:** #83
280+
**Decision:** Constraints added to `backend/models.py`'s `Resort` model only (the CSV-parsing boundary in `backend/data.py`) — `ResortSummary` is always built from an already-validated `Resort.model_dump()` (`main.py:279`), never parsed from raw input, so duplicate constraints there would validate a scenario that can't happen. Two-tier design: **hard** (raise) for missing required fields and a malformed `indy_page` (the one required URL, via `Field(pattern=...)`); **soft** (log a warning, coerce to `None`) for every optional numeric/URL field via `field_validator(mode='after')` — one glitchy scraped value nulls itself out rather than taking down the whole resort or the pipeline run. Bounds were set from an actual audit of `data/resorts.csv` (not guessed): non-negative fields (acres, num_trails(+_xc), num_lifts, vertical variants, snowfall, trail_length) got both a floor *and* a generous ceiling (e.g. acres 0–50,000 against an observed max of 19,136) to catch a scraped value with an accidental extra digit; difficulty percentages 0–100; Peak Rankings subscores 0–**11** (not 10 — Peak Rankings has actually issued a real 11, Spinal-Tap-style, so the ceiling accommodates it instead of nulling a legitimate score); `pr_total` floor only (no confident ceiling); PR ranks `ge=1`.
281+
**Rationale:** Ceilings are calibrated to current real data with headroom, not a theoretical world-record max — the goal is catching obvious scraping glitches (10x errors), not constraining legitimate future resorts. Considered and rejected: applying the same constraints to `ResortSummary` (redundant, since it's never independently parsed); using `AnyUrl` for URL fields (normalizes/rewrites the string, e.g. adds a trailing slash to bare domains, which could subtly alter what's served — a plain regex `Field(pattern=...)`/soft-null keeps the string byte-for-byte as scraped); hard-failing the whole row on any out-of-range value (rejected per user direction — a single bad field on one resort shouldn't abort load or hide an otherwise-good resort). Verified against production data: all 277 current resorts load with zero fields nulled by the new bounds (bounds are accommodating enough for real data as it exists today).
282+
**Follow-up:** #77 (scheduled pipeline) still needs its own design pass on how the soft-null warnings surface in the pre-PR review — e.g. a "N fields nulled across M resorts" summary in the PR description — since silent nulling means `load_resorts()` no longer raises on out-of-range values, only on structural failures (missing required fields, malformed `indy_page`). #132 (drop unused `vertical_meters`/`trail_length_km`) will remove some of these fields' constraints entirely once done — not done yet, low priority.
276283
**Follow-up:** None — settled. Verified live via Playwright at desktop and 390px mobile viewports: toggle stays in sync across header/sidebar/table/sliders/detail modal/map tooltip, and the preference survives a full reload via `localStorage`. Not yet committed.

docs/planning.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ Current work-in-progress. Update this file at the start and end of every session
66

77
## Current Branch
88

9-
`feature/128-unit-selector` — Unit selector (imperial/metric). Implemented and verified live via Playwright (desktop + 390px mobile); not yet committed. See `docs/decisions.md` (2026-07-13) for the full design rationale, posted to #128 as a comment before implementation began.
9+
`feature/83-resort-data-validation` (commit `dde6e90`) — **#83 (data validation on Resort Pydantic model)**: `backend/models.py` gained bounded `field_validator`s (soft log+null) and a hard `indy_page` URL pattern constraint; `backend/tests/test_resort_validation.py` added; `backend/tests/test_meta_endpoint.py` fixture rescaled from placeholder 0–100 PR subscores to the real 0–11 scale. Full backend + pipeline suites pass, Black clean, verified against real `data/resorts.csv` (277 resorts, zero fields nulled). Backend-only change, no frontend surface to verify live. See `docs/decisions.md` (2026-07-13) for full rationale. Committed but not yet pushed/PR'd. Next: push branch, open PR, then #77 (scheduled pipeline, depends on this issue) can proceed.
10+
11+
**#128 (unit selector) merged and deployed (2026-07-13)**, PR #131. Follow-up issue **#132** opened (low priority): drop now-unused `vertical_meters`/`trail_length_km` (and check `vertical_tt`/`acres_tt`) from the pipeline/backend model now that the frontend converts client-side instead of reading precomputed columns.
1012

1113
**#118 merged and deployed (2026-07-12):** AG Grid Theming API migration — see `docs/decisions.md` for the full trail (real prior styling was mostly silently broken; row hover, checkmark contrast fix, header separator investigation).
1214

@@ -37,11 +39,12 @@ Target: public launch on Indy Pass Facebook groups ahead of ski season.
3739
| #109 | UX: Peak Rankings visual encoding | P2 | Done |
3840
| #108 | UX: "How to use" first-load popover | P2 | Done |
3941
| #110 | UX: "Help improve this app" feedback section | P2 | Done |
40-
| #83 | Data validation on Resort Pydantic model | P1 (deferred) | Blocks #77 |
42+
| #83 | Data validation on Resort Pydantic model | P1 | Implemented, not yet committed |
4143
| #77 | GitHub Actions scheduled pipeline | P1 (deferred) | Depends on #83 |
4244
| #11 | Bug: alpine+XC metrics parsing | P1 | Done |
4345
| #118 | AG Grid Theming API migration | P2 | Done |
44-
| #128 | Unit selector (imperial/metric) | P2 | Implemented, not yet committed |
46+
| #128 | Unit selector (imperial/metric) | P2 | Done |
47+
| #132 | Drop unused vertical_meters/trail_length_km | P3 | Open, not started |
4548

4649
**#113 merged and deployed (2026-05-30):**
4750
- Map/Table tab switcher, footer hidden, unified attribution ⓘ in tab bar
@@ -106,7 +109,7 @@ Small tasks interspersed with larger feature work. Check off here and in the Git
106109
- Also fixed `Footer.jsx`'s unrelated pre-existing antd deprecation warning (`overlayInnerStyle``styles.container`, same fix pattern as #119's `HowToUseModal`) — console is now fully clean on load, zero warnings or errors.
107110
- Follow-up caught after the first commit: `BoolCell`'s true-value checkmark used `COLORS.success` — same color as the new chartreuse row hover, so checkmarks vanished on hover (green-on-green). Changed to `COLORS.bgHeader` (near-black), trading the green "yes" cue for guaranteed legibility against both white and hovered rows.
108111

109-
**#83/#77 deferred:** Cosmetic P2 issues take priority over automated pipeline work — the data being a day or two out-of-date isn't consequential right now.
112+
**#83 done (2026-07-13):** see Current Branch section above and `docs/decisions.md` for full detail.
110113

111114
**#77 revised scope:** Pipeline runs on a schedule and opens a PR against main rather than auto-committing. Human reviews the data diff before merging. Pre-PR sanity check runs `load_resorts()` against the new CSV — any Pydantic validation failures abort the job before a PR is opened. See issue for full acceptance criteria.
112115

0 commit comments

Comments
 (0)