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
35 changes: 35 additions & 0 deletions common/utils/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
93 changes: 92 additions & 1 deletion test/common/utils/test_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
])
])

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"]
Loading