6b0fca0100
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.
229 lines
7.8 KiB
Python
229 lines
7.8 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(
|
|
name="Anna Wagner",
|
|
address="Kaiserstraße 12, 76131 Karlsruhe",
|
|
phone="0721-1000001",
|
|
kitchen_size=9,
|
|
allergies="none",
|
|
),
|
|
Participant(
|
|
name="Jonas Becker",
|
|
address="Waldstraße 5, 76133 Karlsruhe",
|
|
phone="0721-1000002",
|
|
kitchen_size=7,
|
|
allergies="lactose",
|
|
),
|
|
Participant(
|
|
name="Mira Hofmann",
|
|
address="Yorckstraße 22, 76185 Karlsruhe",
|
|
phone="0721-1000003",
|
|
kitchen_size=8,
|
|
allergies="none",
|
|
),
|
|
]
|
|
semi_hosts = [
|
|
Participant(
|
|
name="Lukas Schreiber",
|
|
address="Sophienstraße 40, 76135 Karlsruhe",
|
|
phone="0721-1000004",
|
|
kitchen_size=5,
|
|
allergies="nuts",
|
|
),
|
|
Participant(
|
|
name="Sophie Lindner",
|
|
address="Beiertheimer Allee 18, 76137 Karlsruhe",
|
|
phone="0721-1000005",
|
|
kitchen_size=6,
|
|
allergies="none",
|
|
),
|
|
Participant(
|
|
name="Tom Vogel",
|
|
address="Durlacher Allee 75, 76131 Karlsruhe",
|
|
phone="0721-1000006",
|
|
kitchen_size=4,
|
|
allergies="none",
|
|
),
|
|
Participant(
|
|
name="Lea Brandt",
|
|
address="Moltkestraße 30, 76133 Karlsruhe",
|
|
phone="0721-1000007",
|
|
kitchen_size=3,
|
|
allergies="gluten",
|
|
),
|
|
Participant(
|
|
name="Felix Krause",
|
|
address="Adlerstraße 14, 76133 Karlsruhe",
|
|
phone="0721-1000008",
|
|
kitchen_size=8,
|
|
allergies="none",
|
|
),
|
|
Participant(
|
|
name="Nora Fink",
|
|
address="Rüppurrer Straße 60, 76137 Karlsruhe",
|
|
phone="0721-1000009",
|
|
kitchen_size=5,
|
|
allergies="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
|