Move Participant/Group to pydantic and add a saveable/editable Plan model

Participant and Group are now pydantic BaseModels. Group.hosts can form
cycles between groups, so it's kept as a private, non-persisted live list
(set via set_hosts()/add_host()) backed by a serializable host_uuids field,
re-linked via resolve_hosts() after a reload.

A new Plan model (src/tatami/plan.py) bundles groups, the after-party group,
and the event config (course_times, organizer_contacts, info_text,
spreadsheet_id) and supports save()/load() to/from JSON. tatami_masterplan's
__main__ now saves to masterplan.json (PLAN_FILE env var to override) on
first run and loads it on later runs instead of recomputing, so the plan can
be hand-edited (move a member between groups, change a course, fill in
spreadsheet_id) and picked up on rerun without hitting the Routes API again.
spreadsheet_id moves out of .env (GOOGLE_SHEETS_SPREADSHEET_ID) onto the plan
itself, since it's part of the plan rather than a secret.
This commit is contained in:
2026-06-19 16:07:47 +02:00
parent 91dc7729f9
commit 6b0fca0100
12 changed files with 538 additions and 157 deletions
+16 -4
View File
@@ -11,8 +11,12 @@ from conftest import make_participants, make_timedelta_matrix
class TestParticipant:
def test_uuid_is_unique(self):
a = Participant("A", "addr", "", 5, "")
b = Participant("A", "addr", "", 5, "")
a = Participant(
name="A", address="addr", phone="", kitchen_size=5, allergies=""
)
b = Participant(
name="A", address="addr", phone="", kitchen_size=5, allergies=""
)
assert a.uuid != b.uuid
@pytest.mark.parametrize(
@@ -20,7 +24,9 @@ class TestParticipant:
[(10, 0), (7, 9), (0, 30), (5, 15)],
)
def test_penalty_scales_with_kitchen_size(self, kitchen_size, minutes):
p = Participant("A", "addr", "", kitchen_size, "")
p = Participant(
name="A", address="addr", phone="", kitchen_size=kitchen_size, allergies=""
)
assert p.get_penalty() == dt.timedelta(minutes=minutes)
def test_after_party_time_combines_penalty_and_travel(self):
@@ -32,7 +38,13 @@ class TestParticipant:
assert result == dt.timedelta(minutes=9) + dt.timedelta(seconds=600)
def test_dict_roundtrip_fields(self):
p = Participant("Alice", "addr", "555", 8, "peanuts")
p = Participant(
name="Alice",
address="addr",
phone="555",
kitchen_size=8,
allergies="peanuts",
)
d = p.dict()
assert d == {
"uuid": p.uuid,
+45 -33
View File
@@ -71,57 +71,69 @@ def _make_mock_groups() -> tuple[list[Group], Group]:
"""3 groups of 3 fictional participants with addresses around Karlsruhe."""
hosts = [
Participant(
"Anna Wagner", "Kaiserstraße 12, 76131 Karlsruhe", "0721-1000001", 9, "none"
name="Anna Wagner",
address="Kaiserstraße 12, 76131 Karlsruhe",
phone="0721-1000001",
kitchen_size=9,
allergies="none",
),
Participant(
"Jonas Becker",
"Waldstraße 5, 76133 Karlsruhe",
"0721-1000002",
7,
"lactose",
name="Jonas Becker",
address="Waldstraße 5, 76133 Karlsruhe",
phone="0721-1000002",
kitchen_size=7,
allergies="lactose",
),
Participant(
"Mira Hofmann", "Yorckstraße 22, 76185 Karlsruhe", "0721-1000003", 8, "none"
name="Mira Hofmann",
address="Yorckstraße 22, 76185 Karlsruhe",
phone="0721-1000003",
kitchen_size=8,
allergies="none",
),
]
semi_hosts = [
Participant(
"Lukas Schreiber",
"Sophienstraße 40, 76135 Karlsruhe",
"0721-1000004",
5,
"nuts",
name="Lukas Schreiber",
address="Sophienstraße 40, 76135 Karlsruhe",
phone="0721-1000004",
kitchen_size=5,
allergies="nuts",
),
Participant(
"Sophie Lindner",
"Beiertheimer Allee 18, 76137 Karlsruhe",
"0721-1000005",
6,
"none",
name="Sophie Lindner",
address="Beiertheimer Allee 18, 76137 Karlsruhe",
phone="0721-1000005",
kitchen_size=6,
allergies="none",
),
Participant(
"Tom Vogel",
"Durlacher Allee 75, 76131 Karlsruhe",
"0721-1000006",
4,
"none",
name="Tom Vogel",
address="Durlacher Allee 75, 76131 Karlsruhe",
phone="0721-1000006",
kitchen_size=4,
allergies="none",
),
Participant(
"Lea Brandt",
"Moltkestraße 30, 76133 Karlsruhe",
"0721-1000007",
3,
"gluten",
name="Lea Brandt",
address="Moltkestraße 30, 76133 Karlsruhe",
phone="0721-1000007",
kitchen_size=3,
allergies="gluten",
),
Participant(
"Felix Krause", "Adlerstraße 14, 76133 Karlsruhe", "0721-1000008", 8, "none"
name="Felix Krause",
address="Adlerstraße 14, 76133 Karlsruhe",
phone="0721-1000008",
kitchen_size=8,
allergies="none",
),
Participant(
"Nora Fink",
"Rüppurrer Straße 60, 76137 Karlsruhe",
"0721-1000009",
5,
"none",
name="Nora Fink",
address="Rüppurrer Straße 60, 76137 Karlsruhe",
phone="0721-1000009",
kitchen_size=5,
allergies="none",
),
]
+103
View File
@@ -0,0 +1,103 @@
"""Tests for the Plan model: saving, reloading, and editing a masterplan."""
import json
from tatami.classes import Group
from tatami.plan import Plan
from tatami.tatami_masterplan import assign_courses, get_after_party_group
from conftest import make_participants
def make_plan(n: int = 6) -> Plan:
groups = [Group(members=[p]) for p in make_participants(n)]
courses = ["starter", "main", "dessert"] * (n // 3)
assign_courses(groups, courses)
after_party = get_after_party_group("party street")
return Plan(
groups=groups,
after_party_group=after_party,
course_times={"starter": "18:30", "main": "20:00", "dessert": "22:00"},
organizer_contacts=[("Lars (Organizer)", "0151-23456789")],
info_text="Welcome!",
)
class TestPlanRoundTrip:
def test_save_and_load_preserves_groups_and_hosts(self, tmp_path):
plan = make_plan()
path = tmp_path / "masterplan.json"
plan.save(path)
loaded = Plan.load(path)
assert [g.uuid for g in loaded.groups] == [g.uuid for g in plan.groups]
for original, reloaded in zip(plan.groups, loaded.groups):
assert reloaded.course == original.course
assert [h.uuid for h in reloaded.hosts] == [h.uuid for h in original.hosts]
def test_load_resolves_live_host_objects_not_just_uuids(self, tmp_path):
plan = make_plan()
path = tmp_path / "masterplan.json"
plan.save(path)
loaded = Plan.load(path)
group = loaded.groups[0]
assert group.hosts is not None
# Hosts are live Group objects (so .course etc. is accessible), not just ids.
assert all(isinstance(h, Group) for h in group.hosts)
assert {h.course for h in group.hosts} == {"starter", "main", "dessert"}
def test_round_trip_is_idempotent(self, tmp_path):
plan = make_plan()
path = tmp_path / "masterplan.json"
plan.save(path)
first = json.loads(path.read_text())
Plan.load(path).save(path)
second = json.loads(path.read_text())
assert first == second
def test_carries_course_times_contacts_info_and_spreadsheet_id(self, tmp_path):
plan = make_plan()
plan.spreadsheet_id = "abc123"
path = tmp_path / "masterplan.json"
plan.save(path)
loaded = Plan.load(path)
assert loaded.course_times == plan.course_times
assert loaded.organizer_contacts == plan.organizer_contacts
assert loaded.info_text == plan.info_text
assert loaded.spreadsheet_id == "abc123"
def test_spreadsheet_id_defaults_to_none(self):
assert make_plan().spreadsheet_id is None
class TestPlanEditing:
def test_editing_saved_json_changes_reloaded_plan(self, tmp_path):
plan = make_plan()
path = tmp_path / "masterplan.json"
plan.save(path)
data = json.loads(path.read_text())
data["spreadsheet_id"] = "edited-by-hand"
data["groups"][0]["members"][0]["address"] = "New Address 1"
path.write_text(json.dumps(data))
loaded = Plan.load(path)
assert loaded.spreadsheet_id == "edited-by-hand"
assert loaded.groups[0].members[0].address == "New Address 1"
class TestPlanParticipants:
def test_participants_property_collects_everyone_once(self):
plan = make_plan()
uuids = [p.uuid for p in plan.participants]
assert len(uuids) == len(set(uuids))
member_uuids = {m.uuid for g in plan.groups for m in g.members}
member_uuids.add(plan.after_party_group.main_member.uuid)
assert set(uuids) == member_uuids