feat(teams): resolve team dividers by role ID instead of name #8

Merged
renkar merged 1 commits from feat/teamsync-reposition into master 2026-09-03 19:38:24 +00:00
5 changed files with 64 additions and 40 deletions

View File

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

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_"
def _parse_team_dividers() -> dict[str, str]:
"""Collect TEAM_DIVIDER_<SUFFIX> env vars into {suffix: divider role name}.
def _parse_team_dividers() -> dict[str, int]:
"""Collect TEAM_DIVIDER_<SUFFIX> 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", "")

View File

@@ -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)

View File

@@ -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_<SUFFIX>`` 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.

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