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:
@@ -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
|
||||
Reference in New Issue
Block a user