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,216 @@
|
||||
"""Live end-to-end test against the real Google Sheets API.
|
||||
|
||||
This is intentionally excluded from the default test run (it is marked ``e2e``
|
||||
and ``addopts`` in pyproject.toml deselects that marker). It writes a small
|
||||
mock masterplan (fictional Karlsruhe participants) into the spreadsheet
|
||||
configured via ``GOOGLE_SHEETS_SPREADSHEET_ID``/``GOOGLE_SHEETS_CREDENTIALS_FILE``,
|
||||
then reads the cells back via the live API to validate the export. Note this
|
||||
overwrites/deletes tabs in that spreadsheet (the same idempotent rewrite
|
||||
``export_masterplan_to_sheet`` always does) — point it at a scratch/test sheet.
|
||||
|
||||
Run it explicitly:
|
||||
|
||||
uv run pytest -m e2e -k sheets
|
||||
|
||||
It requires a real service-account key file and a spreadsheet ID, with that
|
||||
spreadsheet shared (Editor) with the service account's ``client_email``; the
|
||||
test skips itself if either is only a placeholder/missing.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from tatami.classes import Group, Participant
|
||||
from tatami.sheets_export import (
|
||||
OVERVIEW_TITLE,
|
||||
_group_titles,
|
||||
export_masterplan_to_sheet,
|
||||
load_sheets_client,
|
||||
)
|
||||
from tatami.tatami_masterplan import assign_courses, get_after_party_group
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
_PLACEHOLDER_SPREADSHEET_IDS = {"", "your-spreadsheet-id-here"}
|
||||
_PLACEHOLDER_CREDENTIALS_FILES = {"", "service-account.json"}
|
||||
|
||||
COURSE_TIMES = {
|
||||
"starter": "18:30",
|
||||
"main": "20:00",
|
||||
"dessert": "22:00",
|
||||
"after_party": "23:30",
|
||||
}
|
||||
ORGANIZER_CONTACTS = [("Lars (Organizer)", "0151-1234567")]
|
||||
INFO_TEXT = "Be on time.\nBring a small gift for your hosts."
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def live_spreadsheet():
|
||||
# Prefer real values from the environment / .env over conftest's dummies.
|
||||
load_dotenv(override=True)
|
||||
credentials_file = os.environ.get("GOOGLE_SHEETS_CREDENTIALS_FILE", "")
|
||||
spreadsheet_id = os.environ.get("GOOGLE_SHEETS_SPREADSHEET_ID", "")
|
||||
|
||||
if (
|
||||
credentials_file in _PLACEHOLDER_CREDENTIALS_FILES
|
||||
or not os.path.isfile(os.path.expanduser(credentials_file))
|
||||
or spreadsheet_id in _PLACEHOLDER_SPREADSHEET_IDS
|
||||
):
|
||||
pytest.skip(
|
||||
"No real GOOGLE_SHEETS_CREDENTIALS_FILE/GOOGLE_SHEETS_SPREADSHEET_ID "
|
||||
"available; skipping live Sheets test."
|
||||
)
|
||||
|
||||
client = load_sheets_client(credentials_file)
|
||||
return client.open_by_key(spreadsheet_id)
|
||||
|
||||
|
||||
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"
|
||||
),
|
||||
Participant(
|
||||
"Jonas Becker",
|
||||
"Waldstraße 5, 76133 Karlsruhe",
|
||||
"0721-1000002",
|
||||
7,
|
||||
"lactose",
|
||||
),
|
||||
Participant(
|
||||
"Mira Hofmann", "Yorckstraße 22, 76185 Karlsruhe", "0721-1000003", 8, "none"
|
||||
),
|
||||
]
|
||||
semi_hosts = [
|
||||
Participant(
|
||||
"Lukas Schreiber",
|
||||
"Sophienstraße 40, 76135 Karlsruhe",
|
||||
"0721-1000004",
|
||||
5,
|
||||
"nuts",
|
||||
),
|
||||
Participant(
|
||||
"Sophie Lindner",
|
||||
"Beiertheimer Allee 18, 76137 Karlsruhe",
|
||||
"0721-1000005",
|
||||
6,
|
||||
"none",
|
||||
),
|
||||
Participant(
|
||||
"Tom Vogel",
|
||||
"Durlacher Allee 75, 76131 Karlsruhe",
|
||||
"0721-1000006",
|
||||
4,
|
||||
"none",
|
||||
),
|
||||
Participant(
|
||||
"Lea Brandt",
|
||||
"Moltkestraße 30, 76133 Karlsruhe",
|
||||
"0721-1000007",
|
||||
3,
|
||||
"gluten",
|
||||
),
|
||||
Participant(
|
||||
"Felix Krause", "Adlerstraße 14, 76133 Karlsruhe", "0721-1000008", 8, "none"
|
||||
),
|
||||
Participant(
|
||||
"Nora Fink",
|
||||
"Rüppurrer Straße 60, 76137 Karlsruhe",
|
||||
"0721-1000009",
|
||||
5,
|
||||
"none",
|
||||
),
|
||||
]
|
||||
|
||||
groups = [Group(members=[host]) for host in hosts]
|
||||
for i, member in enumerate(semi_hosts):
|
||||
groups[i % len(groups)].add_member(member)
|
||||
|
||||
assign_courses(groups, ["starter", "main", "dessert"])
|
||||
|
||||
after_party = get_after_party_group("Sebastian-Kneipp-Straße 6, 76131 Karlsruhe")
|
||||
return groups, after_party
|
||||
|
||||
|
||||
def test_export_masterplan_to_live_sheet(live_spreadsheet):
|
||||
groups, after_party = _make_mock_groups()
|
||||
|
||||
export_masterplan_to_sheet(
|
||||
live_spreadsheet,
|
||||
groups,
|
||||
after_party,
|
||||
COURSE_TIMES,
|
||||
organizer_contacts=ORGANIZER_CONTACTS,
|
||||
info_text=INFO_TEXT,
|
||||
)
|
||||
|
||||
all_members = [member for group in groups for member in group.members]
|
||||
group_titles = _group_titles(groups)
|
||||
expected_titles = {OVERVIEW_TITLE, *group_titles}
|
||||
|
||||
# Only the export's own tabs survive — stale/default tabs are cleaned up.
|
||||
assert {w.title for w in live_spreadsheet.worksheets()} == expected_titles
|
||||
|
||||
# A group's tab is named after all its members, not just the host.
|
||||
starter_title = next(
|
||||
title for group, title in zip(groups, group_titles) if group.course == "starter"
|
||||
)
|
||||
starter_group = next(g for g in groups if g.course == "starter")
|
||||
assert starter_title != starter_group.main_member.name
|
||||
|
||||
overview_rows = live_spreadsheet.worksheet(OVERVIEW_TITLE).get_all_values()
|
||||
assert overview_rows[0] == [
|
||||
"Group",
|
||||
"Course",
|
||||
"Name",
|
||||
"Phone",
|
||||
"Address",
|
||||
"Allergies",
|
||||
]
|
||||
member_rows = overview_rows[1 : 1 + len(all_members)]
|
||||
assert {row[2] for row in member_rows} == {m.name for m in all_members}
|
||||
|
||||
def _find_overview_row(prefix: list[str]) -> int:
|
||||
return next(
|
||||
i for i, row in enumerate(overview_rows) if row[: len(prefix)] == prefix
|
||||
)
|
||||
|
||||
times_idx = _find_overview_row(["Meal Times"])
|
||||
assert overview_rows[times_idx + 1][:2] == ["Course", "Time"]
|
||||
times_rows = overview_rows[times_idx + 2 : times_idx + 2 + len(COURSE_TIMES)]
|
||||
assert [row[:2] for row in times_rows] == [
|
||||
[course, time] for course, time in COURSE_TIMES.items()
|
||||
]
|
||||
|
||||
contacts_idx = _find_overview_row(["Support Contacts"])
|
||||
assert overview_rows[contacts_idx + 1][:2] == ["Name", "Contact"]
|
||||
assert overview_rows[contacts_idx + 2][:2] == list(ORGANIZER_CONTACTS[0])
|
||||
|
||||
info_idx = _find_overview_row(["Info"])
|
||||
info_lines = [row[0] for row in overview_rows[info_idx + 1 :]]
|
||||
assert info_lines == INFO_TEXT.splitlines()
|
||||
|
||||
tab_rows = live_spreadsheet.worksheet(starter_title).get_all_values()
|
||||
|
||||
# The live API pads every row to the widest row in the tab, so match on a
|
||||
# row's leading cells rather than exact-length equality.
|
||||
def _find_row(prefix: list[str]) -> int:
|
||||
return next(i for i, row in enumerate(tab_rows) if row[: len(prefix)] == prefix)
|
||||
|
||||
route_idx = _find_row(["Route", "Host", "Address", "Time"])
|
||||
route_rows = tab_rows[route_idx + 1 : route_idx + 5]
|
||||
assert [row[0] for row in route_rows] == [
|
||||
"starter",
|
||||
"main",
|
||||
"dessert",
|
||||
"After Party",
|
||||
]
|
||||
assert [row[3] for row in route_rows] == ["18:30", "20:00", "22:00", "23:30"]
|
||||
|
||||
guest_idx = _find_row(["Guests for your course", "Allergies"])
|
||||
guest_rows = [row for row in tab_rows[guest_idx + 1 :] if row and row[0]]
|
||||
expected_guests = {(p.name, p.allergies) for p in starter_group.get_guests(groups)}
|
||||
assert {(row[0], row[1]) for row in guest_rows} == expected_guests
|
||||
@@ -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