2 Commits

Author SHA1 Message Date
Rene Arumetsa
c19e67b5ab fix(teams): resolve dividers from tab name + full title, not last notice row
Section titles are stacked single-cell rows: the "TipiLAN 2026 CS2" title
sits above notice rows ("If a team withdraws..."), and parse_team_sections
kept only the LAST one, so the notice clobbered the title and resolve_divider
saw no game/year keywords -> None -> teams never positioned.

- parse_team_sections now accumulates all single-cell rows above a header, so
  the game/year title survives alongside the notices.
- _refresh_teams_sync resolves the divider from "<tab name> <section title>",
  so the game is taken reliably from the CS2/LoL worksheet name while the year
  still comes from the title, keeping year-scoped TEAM_DIVIDER_*_2026 vars.
- regression test: title survives notice rows and still resolves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPRsW4tazVtYi2jNzVQkre
2026-09-03 23:32:58 +03:00
Rene Arumetsa
d48a436e26 feat(teams): resolve team dividers by role ID instead of name
Divider placement matched the divider role by its exact Discord name, so
renaming the role in Discord silently broke positioning. Switch the
TEAM_DIVIDER_<SUFFIX> config to hold a role ID; resolve the ID to the
role's current name in apply_team_role_positions and keep the existing
name-based ordering maths downstream unchanged.

- config._parse_team_dividers now parses values as ints (rejects non-ints)
- resolve_divider / _team_divider cache / get_team_dividers return IDs
- apply_team_role_positions resolves each ID via guild.get_role once
- .env.example documents IDs and ships the CS2/LoL divider role IDs
- resolve_divider tests updated to assert IDs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPRsW4tazVtYi2jNzVQkre
2026-09-03 22:34:34 +03:00
5 changed files with 89 additions and 42 deletions

View File

@@ -18,10 +18,11 @@ TEAM_SHEET_ID=
# is matched against the section's title row in the sheet ("TipiLAN 2026 CS2 # is matched against the section's title row in the sheet ("TipiLAN 2026 CS2
# Registration Log") - every underscore-separated part must appear in it, so # Registration Log") - every underscore-separated part must appear in it, so
# CS2_2026 matches only the 2026 CS2 block while a plain CS2 would match any # CS2_2026 matches only the 2026 CS2 block while a plain CS2 would match any
# year. The value is the EXACT Discord role name to place those teams under. # year. The value is the Discord role ID to place those teams under (matching by
# ID means renaming the divider role never breaks positioning).
# Optional: sections that match nothing still get their roles, just unpositioned. # Optional: sections that match nothing still get their roles, just unpositioned.
TEAM_DIVIDER_CS2_2026= TEAM_DIVIDER_CS2_2026=1498736834656604251
TEAM_DIVIDER_LOL_2026= TEAM_DIVIDER_LOL_2026=1498736949706490017
# Path to Google service account credentials JSON # Path to Google service account credentials JSON
GOOGLE_CREDS_PATH=credentials.json GOOGLE_CREDS_PATH=credentials.json

View File

