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:
+16
-4
@@ -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
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user