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

6
bot.py
View File

@@ -420,6 +420,12 @@ async def on_ready():
log.info("Loaded %d member rows from Google Sheets", len(data)) log.info("Loaded %d member rows from Google Sheets", len(data))
except Exception as e: except Exception as e:
log.error("Failed to load sheet on startup: %s", e) log.error("Failed to load sheet on startup: %s", e)
try:
rosters = await sheets.refresh_teams()
if rosters:
log.info("Loaded %d teams from the registration sheet", len(rosters))
except Exception as e:
log.error("Failed to load team sheet on startup: %s", e)
# Sync slash commands to the guild only; wipe any leftover global registrations # Sync slash commands to the guild only; wipe any leftover global registrations
tree.copy_global_to(guild=GUILD_OBJ) tree.copy_global_to(guild=GUILD_OBJ)

View File

@@ -182,6 +182,10 @@ def register_dev_member_commands(
except Exception as e: except Exception as e:
await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True) await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True)
return return
try:
await sheets.refresh_teams()
except Exception as e:
log.warning("/check: team sheet refresh failed, using stale team cache: %s", e)
ids_filled = 0 ids_filled = 0
for row in data: for row in data:
@@ -233,6 +237,8 @@ def register_dev_member_commands(
parts.append(S.CHECK_UI["detail_nickname"]) parts.append(S.CHECK_UI["detail_nickname"])
if result.roles_added: if result.roles_added:
parts.append(S.CHECK_UI["detail_roles_added"].format(roles=", ".join(result.roles_added))) parts.append(S.CHECK_UI["detail_roles_added"].format(roles=", ".join(result.roles_added)))
if result.roles_removed:
parts.append(S.CHECK_UI["detail_roles_removed"].format(roles=", ".join(result.roles_removed)))
details.append(S.CHECK_UI["detail_changed"].format(name=member.display_name, parts=", ".join(parts))) details.append(S.CHECK_UI["detail_changed"].format(name=member.display_name, parts=", ".join(parts)))
else: else:
already_ok += 1 already_ok += 1

View File

@@ -23,6 +23,9 @@ DISCORD_TOKEN = (
) or _LEGACY_DISCORD_TOKEN ) or _LEGACY_DISCORD_TOKEN
SHEET_ID = os.getenv("SHEET_ID") SHEET_ID = os.getenv("SHEET_ID")
# Separate spreadsheet holding the tournament team registrations (Team Name +
# lineup of Discord usernames). Optional: when unset, team-role sync is a no-op.
TEAM_SHEET_ID = os.getenv("TEAM_SHEET_ID")
GOOGLE_CREDS_PATH = os.getenv("GOOGLE_CREDS_PATH", "credentials.json") GOOGLE_CREDS_PATH = os.getenv("GOOGLE_CREDS_PATH", "credentials.json")
_LEGACY_GUILD_ID = _env_int("GUILD_ID", 0) _LEGACY_GUILD_ID = _env_int("GUILD_ID", 0)

View File

@@ -199,9 +199,35 @@ async def sync_member(
else: else:
result.errors.append(f"Baasrolli ID {rid} ei leitud serverist") result.errors.append(f"Baasrolli ID {rid} ei leitud serverist")
# --- Team role (from the separate registration spreadsheet) --------------
# Matched by Discord username. One team per person: switching teams removes
# the previous team's role; a team with no Discord role yet is auto-created.
# Only role NAMES that appear in the team sheet are ever touched here, so
# organisation/field/base roles are never at risk. When TEAM_SHEET_ID is
# unset the caches are empty and this whole block is a no-op.
team_name = sheets.get_team_for_username(member.name)
all_teams = sheets.all_team_names()
if team_name:
team_role = discord.utils.get(guild.roles, name=team_name)
if team_role is None:
try:
team_role = await guild.create_role(name=team_name, reason="Team sync: uus tiim")
log.info("Created team role %r for %s", team_name, member)
except discord.Forbidden:
result.errors.append(f"Tiimirolli '{team_name}' loomiseks puudub õigus")
team_role = None
except discord.HTTPException as e:
result.errors.append(f"Tiimirolli '{team_name}' loomine ebaõnnestus: {e}")
team_role = None
if team_role is not None:
desired_roles.append(team_role)
# Team roles the member has but is no longer registered for (left/switched).
to_remove = [r for r in member.roles if r.name in all_teams and r.name != team_name]
# Roles to add (desired but member doesn't have) # Roles to add (desired but member doesn't have)
to_add = [r for r in desired_roles if r not in member.roles] to_add = [r for r in desired_roles if r not in member.roles]
# (we currently only ADD the desired roles, not remove extras - safe default) # (outside of team roles we only ADD, never remove extras - safe default)
if to_add: if to_add:
try: try:
@@ -212,6 +238,15 @@ async def sync_member(
except discord.HTTPException as e: except discord.HTTPException as e:
result.errors.append(f"Rolli viga kasutajale {member}: {e}") result.errors.append(f"Rolli viga kasutajale {member}: {e}")
if to_remove:
try:
await member.remove_roles(*to_remove, reason="Team sync: tiim vahetus")
result.roles_removed = [r.name for r in to_remove]
except discord.Forbidden:
log.debug("No permission to remove roles for %s (likely admin), skipping", member)
except discord.HTTPException as e:
result.errors.append(f"Rolli eemaldamise viga kasutajale {member}: {e}")
# --- Birthday check --- # --- Birthday check ---
birthday_str = str(row.get("Sünnipäev", "")).strip() birthday_str = str(row.get("Sünnipäev", "")).strip()
if not _is_placeholder(birthday_str): if not _is_placeholder(birthday_str):

View File

@@ -8,6 +8,7 @@ Pure-cache helpers (get_cache, find_*) remain sync.
import asyncio import asyncio
import logging import logging
import re
import gspread import gspread
from google.oauth2.service_account import Credentials 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: 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).""" """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) 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