@@ -72,32 +72,39 @@ BOT_ADMIN_ROLES: dict[int, set[int]] = _parse_admin_roles(os.getenv("DISCORD_ADM
_TEAM_DIVIDER_PREFIX = "TEAM_DIVIDER_" _TEAM_DIVIDER_PREFIX = "TEAM_DIVIDER_"
def _parse_team_dividers() -> dict[str, str]: def _parse_team_dividers() -> dict[str, int]:
"""Collect TEAM_DIVIDER_<SUFFIX> env vars into {suffix: divider role name}. """Collect TEAM_DIVIDER_<SUFFIX> env vars into {suffix: divider role ID}.
The suffix says which sheet sections the divider covers, the value is the The suffix says which sheet sections the divider covers, the value is the
exact Discord role name their teams get positioned under: Discord role ID their teams get positioned under:
TEAM_DIVIDER_CS2_2026="====== COUNTER-STRIKE 2 2026 ======" TEAM_DIVIDER_CS2_2026=1498736834656604251
Every underscore-separated part of the suffix must appear in the section's Matching by ID (not name) means renaming the divider role in Discord never
title row, so `CS2_2026` matches only "TipiLAN 2026 CS2 Registration Log" breaks positioning. Every underscore-separated part of the suffix must appear
while a plain `CS2` would match that section in any year. Defining a var is in the section's title row, so `CS2_2026` matches only "TipiLAN 2026 CS2
what switches positioning on for those sections; teams whose section matches Registration Log" while a plain `CS2` would match that section in any year.
nothing are still granted their role, just never moved. Defining a var is what switches positioning on for those sections; teams
whose section matches nothing are still granted their role, just never moved.
""" """
dividers: dict[str, str] = {} dividers: dict[str, int] = {}
for key, value in os.environ.items(): for key, value in os.environ.items():
if not key.startswith(_TEAM_DIVIDER_PREFIX): if not key.startswith(_TEAM_DIVIDER_PREFIX):
continue continue
suffix = key[len(_TEAM_DIVIDER_PREFIX):].strip().lower() suffix = key[len(_TEAM_DIVIDER_PREFIX):].strip().lower()
name = value.strip() raw = value.strip()
if suffix and name: if not suffix or not raw:
dividers[suffix] = name continue
try:
dividers[suffix] = int(raw)
except ValueError:
raise SystemExit(
f"{key}: expected a Discord role ID (integer), got {raw!r}"
)
return dividers return dividers
TEAM_DIVIDERS: dict[str, str] = _parse_team_dividers() TEAM_DIVIDERS: dict[str, int] = _parse_team_dividers()
PB_URL = os.getenv("PB_URL", "http://127.0.0.1:8090") PB_URL = os.getenv("PB_URL", "http://127.0.0.1:8090")
PB_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "") PB_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "")

View File

