feat/participant-divider-roles #6
@@ -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
|
||||
|
||||
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_"
|
||||
|
||||
|
||||
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", "")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user