View File

@@ -91,6 +91,7 @@ CHECK_UI: dict[str, str] = {
"detail_error": "⚠️ {error}", "detail_error": "⚠️ {error}",
"detail_nickname": "hüüdnimi", "detail_nickname": "hüüdnimi",
"detail_roles_added": "+rollid: {roles}", "detail_roles_added": "+rollid: {roles}",
"detail_roles_removed": "-rollid: {roles}",
"detail_changed": "🔧 **{name}**: {parts}", "detail_changed": "🔧 **{name}**: {parts}",
"ids_filled": "\n🔑 Täideti **{count}** puuduvat kasutaja ID-d.", "ids_filled": "\n🔑 Täideti **{count}** puuduvat kasutaja ID-d.",
} }

225
tests/test_team_sync.py Normal file
View File

@@ -0,0 +1,225 @@
"""Tests for team-role sync from the tournament registration sheet.
Covers the risky parsing (turning a messy, multi-section, merged-cell sheet
into {team: [discord usernames]}) and the add/remove/auto-create behaviour of
sync_member, using lightweight fakes for discord + the sheets cache.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
import config # noqa: E402
from core import member_sync, sheets # noqa: E402
from tests.conftest import run # noqa: E402
# --- parse_lineup ----------------------------------------------------------
def test_parse_lineup_strips_citizenship_and_splits():
cell = "TFT (EST), nqmm (EST), sn1rk (EST), Kevka (EST), Kalatexx (EST)"
assert sheets.parse_lineup(cell) == ["TFT", "nqmm", "sn1rk", "Kevka", "Kalatexx"]
def test_parse_lineup_keeps_names_containing_commas_and_semicolons():
# A single player's descriptive name contains a ';' - must stay one name.
cell = "Onu Klaus ; vahepeal ka mõni teine tegelane (EST), Lurban (EST)"
assert sheets.parse_lineup(cell) == [
"Onu Klaus ; vahepeal ka mõni teine tegelane",
"Lurban",
]
def test_parse_lineup_handles_odd_usernames():
cell = "-acc +vac (EST), m (EST), M1X3RRRRRR (EST)"
assert sheets.parse_lineup(cell) == ["-acc +vac", "m", "M1X3RRRRRR"]
def test_parse_lineup_mixed_citizenship():
cell = "imp (LAT), milteg (EST), Freesies (EST)"
assert sheets.parse_lineup(cell) == ["imp", "milteg", "Freesies"]
def test_parse_lineup_empty():
assert sheets.parse_lineup("") == []
assert sheets.parse_lineup(" ") == []
def test_parse_lineup_fallback_without_citizenship():
assert sheets.parse_lineup("alice, bob") == ["alice", "bob"]
# --- parse_team_rosters (multi-section sheet) ------------------------------
# Mirrors the real sheet: merged title rows, a header row, team rows, a blank
# separator, then a SECOND section with a different column count.
SHEET_ROWS = [
["", "", "", "", "", "", ""],
["name", "members", "vrs ranking", "registration_date", "game", "", ""],
["[merged] TipiLAN 2026 CS2 Registration Log"] + [""] * 6,
["No", "Team Name", "Lineup (nickname, citizenship)", "VRS Ranking",
"Registration Timestamp", "Status", "Participation confirmed?"],
["1", "Piirivalvurid", "TFT (EST), nqmm (EST)", "N/A", "01.05.2026 15:07", "Confirmed", "Yes"],
["2", "GENESIS", "kapa (EST), neaQ (EST)", "N/A", "01.05.2026 15:24", "Confirmed", "Yes"],
["", "", "", "", "", "", ""],
["[merged] TipiLAN 2026 LoL Registration Log"] + [""] * 4,
["No", "Team Name", "Lineup (nickname, citizenship)",
"Registration Timestamp (dd.mm.yyyy hh:mm)", "Confirmation Status"],
["1", "Pushing 30s", "Onu Klaus (EST), Lurban (EST)", "01.05.2026 21:40", ""],
["", "", "", "", ""],
]
def test_parse_team_rosters_multiple_sections():
rosters = sheets.parse_team_rosters(SHEET_ROWS)
assert rosters == {
"Piirivalvurid": ["TFT", "nqmm"],
"GENESIS": ["kapa", "neaQ"],
"Pushing 30s": ["Onu Klaus", "Lurban"],
}
def test_parse_team_rosters_ignores_non_table_content():
# No header row anywhere -> nothing extracted, no crash.
assert sheets.parse_team_rosters([["just", "some", "prose"], ["more"]]) == {}
def test_build_username_index_is_case_insensitive():
index = sheets.build_username_index({"GENESIS": ["Kapa", "neaQ"]})
assert index == {"kapa": "GENESIS", "neaq": "GENESIS"}
# --- sync_member team behaviour -------------------------------------------
class FakeRole:
def __init__(self, rid: int, name: str):
self.id = rid
self.name = name
def __eq__(self, other):
return isinstance(other, FakeRole) and other.id == self.id
def __hash__(self):
return hash(self.id)
class FakeMember:
def __init__(self, uid: int, name: str, roles):
self.id = uid
self.name = name
self.nick = None
self.roles = list(roles)
async def edit(self, nick=None):
self.nick = nick
async def add_roles(self, *roles, reason=None):
self.roles.extend(roles)
async def remove_roles(self, *roles, reason=None):
self.roles = [r for r in self.roles if r not in roles]
class FakeGuild:
def __init__(self, roles):
self.roles = list(roles)
self._next = 9000
self.created: list[str] = []
def get_role(self, rid):
return next((r for r in self.roles if r.id == rid), None)
async def create_role(self, name, reason=None):
self._next += 1
role = FakeRole(self._next, name)
self.roles.append(role)
self.created.append(name)
return role
@pytest.fixture
def team_env(monkeypatch):
"""Base roles present in the guild; no organisation/field/birthday noise."""
base_roles = [FakeRole(rid, f"base-{rid}") for rid in config.BASE_ROLE_IDS]
def make_row(name):
# User ID + Discord match the member so no sheet writes fire; everything
# else is a placeholder so only the team role logic is exercised.
return {
"User ID": "1", "Discord": name, "Nimi": "-",
"Organisatsioon": "-", "Valdkond": "-", "Roll": "-", "Sünnipäev": "-",
}
monkeypatch.setattr(sheets, "find_member", lambda uid, name: make_row(name))
return base_roles
def test_sync_creates_missing_team_role_and_removes_old_one(team_env, monkeypatch):
old_team = FakeRole(1, "OldTeam")
keeper = FakeRole(2, "Member") # not a team role - must be left alone
member = FakeMember(1, "tft", roles=[old_team, keeper])
guild = FakeGuild(team_env + [old_team, keeper])
monkeypatch.setattr(sheets, "get_team_for_username",
lambda n: "GENESIS" if n.lower() == "tft" else None)
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS", "OldTeam"})
result = run(member_sync.sync_member(member, guild))
assert "GENESIS" in guild.created # auto-created the missing role
assert "GENESIS" in result.roles_added
assert result.roles_removed == ["OldTeam"] # left their previous team
role_names = {r.name for r in member.roles}
assert "GENESIS" in role_names
assert "OldTeam" not in role_names
assert "Member" in role_names # unrelated role untouched
def test_sync_uses_existing_team_role(team_env, monkeypatch):
genesis = FakeRole(3, "GENESIS")
member = FakeMember(1, "kapa", roles=[])
guild = FakeGuild(team_env + [genesis])
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "GENESIS")
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"})
result = run(member_sync.sync_member(member, guild))
assert guild.created == [] # did NOT create a duplicate
assert "GENESIS" in result.roles_added
assert genesis in member.roles
def test_sync_strips_team_role_when_not_registered(team_env, monkeypatch):
old_team = FakeRole(1, "OldTeam")
member = FakeMember(1, "ghost", roles=[old_team])
guild = FakeGuild(team_env + [old_team])
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: None)
monkeypatch.setattr(sheets, "all_team_names", lambda: {"OldTeam"})
result = run(member_sync.sync_member(member, guild))
assert result.roles_removed == ["OldTeam"]
assert old_team not in member.roles
def test_sync_no_team_sheet_is_noop(team_env, monkeypatch):
keeper = FakeRole(2, "Member")
member = FakeMember(1, "someone", roles=[keeper])
guild = FakeGuild(team_env + [keeper])
# Empty caches = feature switched off.
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: None)
monkeypatch.setattr(sheets, "all_team_names", lambda: set())
result = run(member_sync.sync_member(member, guild))
assert result.roles_removed == []
assert guild.created == []
assert keeper in member.roles