@@ -363,16 +363,32 @@ async def apply_team_role_positions(
"""Move every team role directly beneath its configured divider role. """Move every team role directly beneath its configured divider role.
Driven by the ``TEAM_DIVIDER_*`` config: teams whose sheet section matched Driven by the ``TEAM_DIVIDER_*`` config: teams whose sheet section matched
one are placed under that role, the rest are left exactly where they are. one are placed under that divider role, the rest are left exactly where they
Returns ``(roles_moved, errors)``; a no-op returns ``(0, [])``. are. Dividers are configured by role ID, so a rename never breaks placement;
the ID is resolved to the role's current name here, and the ordering maths
downstream is name-based. Returns ``(roles_moved, errors)``; a no-op returns
``(0, [])``.
""" """
placements: dict[str, list[str]] = {} team_dividers = sheets.get_team_dividers() # {team: divider role ID}
for team, divider in sheets.get_team_dividers().items(): if not team_dividers:
placements.setdefault(divider, []).append(team)
if not placements:
return 0, [] # no dividers configured, or nothing matched return 0, [] # no dividers configured, or nothing matched
errors: list[str] = [] errors: list[str] = []
# Resolve each configured divider ID to its role once, then key placements by
# that role's current name for the name-based positioning maths below.
placements: dict[str, list[str]] = {}
resolved: dict[int, discord.Role | None] = {}
for team, divider_id in team_dividers.items():
if divider_id not in resolved:
resolved[divider_id] = guild.get_role(divider_id)
if resolved[divider_id] is None:
errors.append(f"Eraldajarolli ID {divider_id} ei leitud serverist")
divider = resolved[divider_id]
if divider is not None:
placements.setdefault(divider.name, []).append(team)
if not placements:
return 0, errors
by_name: dict[str, discord.Role] = {} by_name: dict[str, discord.Role] = {}
for role in sorted(guild.roles, key=lambda r: r.position): for role in sorted(guild.roles, key=lambda r: r.position):
by_name.setdefault(role.name, role) by_name.setdefault(role.name, role)

View File

@@ -280,7 +280,7 @@ _LINEUP_HEADER_PREFIX = "lineup"
_team_roster: dict[str, list[str]] = {} # team name -> [Discord username, ...] _team_roster: dict[str, list[str]] = {} # team name -> [Discord username, ...]
_team_by_username: dict[str, str] = {} # normalized username -> team name _team_by_username: dict[str, str] = {} # normalized username -> team name
_team_names: set[str] = set() # universe of all team names _team_names: set[str] = set() # universe of all team names
_team_divider: dict[str, str] = {} # team name -> divider role name _team_divider: dict[str, int] = {} # team name -> divider role ID
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -362,7 +362,11 @@ def parse_team_sections(rows: list[list]) -> list[TeamSection]:
lineup_col = _find_col(rows[i], lambda c: c.strip().lower().startswith(_LINEUP_HEADER_PREFIX)) lineup_col = _find_col(rows[i], lambda c: c.strip().lower().startswith(_LINEUP_HEADER_PREFIX))
if name_col is None or lineup_col is None: if name_col is None or lineup_col is None:
if (text := _merged_title(rows[i])) is not None: if (text := _merged_title(rows[i])) is not None:
title = text # Accumulate every single-cell row above the header, not just the
# last one: sheets stack the "TipiLAN 2026 CS2" title above notice
# rows ("If a team withdraws..."), and the later notices must not
# clobber the title whose keywords resolve_divider needs.
title = f"{title} {text}".strip() if title else text
i += 1 i += 1
continue continue
rosters: dict[str, list[str]] = {} rosters: dict[str, list[str]] = {}
@@ -390,8 +394,8 @@ def parse_team_rosters(rows: list[list]) -> dict[str, list[str]]:
return rosters return rosters
def resolve_divider(title: str, dividers: dict[str, str] | None = None) -> str | None: def resolve_divider(title: str, dividers: dict[str, int] | None = None) -> int | None:
"""Return the divider role name configured for a section title, if any. """Return the divider role ID configured for a section title, if any.
A ``TEAM_DIVIDER_<SUFFIX>`` entry matches when every underscore-separated A ``TEAM_DIVIDER_<SUFFIX>`` entry matches when every underscore-separated
part of its suffix appears as a whole word in the title, so ``CS2`` matches part of its suffix appears as a whole word in the title, so ``CS2`` matches
@@ -402,15 +406,15 @@ def resolve_divider(title: str, dividers: dict[str, str] | None = None) -> str |
if dividers is None: if dividers is None:
dividers = config.TEAM_DIVIDERS dividers = config.TEAM_DIVIDERS
haystack = title.lower() haystack = title.lower()
best_name: str | None = None best_id: int | None = None
best_parts = 0 best_parts = 0
for suffix, role_name in dividers.items(): for suffix, role_id in dividers.items():
parts = [p for p in suffix.split("_") if p] parts = [p for p in suffix.split("_") if p]
if not parts or len(parts) <= best_parts: if not parts or len(parts) <= best_parts:
continue continue
if all(re.search(rf"\b{re.escape(p)}\b", haystack) for p in parts): if all(re.search(rf"\b{re.escape(p)}\b", haystack) for p in parts):
best_name, best_parts = role_name, len(parts) best_id, best_parts = role_id, len(parts)
return best_name return best_id
def build_username_index(rosters: dict[str, list[str]]) -> dict[str, str]: def build_username_index(rosters: dict[str, list[str]]) -> dict[str, str]:
@@ -441,10 +445,13 @@ def _refresh_teams_sync() -> dict[str, list[str]]:
spreadsheet = client.open_by_key(config.TEAM_SHEET_ID) spreadsheet = client.open_by_key(config.TEAM_SHEET_ID)
rosters: dict[str, list[str]] = {} rosters: dict[str, list[str]] = {}
dividers: dict[str, str] = {} dividers: dict[str, int] = {}
for ws in spreadsheet.worksheets(): for ws in spreadsheet.worksheets():
for section in parse_team_sections(ws.get_all_values()): for section in parse_team_sections(ws.get_all_values()):
divider = resolve_divider(section.title) # Match on the tab name too ("CS2"/"LoL"): it names the game reliably
# even when the game/year title row is shadowed by notice rows, while
# the section title still supplies the year for year-scoped dividers.
divider = resolve_divider(f"{ws.title} {section.title}")
for team, players in section.rosters.items(): for team, players in section.rosters.items():
rosters.setdefault(team, []).extend(players) rosters.setdefault(team, []).extend(players)
if divider: if divider:
@@ -483,8 +490,8 @@ def get_team_rosters() -> dict[str, list[str]]:
return _team_roster return _team_roster
def get_team_dividers() -> dict[str, str]: def get_team_dividers() -> dict[str, int]:
"""Current {team: divider role name} cache. """Current {team: divider role ID} cache.
Only teams whose section title matched a configured TEAM_DIVIDER_* entry Only teams whose section title matched a configured TEAM_DIVIDER_* entry
appear here, so an empty dict means positioning is switched off. appear here, so an empty dict means positioning is switched off.

View File

@@ -113,18 +113,18 @@ def test_parse_team_sections_title_does_not_leak_to_untitled_block():
# --- resolve_divider (section title -> configured divider role) ------------ # --- resolve_divider (section title -> configured divider role) ------------
DIVIDERS = { DIVIDERS = {
"cs2_2026": "====== COUNTER-STRIKE 2 2026 ======", "cs2_2026": 1498736834656604251,
"lol_2026": "===== LEAGUE OF LEGENDS 2026 =====", "lol_2026": 1498736949706490017,
} }
def test_resolve_divider_matches_game_and_year(): def test_resolve_divider_matches_game_and_year():
assert sheets.resolve_divider( assert sheets.resolve_divider(
"[merged] TipiLAN 2026 CS2 Registration Log", DIVIDERS "[merged] TipiLAN 2026 CS2 Registration Log", DIVIDERS
) == "====== COUNTER-STRIKE 2 2026 ======" ) == 1498736834656604251
assert sheets.resolve_divider( assert sheets.resolve_divider(
"[merged] TipiLAN 2026 LoL Registration Log", DIVIDERS "[merged] TipiLAN 2026 LoL Registration Log", DIVIDERS
) == "===== LEAGUE OF LEGENDS 2026 =====" ) == 1498736949706490017
def test_resolve_divider_year_scoped_key_ignores_other_years(): def test_resolve_divider_year_scoped_key_ignores_other_years():
@@ -132,9 +132,9 @@ def test_resolve_divider_year_scoped_key_ignores_other_years():
def test_resolve_divider_prefers_the_most_specific_match(): def test_resolve_divider_prefers_the_most_specific_match():
dividers = {"cs2": "== CS2 ALL YEARS ==", "cs2_2026": "== CS2 2026 =="} dividers = {"cs2": 111, "cs2_2026": 222}
assert sheets.resolve_divider("TipiLAN 2026 CS2 Log", dividers) == "== CS2 2026 ==" assert sheets.resolve_divider("TipiLAN 2026 CS2 Log", dividers) == 222
assert sheets.resolve_divider("TipiLAN 2025 CS2 Log", dividers) == "== CS2 ALL YEARS ==" assert sheets.resolve_divider("TipiLAN 2025 CS2 Log", dividers) == 111
def test_resolve_divider_no_config_or_no_title_is_none(): def test_resolve_divider_no_config_or_no_title_is_none():
@@ -142,6 +142,22 @@ def test_resolve_divider_no_config_or_no_title_is_none():
assert sheets.resolve_divider("", DIVIDERS) is None assert sheets.resolve_divider("", DIVIDERS) is None
def test_title_survives_notice_rows_below_it():
# Real sheets stack the game/year title above single-cell notice rows; the
# title's keywords must survive so resolve_divider still matches (regression:
# notice rows used to overwrite the title, yielding a None divider).
rows = [
["TipiLAN 2026 CS2 Registration Log"] + [""] * 4,
["This log is updated automatically."] + [""] * 4,
["If a team from the Top 32 withdraws, ..."] + [""] * 4,
["No", "Team Name", "Lineup (nickname, citizenship)", "", ""],
["1", "GENESIS", "kapa (EST)", "", ""],
]
section = sheets.parse_team_sections(rows)[0]
assert "CS2" in section.title and "2026" in section.title
assert sheets.resolve_divider(section.title, DIVIDERS) == 1498736834656604251
# --- plan_team_positions (pure role-ordering maths) ------------------------ # --- plan_team_positions (pure role-ordering maths) ------------------------
def test_plan_moves_teams_directly_under_their_divider(): def test_plan_moves_teams_directly_under_their_divider():