Merge pull request 'feat(teams): resolve team dividers by role ID instead of name' (#8) from feat/teamsync-reposition into master
Reviewed-on: renkar/tipibot#8
This commit is contained in:
@@ -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
|
||||||
|
|||||||
35
config.py
35
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_"
|
_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", "")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -390,8 +390,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 +402,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,7 +441,7 @@ 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)
|
divider = resolve_divider(section.title)
|
||||||
@@ -483,8 +483,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.
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
Reference in New Issue
Block a user