From d48a436e26e31fc259c05d0678a071ac0d0a8b8e Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Thu, 3 Sep 2026 22:34:34 +0300 Subject: [PATCH] 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_ 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) Claude-Session: https://claude.ai/code/session_01XPRsW4tazVtYi2jNzVQkre --- .env.example | 7 ++++--- config.py | 35 +++++++++++++++++++++-------------- core/member_sync.py | 28 ++++++++++++++++++++++------ core/sheets.py | 20 ++++++++++---------- tests/test_team_sync.py | 14 +++++++------- 5 files changed, 64 insertions(+), 40 deletions(-) diff --git a/.env.example b/.env.example index 494e974..c713385 100644 --- a/.env.example +++ b/.env.example @@ -18,10 +18,11 @@ TEAM_SHEET_ID= # 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 # 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. -TEAM_DIVIDER_CS2_2026= -TEAM_DIVIDER_LOL_2026= +TEAM_DIVIDER_CS2_2026=1498736834656604251 +TEAM_DIVIDER_LOL_2026=1498736949706490017 # Path to Google service account credentials JSON GOOGLE_CREDS_PATH=credentials.json diff --git a/config.py b/config.py index 717b734..993bbd4 100644 --- a/config.py +++ b/config.py @@ -72,32 +72,39 @@ BOT_ADMIN_ROLES: dict[int, set[int]] = _parse_admin_roles(os.getenv("DISCORD_ADM _TEAM_DIVIDER_PREFIX = "TEAM_DIVIDER_" -def _parse_team_dividers() -> dict[str, str]: - """Collect TEAM_DIVIDER_ env vars into {suffix: divider role name}. +def _parse_team_dividers() -> dict[str, int]: + """Collect TEAM_DIVIDER_ env vars into {suffix: divider role ID}. 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 - title row, so `CS2_2026` matches only "TipiLAN 2026 CS2 Registration Log" - while a plain `CS2` would match that section in any year. Defining a var is - what switches positioning on for those sections; teams whose section matches - nothing are still granted their role, just never moved. + Matching by ID (not name) means renaming the divider role in Discord never + breaks positioning. Every underscore-separated part of the suffix must appear + in the section's title row, so `CS2_2026` matches only "TipiLAN 2026 CS2 + Registration Log" while a plain `CS2` would match that section in any year. + 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(): if not key.startswith(_TEAM_DIVIDER_PREFIX): continue suffix = key[len(_TEAM_DIVIDER_PREFIX):].strip().lower() - name = value.strip() - if suffix and name: - dividers[suffix] = name + raw = value.strip() + if not suffix or not raw: + continue + try: + dividers[suffix] = int(raw) + except ValueError: + raise SystemExit( + f"{key}: expected a Discord role ID (integer), got {raw!r}" + ) 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_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "") diff --git a/core/member_sync.py b/core/member_sync.py index 1bf89d4..b8c150f 100644 --- a/core/member_sync.py +++ b/core/member_sync.py @@ -363,16 +363,32 @@ async def apply_team_role_positions( """Move every team role directly beneath its configured divider role. 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. - Returns ``(roles_moved, errors)``; a no-op returns ``(0, [])``. + one are placed under that divider role, the rest are left exactly where they + 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]] = {} - for team, divider in sheets.get_team_dividers().items(): - placements.setdefault(divider, []).append(team) - if not placements: + team_dividers = sheets.get_team_dividers() # {team: divider role ID} + if not team_dividers: return 0, [] # no dividers configured, or nothing matched 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] = {} for role in sorted(guild.roles, key=lambda r: r.position): by_name.setdefault(role.name, role) diff --git a/core/sheets.py b/core/sheets.py index 105bdb3..d490895 100644 --- a/core/sheets.py +++ b/core/sheets.py @@ -280,7 +280,7 @@ _LINEUP_HEADER_PREFIX = "lineup" _team_roster: dict[str, list[str]] = {} # team name -> [Discord username, ...] _team_by_username: dict[str, str] = {} # normalized username -> team name _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) @@ -390,8 +390,8 @@ def parse_team_rosters(rows: list[list]) -> dict[str, list[str]]: return rosters -def resolve_divider(title: str, dividers: dict[str, str] | None = None) -> str | None: - """Return the divider role name configured for a section title, if any. +def resolve_divider(title: str, dividers: dict[str, int] | None = None) -> int | None: + """Return the divider role ID configured for a section title, if any. A ``TEAM_DIVIDER_`` entry matches when every underscore-separated part of its suffix appears as a whole word in the title, so ``CS2`` matches @@ -402,15 +402,15 @@ def resolve_divider(title: str, dividers: dict[str, str] | None = None) -> str | if dividers is None: dividers = config.TEAM_DIVIDERS haystack = title.lower() - best_name: str | None = None + best_id: int | None = None 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] if not parts or len(parts) <= best_parts: continue if all(re.search(rf"\b{re.escape(p)}\b", haystack) for p in parts): - best_name, best_parts = role_name, len(parts) - return best_name + best_id, best_parts = role_id, len(parts) + return best_id def build_username_index(rosters: dict[str, list[str]]) -> dict[str, str]: @@ -441,7 +441,7 @@ def _refresh_teams_sync() -> dict[str, list[str]]: spreadsheet = client.open_by_key(config.TEAM_SHEET_ID) rosters: dict[str, list[str]] = {} - dividers: dict[str, str] = {} + dividers: dict[str, int] = {} for ws in spreadsheet.worksheets(): for section in parse_team_sections(ws.get_all_values()): divider = resolve_divider(section.title) @@ -483,8 +483,8 @@ def get_team_rosters() -> dict[str, list[str]]: return _team_roster -def get_team_dividers() -> dict[str, str]: - """Current {team: divider role name} cache. +def get_team_dividers() -> dict[str, int]: + """Current {team: divider role ID} cache. Only teams whose section title matched a configured TEAM_DIVIDER_* entry appear here, so an empty dict means positioning is switched off. diff --git a/tests/test_team_sync.py b/tests/test_team_sync.py index 24fdd9e..675a7f9 100644 --- a/tests/test_team_sync.py +++ b/tests/test_team_sync.py @@ -113,18 +113,18 @@ def test_parse_team_sections_title_does_not_leak_to_untitled_block(): # --- resolve_divider (section title -> configured divider role) ------------ DIVIDERS = { - "cs2_2026": "====== COUNTER-STRIKE 2 2026 ======", - "lol_2026": "===== LEAGUE OF LEGENDS 2026 =====", + "cs2_2026": 1498736834656604251, + "lol_2026": 1498736949706490017, } def test_resolve_divider_matches_game_and_year(): assert sheets.resolve_divider( "[merged] TipiLAN 2026 CS2 Registration Log", DIVIDERS - ) == "====== COUNTER-STRIKE 2 2026 ======" + ) == 1498736834656604251 assert sheets.resolve_divider( "[merged] TipiLAN 2026 LoL Registration Log", DIVIDERS - ) == "===== LEAGUE OF LEGENDS 2026 =====" + ) == 1498736949706490017 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(): - dividers = {"cs2": "== CS2 ALL YEARS ==", "cs2_2026": "== CS2 2026 =="} - assert sheets.resolve_divider("TipiLAN 2026 CS2 Log", dividers) == "== CS2 2026 ==" - assert sheets.resolve_divider("TipiLAN 2025 CS2 Log", dividers) == "== CS2 ALL YEARS ==" + dividers = {"cs2": 111, "cs2_2026": 222} + assert sheets.resolve_divider("TipiLAN 2026 CS2 Log", dividers) == 222 + assert sheets.resolve_divider("TipiLAN 2025 CS2 Log", dividers) == 111 def test_resolve_divider_no_config_or_no_title_is_none(): -- 2.47.3