feat(members): assign team roles from the tournament registration sheet

Match members by Discord username against the lineup nicknames in the
separate registration spreadsheet (TEAM_SHEET_ID) and give each their
team's role. One team per person: switching teams removes the old team
role, and a team with no Discord role yet is auto-created. Only role
names present in the sheet are ever touched, so organisation/field/base
roles are never at risk; the feature is a no-op when TEAM_SHEET_ID is
unset.

The sheet is not a single table (merged rows, stacked CS2/LoL sections
with different layouts), so it is parsed via raw-row scanning rather than
get_all_records. Citizenship markers like "(EST)" are used only as
player delimiters and discarded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rene Arumetsa
2026-08-28 14:01:22 +03:00
parent 567a82b9f2
commit 52002c37fc
7 changed files with 438 additions and 1 deletions

View File

@@ -8,6 +8,7 @@ Pure-cache helpers (get_cache, find_*) remain sync.
import asyncio
import logging
import re
import gspread
from google.oauth2.service_account import Credentials
@@ -250,3 +251,163 @@ def _add_new_member_row_sync(username: str, discord_id: int) -> None:
async def add_new_member_row(username: str, discord_id: int) -> None:
"""Append a new row pre-filled with Discord username and User ID (non-blocking)."""
await asyncio.to_thread(_add_new_member_row_sync, username, discord_id)
# ===========================================================================
# Team registration sheet (a SEPARATE spreadsheet, config.TEAM_SHEET_ID)
# ---------------------------------------------------------------------------
# Unlike the member roster this sheet is NOT a single clean table: it stacks
# several game sections (CS2, LoL, ...) - each with merged title/description
# rows, its own header row, and a block of team rows - across one or more tabs.
# So we read raw cell values (get_all_values) and scan for header rows rather
# than relying on get_all_records, which requires one rectangular table.
#
# Each team's players live in one "Lineup" cell, comma-joined, every nickname
# suffixed with a citizenship marker like "(EST)". Those nicknames ARE the
# players' Discord usernames; the citizenship is used only as a delimiter
# (a nickname may itself contain commas) and then discarded.
# ===========================================================================
# Matches a citizenship marker such as "(EST)" / "(LAT)". Used to split a
# lineup cell into individual players, then thrown away.
_CITIZENSHIP_RE = re.compile(r"\(\s*[A-Za-z]{2,4}\s*\)")
_TEAM_NAME_HEADER = "team name"
_LINEUP_HEADER_PREFIX = "lineup"
# Team-sheet caches (mirrors the member-roster cache above)
_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
def parse_lineup(cell: str) -> list[str]:
"""Split one 'Lineup' cell into player nicknames (Discord usernames).
Players are delimited by their trailing citizenship marker, e.g.
'TFT (EST), nqmm (EST), sn1rk (EST)' -> ['TFT', 'nqmm', 'sn1rk']
Splitting on the marker (not on commas) keeps nicknames that themselves
contain commas or semicolons intact. Falls back to comma-splitting when a
cell carries no citizenship markers at all.
"""
cell = str(cell).strip()
if not cell:
return []
names: list[str] = []
last = 0
matched = False
for m in _CITIZENSHIP_RE.finditer(cell):
matched = True
chunk = cell[last:m.start()].strip().strip(",;").strip()
if chunk:
names.append(chunk)
last = m.end()
if not matched:
return [p.strip() for p in cell.split(",") if p.strip()]
tail = cell[last:].strip().strip(",;").strip() # stray name after last marker
if tail:
names.append(tail)
return names
def _find_col(row: list, matches) -> int | None:
for idx, cell in enumerate(row):
if matches(str(cell)):
return idx
return None
def _cell(row: list, idx: int) -> str:
return str(row[idx]) if 0 <= idx < len(row) else ""
def parse_team_rosters(rows: list[list]) -> dict[str, list[str]]:
"""Extract {team_name: [nickname, ...]} from a tab's raw rows.
Scans for every header row that has both a 'Team Name' and a 'Lineup...'
column, then reads the rows beneath it (using that section's own column
positions) until the team-name column goes blank or a new header appears.
Handles multiple stacked sections with differing layouts in one tab.
"""
rosters: dict[str, list[str]] = {}
i, n = 0, len(rows)
while i < n:
name_col = _find_col(rows[i], lambda c: c.strip().lower() == _TEAM_NAME_HEADER)
lineup_col = _find_col(rows[i], lambda c: c.strip().lower().startswith(_LINEUP_HEADER_PREFIX))
if name_col is None or lineup_col is None:
i += 1
continue
i += 1 # move past the header into the data block
while i < n:
team = _cell(rows[i], name_col).strip()
if not team or team.lower() == _TEAM_NAME_HEADER:
break # blank team-name (or a new header) ends this section
players = parse_lineup(_cell(rows[i], lineup_col))
if players:
rosters.setdefault(team, []).extend(players)
i += 1
return rosters
def build_username_index(rosters: dict[str, list[str]]) -> dict[str, str]:
"""Invert {team: [usernames]} into {normalized username: team}.
If the same username appears on two teams the last one wins and a warning
is logged (a person is expected to be on exactly one team).
"""
index: dict[str, str] = {}
for team, players in rosters.items():
for player in players:
key = player.strip().lower()
if not key:
continue
if key in index and index[key] != team:
log.warning(
"Player %r appears on both %r and %r; using %r",
player, index[key], team, team,
)
index[key] = team
return index
def _refresh_teams_sync() -> dict[str, list[str]]:
global _team_roster, _team_by_username, _team_names
creds = Credentials.from_service_account_file(config.GOOGLE_CREDS_PATH, scopes=SCOPES)
client = gspread.authorize(creds)
spreadsheet = client.open_by_key(config.TEAM_SHEET_ID)
rosters: dict[str, list[str]] = {}
for ws in spreadsheet.worksheets():
for team, players in parse_team_rosters(ws.get_all_values()).items():
rosters.setdefault(team, []).extend(players)
_team_roster = rosters
_team_by_username = build_username_index(rosters)
_team_names = set(rosters)
return rosters
async def refresh_teams() -> dict[str, list[str]]:
"""Reload the team registration sheet into the in-memory team caches.
No-op returning {} when TEAM_SHEET_ID is not configured, so the whole
feature can be left switched off without touching sync behaviour.
"""
if not config.TEAM_SHEET_ID:
return {}
return await asyncio.to_thread(_refresh_teams_sync)
def get_team_for_username(username: str) -> str | None:
"""Return the team a Discord username is registered on, or None."""
return _team_by_username.get(str(username).strip().lower())
def all_team_names() -> set[str]:
"""Every team name known from the registration sheet (the role universe)."""
return set(_team_names)
def get_team_rosters() -> dict[str, list[str]]:
"""Current {team: [usernames]} cache (mainly for diagnostics/tests)."""
return _team_roster