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():