From ff0c1b7f6f63033faf447427653fe6e13b27a3ad Mon Sep 17 00:00:00 2001 From: Greg V <6913307+gregv@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:46:32 -0700 Subject: [PATCH] Validate constraints.meals_mode + meals_note (times-only meal schedules) The frontend adds a times-only meals mode: meals_mode "schedule" shows hackers just the meal times (no item selection) with an optional meals_note intro line. Both hackathon validators now enforce meals_mode in ALLOWED_MEALS_MODES {menu, schedule} and meals_note as a string capped at MAX_MEALS_NOTE_LENGTH (500), with partial-save skip semantics. Kept in sync with MEALS_MODE_* / MEALS_NOTE_MAX_LENGTH in the frontend MealSchedule.js. Co-Authored-By: Claude Fable 5 --- common/utils/validators.py | 35 +++++++++++ test/common/utils/test_validators.py | 93 +++++++++++++++++++++++++++- 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/common/utils/validators.py b/common/utils/validators.py index 6aa20ff..6890db7 100644 --- a/common/utils/validators.py +++ b/common/utils/validators.py @@ -161,6 +161,20 @@ def validate_hackathon_data(data): if meals is not None: validate_meals(meals) + # Validate meals_mode if present ("menu" = hackers pick items per slot, + # "schedule" = times-only, nothing to select on the hacker application) + meals_mode = constraints.get("meals_mode") + if meals_mode not in (None, ""): + if not isinstance(meals_mode, str) or meals_mode not in ALLOWED_MEALS_MODES: + raise ValueError(f"meals_mode must be one of {sorted(ALLOWED_MEALS_MODES)}") + + # Validate meals_note if present (optional intro line shown to hackers + # above the times-only meal schedule) + meals_note = constraints.get("meals_note") + if meals_note is not None: + if not isinstance(meals_note, str) or len(meals_note) > MAX_MEALS_NOTE_LENGTH: + raise ValueError(f"meals_note must be a string <= {MAX_MEALS_NOTE_LENGTH} chars") + # Validate event_photos if present event_photos = data.get("event_photos") if event_photos is not None: @@ -283,6 +297,18 @@ def _skip(field, reason): _skip("constraints.meals", str(e)) c.pop("meals") + if "meals_mode" in c and c["meals_mode"] not in (None, ""): + mm = c["meals_mode"] + if not isinstance(mm, str) or mm not in ALLOWED_MEALS_MODES: + _skip("constraints.meals_mode", f"must be one of {sorted(ALLOWED_MEALS_MODES)}") + c.pop("meals_mode") + + if "meals_note" in c and c["meals_note"] is not None: + mn = c["meals_note"] + if not isinstance(mn, str) or len(mn) > MAX_MEALS_NOTE_LENGTH: + _skip("constraints.meals_note", f"must be a string <= {MAX_MEALS_NOTE_LENGTH} chars") + c.pop("meals_note") + cleaned["constraints"] = c # event_photos @@ -330,6 +356,15 @@ def _skip(field, reason): return cleaned, skipped +# How an event collects meals from hackers. Kept in sync with the frontend +# MEALS_MODE_* constants in src/components/ApplicationForm/MealSchedule.js. +# "menu" (default) = hackers pick one item per slot; "schedule" = times-only. +ALLOWED_MEALS_MODES = {"menu", "schedule"} + +# Max length for constraints.meals_note (kept in sync with +# MEALS_NOTE_MAX_LENGTH in the frontend MealSchedule.js). +MAX_MEALS_NOTE_LENGTH = 500 + ALLOWED_DIETARY_TAGS = { "vegetarian", "vegan", diff --git a/test/common/utils/test_validators.py b/test/common/utils/test_validators.py index ac349df..20ad505 100644 --- a/test/common/utils/test_validators.py +++ b/test/common/utils/test_validators.py @@ -20,4 +20,95 @@ def test_validate_social_posts_keeps_platform_host_checks(): with pytest.raises(ValueError, match=r"social_posts\[0\]\.url host must match platform 'linkedin'"): validate_social_posts([ {"platform": "linkedin", "url": "https://example.com/story"}, - ]) \ No newline at end of file + ]) + +from common.utils.validators import ( + ALLOWED_MEALS_MODES, + MAX_MEALS_NOTE_LENGTH, + validate_hackathon_data, + validate_hackathon_data_partial, + validate_meals, +) + + +def _hackathon_data(constraints_extra=None): + """Minimal payload that passes both hackathon validators.""" + constraints = { + "max_people_per_team": 5, + "max_teams_per_problem": 3, + "min_people_per_team": 2, + } + constraints.update(constraints_extra or {}) + return { + "title": "Test Hackathon", + "description": "A test", + "location": "Tempe, Arizona", + "start_date": "2026-10-10", + "end_date": "2026-10-12", + "type": "hackathon", + "image_url": "https://cdn.ohack.dev/test.webp", + "event_id": "fall-2026-test", + "constraints": constraints, + } + + +def test_validate_meals_allows_meal_without_items(): + # A times-only ("schedule" mode) slot carries no menu items at all. + validate_meals([ + {"id": "m1", "name": "Saturday Lunch", "time": "2026-10-10T12:00:00Z"}, + {"id": "m2", "name": "Saturday Dinner", "items": []}, + ]) + + +def test_validate_hackathon_data_accepts_meals_modes(): + for mode in sorted(ALLOWED_MEALS_MODES): + validate_hackathon_data(_hackathon_data({"meals_mode": mode})) + # Unset / empty behave as the default menu mode + validate_hackathon_data(_hackathon_data({"meals_mode": None})) + validate_hackathon_data(_hackathon_data({"meals_mode": ""})) + validate_hackathon_data(_hackathon_data()) + + +def test_validate_hackathon_data_rejects_bad_meals_mode(): + with pytest.raises(ValueError, match="meals_mode must be one of"): + validate_hackathon_data(_hackathon_data({"meals_mode": "buffet"})) + with pytest.raises(ValueError, match="meals_mode must be one of"): + validate_hackathon_data(_hackathon_data({"meals_mode": ["schedule"]})) + + +def test_validate_hackathon_data_meals_note_bounds(): + validate_hackathon_data( + _hackathon_data({"meals_note": "Breakfast, lunch, and dinner provided."}) + ) + with pytest.raises(ValueError, match="meals_note must be a string"): + validate_hackathon_data( + _hackathon_data({"meals_note": "x" * (MAX_MEALS_NOTE_LENGTH + 1)}) + ) + with pytest.raises(ValueError, match="meals_note must be a string"): + validate_hackathon_data(_hackathon_data({"meals_note": 42})) + + +def test_partial_keeps_valid_meals_mode_and_note(): + cleaned, skipped = validate_hackathon_data_partial( + _hackathon_data({"meals_mode": "schedule", "meals_note": "Meals provided."}) + ) + assert skipped == [] + assert cleaned["constraints"]["meals_mode"] == "schedule" + assert cleaned["constraints"]["meals_note"] == "Meals provided." + + +def test_partial_strips_invalid_meals_mode_but_saves_rest(): + cleaned, skipped = validate_hackathon_data_partial( + _hackathon_data({"meals_mode": "buffet", "meals_note": "Meals provided."}) + ) + assert any(s["field"] == "constraints.meals_mode" for s in skipped) + assert "meals_mode" not in cleaned["constraints"] + assert cleaned["constraints"]["meals_note"] == "Meals provided." + + +def test_partial_strips_invalid_meals_note(): + cleaned, skipped = validate_hackathon_data_partial( + _hackathon_data({"meals_note": "x" * (MAX_MEALS_NOTE_LENGTH + 1)}) + ) + assert any(s["field"] == "constraints.meals_note" for s in skipped) + assert "meals_note" not in cleaned["constraints"]