Add optional Google Sheets export for the masterplan

Lets organizers automatically populate a shared Google Sheet (the same kind
they previously built by hand) with an Overview tab and one tab per group,
instead of printing dicts. Group tabs are named after all their members
(not a single host) and show each group's route, course times, and guests
with allergies. The Overview tab also gets configurable Meal Times, Support
Contacts, and Info sections, passed through as plain data from
tatami_masterplan.py. Export is fully opt-in via GOOGLE_SHEETS_CREDENTIALS_FILE
and GOOGLE_SHEETS_SPREADSHEET_ID; without them, behavior is unchanged.
This commit is contained in:
2026-06-19 15:17:37 +02:00
parent 21eb9539d4
commit 91dc7729f9
10 changed files with 1013 additions and 8 deletions
+245
View File
@@ -0,0 +1,245 @@
"""Unit tests for the optional Google Sheets export (no real network/creds)."""
import gspread
from tatami.classes import Group
from tatami.tatami_masterplan import assign_courses, get_after_party_group
from tatami.sheets_export import (
OVERVIEW_TITLE,
_group_titles,
_unique_titles,
export_masterplan_to_sheet,
)
from conftest import make_participants
COURSE_TIMES = {
"starter": "18:30",
"main": "20:00",
"dessert": "22:00",
"after_party": "23:30",
}
class FakeWorksheet:
def __init__(self, title: str):
self.title = title
self.rows: list[list[str]] | None = None
def clear(self) -> None:
self.rows = None
def update(self, rows: list[list[str]]) -> None:
self.rows = rows
class FakeSpreadsheet:
def __init__(self):
self._worksheets: dict[str, FakeWorksheet] = {}
def worksheet(self, title: str) -> FakeWorksheet:
if title not in self._worksheets:
raise gspread.WorksheetNotFound(title)
return self._worksheets[title]
def add_worksheet(
self, title: str, rows: int = 200, cols: int = 10
) -> FakeWorksheet:
worksheet = FakeWorksheet(title)
self._worksheets[title] = worksheet
return worksheet
def worksheets(self) -> list[FakeWorksheet]:
return list(self._worksheets.values())
def del_worksheet(self, worksheet: FakeWorksheet) -> None:
del self._worksheets[worksheet.title]
def make_groups(n: int) -> list[Group]:
"""n one-member groups with starter/main/dessert cycling and hosts wired up."""
groups = [Group(members=[p]) for p in make_participants(n)]
courses = ["starter", "main", "dessert"] * (n // 3)
assign_courses(groups, courses)
return groups
class TestUniqueTitles:
def test_dedups_repeated_names(self):
assert _unique_titles(["Alice", "Bob", "Alice"]) == [
"Alice",
"Bob",
"Alice (2)",
]
def test_leaves_distinct_names_untouched(self):
assert _unique_titles(["Alice", "Bob"]) == ["Alice", "Bob"]
class TestGroupTitles:
def test_joins_all_member_names(self):
participants = make_participants(2)
group = Group(members=participants)
assert _group_titles([group]) == [
f"{participants[0].name} & {participants[1].name}"
]
def test_is_not_a_single_members_name(self):
participants = make_participants(3)
group = Group(members=participants)
title = _group_titles([group])[0]
assert all(member.name != title for member in participants)
class TestExportMasterplanToSheet:
def test_writes_overview_with_one_row_per_participant(self):
groups = make_groups(3)
after_party = get_after_party_group("party street")
spreadsheet = FakeSpreadsheet()
export_masterplan_to_sheet(spreadsheet, groups, after_party, COURSE_TIMES)
overview = spreadsheet.worksheet(OVERVIEW_TITLE)
assert overview.rows is not None
rows = overview.rows
assert rows[0] == ["Group", "Course", "Name", "Phone", "Address", "Allergies"]
member_rows = rows[1:4]
assert len(member_rows) == 3
names = {row[2] for row in member_rows}
assert names == {g.main_member.name for g in groups}
def test_writes_meal_times_table_below_the_group_assignments(self):
groups = make_groups(3)
after_party = get_after_party_group("party street")
spreadsheet = FakeSpreadsheet()
export_masterplan_to_sheet(spreadsheet, groups, after_party, COURSE_TIMES)
rows = spreadsheet.worksheet(OVERVIEW_TITLE).rows
assert rows is not None
times_idx = rows.index(["Meal Times"])
assert rows[times_idx + 1] == ["Course", "Time"]
assert rows[times_idx + 2 :] == [
[course, time] for course, time in COURSE_TIMES.items()
]
def test_writes_support_contacts_and_info_text_when_given(self):
groups = make_groups(3)
after_party = get_after_party_group("party street")
spreadsheet = FakeSpreadsheet()
contacts = [
("Lars (Organizer)", "0151-1234567"),
("Anna (Backup)", "0151-7654321"),
]
info_text = "Be on time.\nBring a small gift for your hosts."
export_masterplan_to_sheet(
spreadsheet,
groups,
after_party,
COURSE_TIMES,
organizer_contacts=contacts,
info_text=info_text,
)
rows = spreadsheet.worksheet(OVERVIEW_TITLE).rows
assert rows is not None
contacts_idx = rows.index(["Support Contacts"])
assert rows[contacts_idx + 1] == ["Name", "Contact"]
assert rows[contacts_idx + 2 : contacts_idx + 4] == [list(c) for c in contacts]
info_idx = rows.index(["Info"])
assert rows[info_idx + 1 :] == [
["Be on time."],
["Bring a small gift for your hosts."],
]
def test_omits_support_contacts_and_info_sections_when_not_given(self):
groups = make_groups(3)
after_party = get_after_party_group("party street")
spreadsheet = FakeSpreadsheet()
export_masterplan_to_sheet(spreadsheet, groups, after_party, COURSE_TIMES)
rows = spreadsheet.worksheet(OVERVIEW_TITLE).rows
assert rows is not None
assert ["Support Contacts"] not in rows
assert ["Info"] not in rows
def test_writes_one_tab_per_group_with_route_and_guests(self):
groups = make_groups(3)
after_party = get_after_party_group("party street")
spreadsheet = FakeSpreadsheet()
export_masterplan_to_sheet(spreadsheet, groups, after_party, COURSE_TIMES)
starter_group = next(g for g in groups if g.course == "starter")
tab = spreadsheet.worksheet(starter_group.main_member.name)
assert tab.rows is not None
route_header_idx = tab.rows.index(["Route", "Host", "Address", "Time"])
route_rows = tab.rows[route_header_idx + 1 : route_header_idx + 1 + 4]
# 3 hosts (starter, main, dessert) plus the after party.
labels = [row[0] for row in route_rows]
assert labels == ["starter", "main", "dessert", "After Party"]
times = [row[3] for row in route_rows]
assert times == ["18:30", "20:00", "22:00", "23:30"]
guest_header_idx = tab.rows.index(["Guests for your course", "Allergies"])
guest_rows = tab.rows[guest_header_idx + 1 :]
expected_guests = {p.name for p in starter_group.get_guests(groups)}
assert {row[0] for row in guest_rows} == expected_guests
def test_group_tab_is_titled_after_all_members_not_just_the_host(self):
participants = make_participants(6)
groups = [Group(members=[participants[0], participants[1]])]
groups[0].add_member(participants[2])
groups += [Group(members=[participants[3]]), Group(members=[participants[4]])]
groups[1].add_member(participants[5])
assign_courses(groups, ["starter", "main", "dessert"])
after_party = get_after_party_group("party street")
spreadsheet = FakeSpreadsheet()
export_masterplan_to_sheet(spreadsheet, groups, after_party, COURSE_TIMES)
host_group = groups[0]
expected_title = " & ".join(m.name for m in host_group.members)
assert expected_title != host_group.main_member.name
tab = spreadsheet.worksheet(expected_title)
assert tab.rows is not None
assert tab.rows[0] == [expected_title]
def test_deletes_stale_tabs_from_a_previous_run(self):
groups = make_groups(3)
after_party = get_after_party_group("party street")
spreadsheet = FakeSpreadsheet()
spreadsheet.add_worksheet("Leftover Tab")
export_masterplan_to_sheet(spreadsheet, groups, after_party, COURSE_TIMES)
assert "Leftover Tab" not in {w.title for w in spreadsheet.worksheets()}
def test_rerun_is_idempotent(self):
groups = make_groups(3)
after_party = get_after_party_group("party street")
spreadsheet = FakeSpreadsheet()
export_masterplan_to_sheet(spreadsheet, groups, after_party, COURSE_TIMES)
export_masterplan_to_sheet(spreadsheet, groups, after_party, COURSE_TIMES)
expected_titles = {OVERVIEW_TITLE, *(g.main_member.name for g in groups)}
assert {w.title for w in spreadsheet.worksheets()} == expected_titles
def test_dedups_tab_names_for_same_named_hosts(self):
participants = make_participants(3)
participants[1].name = participants[0].name
groups = [Group(members=[p]) for p in participants]
assign_courses(groups, ["starter", "main", "dessert"])
after_party = get_after_party_group("party street")
spreadsheet = FakeSpreadsheet()
export_masterplan_to_sheet(spreadsheet, groups, after_party, COURSE_TIMES)
titles = {w.title for w in spreadsheet.worksheets()}
assert participants[0].name in titles
assert f"{participants[0].name} (2)" in titles