Files
Tatami/tests/test_e2e_sheets.py
T
lars 91dc7729f9 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.
2026-06-19 15:17:37 +02:00

217 lines
7.3 KiB
Python

"""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