feat/team-divider-placement #7

Open
renkar wants to merge 2 commits from feat/team-divider-placement into master
8 changed files with 306 additions and 13 deletions

View File

@@ -14,6 +14,12 @@ SHEET_ID=your-google-sheet-id-here
# the economy/community bot. Leave unset to disable team-role sync entirely.
TEAM_SHEET_ID=
# Team-section "divider" role IDs. A newly-created team role is positioned
# directly under the divider for its game (CS2 / LoL). Season-specific - update
# yearly. 0 = leave that game's new roles at the bottom of the role list.
CS2_DIVIDER_ROLE_ID=
LOL_DIVIDER_ROLE_ID=
# Path to Google service account credentials JSON
GOOGLE_CREDS_PATH=credentials.json

View File

@@ -16,7 +16,7 @@ from discord import app_commands
from core import sheets
from core.admin import bot_admin_check
from core.member_sync import sync_all_team_roles
from core.member_sync import reposition_team_roles, sync_all_team_roles
import strings as S
@@ -27,8 +27,9 @@ def register_economy_team_commands(
) -> None:
@tree.command(name="teamsync", description=S.CMD["teamsync"])
@app_commands.guild_only()
@app_commands.describe(reposition=S.OPT["teamsync_reposition"])
@bot_admin_check()
async def cmd_teamsync(interaction: discord.Interaction):
async def cmd_teamsync(interaction: discord.Interaction, reposition: bool = False):
await interaction.response.defer(ephemeral=True)
guild = interaction.guild
@@ -48,14 +49,22 @@ def register_economy_team_commands(
return
summary = await sync_all_team_roles(guild, log)
await interaction.followup.send(_format_summary(summary), ephemeral=True)
message = _format_summary(summary)
repo = None
if reposition:
repo = await reposition_team_roles(guild, log)
message += "\n\n" + _format_reposition(repo)
await interaction.followup.send(message, ephemeral=True)
log.info(
"/teamsync - scanned=%d, assigned=%d, removed=%d, created=%d, errors=%d",
"/teamsync - scanned=%d, assigned=%d, removed=%d, created=%d, errors=%d%s",
summary.scanned,
summary.assigned,
summary.removed,
len(summary.created),
len(summary.errors),
f", repositioned={repo.moved}" if repo else "",
)
@@ -85,3 +94,17 @@ def _format_summary(summary) -> str:
text += "\n\n" + S.TEAMSYNC_UI["no_changes"]
return text
def _format_reposition(repo) -> str:
lines = [S.TEAMSYNC_UI["reposition_header"]]
if not repo.moved:
lines.append(S.TEAMSYNC_UI["reposition_none"])
else:
lines.append(S.TEAMSYNC_UI["repositioned"].format(count=repo.moved))
lines.extend(repo.moves[:20])
if len(repo.moves) > 20:
lines.append(S.TEAMSYNC_UI["changes_more"].format(count=len(repo.moves) - 20))
if repo.errors:
lines.append(S.TEAMSYNC_UI["errors"].format(count=len(repo.errors)))
return "\n".join(lines)

View File

@@ -45,6 +45,19 @@ BIRTHDAY_CHANNEL_ID = (
BIRTHDAY_WINDOW_DAYS = int(os.getenv("BIRTHDAY_WINDOW_DAYS", "7"))
BASE_ROLE_IDS: list[int] = [1478304631930228779, 1478302278862766190]
# Team-section "divider" roles in the community guild. When a new team role is
# auto-created it is positioned directly under the divider for its game, so the
# role list stays grouped by game. Season-specific - update yearly (or override
# via CS2_DIVIDER_ROLE_ID / LOL_DIVIDER_ROLE_ID). 0 disables placement for that
# game (the role is still created, just left at the bottom of the list).
CS2_DIVIDER_ROLE_ID = _env_int("CS2_DIVIDER_ROLE_ID", 1498736834656604251)
LOL_DIVIDER_ROLE_ID = _env_int("LOL_DIVIDER_ROLE_ID", 1498736949706490017)
# Keyed by the canonical game code produced by core.sheets._detect_game.
TEAM_DIVIDER_ROLE_IDS: dict[str, int] = {
"CS2": CS2_DIVIDER_ROLE_ID,
"LoL": LOL_DIVIDER_ROLE_ID,
}
def _parse_admin_roles(raw: str) -> dict[int, set[int]]:
"""Parse DISCORD_ADMIN_ROLES env var as "guild_id:role_id[:role_id...],guild_id:role_id...".

View File

@@ -83,6 +83,15 @@ class TeamSyncSummary:
errors: list[str] = field(default_factory=list)
@dataclass
class RepositionSummary:
"""Aggregate outcome of the opt-in existing-team-role reposition pass."""
scanned: int = 0 # existing team roles considered
moved: int = 0 # roles relocated under their divider
moves: list[str] = field(default_factory=list) # "role -> game" lines
errors: list[str] = field(default_factory=list)
def _format_nickname(full_name: str) -> str:
"""Format a nickname from a full name: first name + last name initial.
@@ -247,6 +256,35 @@ async def sync_member(
return result
async def _position_team_role_under_divider(
guild: discord.Guild,
role: discord.Role,
team_name: str,
result: TeamSyncResult,
) -> None:
"""Move a freshly-created team role directly under its game's divider role.
The team's game comes from the registration sheet; the divider role IDs come
from config (``TEAM_DIVIDER_ROLE_IDS``). Best-effort: if the game/divider is
unknown or the move is refused, the role just stays where it was created.
"""
game = sheets.get_game_for_team(team_name)
divider_id = config.TEAM_DIVIDER_ROLE_IDS.get(game) if game else None
if not divider_id:
return
divider = guild.get_role(divider_id)
if divider is None:
log.warning("Divider role %s for game %s not found in guild", divider_id, game)
return
try:
await role.edit(position=divider.position, reason="Team sync: paiguta tiimide alla")
log.info("Positioned team role %r under the %s divider", team_name, game)
except discord.Forbidden:
result.errors.append(f"Tiimirolli '{team_name}' paigutamiseks puudub õigus")
except discord.HTTPException as e:
result.errors.append(f"Tiimirolli '{team_name}' paigutamine ebaõnnestus: {e}")
async def sync_team_role(
member: discord.Member,
guild: discord.Guild,
@@ -284,6 +322,8 @@ async def sync_team_role(
result.errors.append(f"Tiimirolli '{team_name}' loomiseks puudub õigus")
except discord.HTTPException as e:
result.errors.append(f"Tiimirolli '{team_name}' loomine ebaõnnestus: {e}")
if result.created and desired is not None:
await _position_team_role_under_divider(guild, desired, team_name, result)
# Team roles held but no longer registered for (switched teams / dropped out).
to_remove = [r for r in member.roles if r.name in all_teams and r.name != team_name]
@@ -342,6 +382,69 @@ async def sync_all_team_roles(
return summary
def _resolve_dividers(guild: discord.Guild) -> dict[str, discord.Role]:
"""Map each configured game code to its divider role present in the guild."""
dividers: dict[str, discord.Role] = {}
for game, rid in config.TEAM_DIVIDER_ROLE_IDS.items():
if not rid:
continue
role = guild.get_role(rid)
if role is not None:
dividers[game] = role
return dividers
async def reposition_team_roles(
guild: discord.Guild,
log: logging.Logger = log,
) -> RepositionSummary:
"""Move EXISTING team roles under their game's divider (opt-in cleanup).
Unlike creation-time placement this also relocates team roles that were made
before divider placement existed and are sitting at the bottom of the list.
Sheet-driven: a role's game (and thus its divider) comes from
``sheets.get_game_for_team``; roles whose team is no longer in the sheet, or
whose game/divider is unknown, are left untouched.
Idempotent: a role already within its section band (below its own divider and
above the next divider down) is skipped, so re-running moves nothing.
"""
summary = RepositionSummary()
teams = sheets.all_team_names()
dividers = _resolve_dividers(guild)
if not teams or not dividers:
return summary
for role in list(guild.roles):
if role.name not in teams:
continue
summary.scanned += 1
game = sheets.get_game_for_team(role.name)
divider = dividers.get(game) if game else None
if divider is None:
continue # unknown game / divider not in guild -> leave in place
# Section band = (highest divider below this one, this divider). A role
# already inside it is grouped correctly; only relocate outliers.
lower = max(
(d.position for d in dividers.values() if d.position < divider.position),
default=0,
)
if lower < role.position < divider.position:
continue
try:
await role.edit(position=divider.position, reason="Team sync: reposition")
summary.moved += 1
summary.moves.append(f"{role.name} -> {game}")
log.info("Repositioned team role %r under the %s divider", role.name, game)
except discord.Forbidden:
summary.errors.append(f"Tiimirolli '{role.name}' paigutamiseks puudub õigus")
except discord.HTTPException as e:
summary.errors.append(f"Tiimirolli '{role.name}' paigutamine ebaõnnestus: {e}")
return summary
async def announce_birthday(
member: discord.Member,
bot: discord.Client,

View File

@@ -275,10 +275,20 @@ _CITIZENSHIP_RE = re.compile(r"\(\s*[A-Za-z]{2,4}\s*\)")
_TEAM_NAME_HEADER = "team name"
_LINEUP_HEADER_PREFIX = "lineup"
# Canonical game code -> substrings that identify that game's section title.
# The sheet stacks a CS2 section and a LoL section, each introduced by a title
# row like "TipiLAN 2026 CS2 Registration Log"; teams are tagged with the game
# of the section they sit under so their Discord role can be placed accordingly.
_GAME_KEYWORDS: list[tuple[str, tuple[str, ...]]] = [
("CS2", ("cs2", "counter-strike", "counter strike", "csgo", "cs:go")),
("LoL", ("lol", "league of legends", "league")),
]
# 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
_team_game: dict[str, str] = {} # team name -> game code ("CS2"/"LoL")
def parse_lineup(cell: str) -> list[str]:
@@ -321,20 +331,34 @@ 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.
def _detect_game(row: list) -> str | None:
"""Classify a row as a CS2 / LoL section title, or None if it's neither."""
text = " ".join(str(c) for c in row).lower()
for game, keys in _GAME_KEYWORDS:
if any(k in text for k in keys):
return game
return None
def _scan_sections(rows: list[list]):
"""Yield ``(team, players, game)`` for every team row across the sheet.
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.
Handles multiple stacked sections with differing layouts in one tab. The
most recent game section title seen (via :func:`_detect_game`) tags every
team in the block that follows.
"""
rosters: dict[str, list[str]] = {}
current_game: str | None = None
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:
game = _detect_game(rows[i]) # title / meta row - may name the game
if game:
current_game = game
i += 1
continue
i += 1 # move past the header into the data block
@@ -343,12 +367,28 @@ def parse_team_rosters(rows: list[list]) -> dict[str, list[str]]:
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)
yield team, players, current_game
i += 1
def parse_team_rosters(rows: list[list]) -> dict[str, list[str]]:
"""Extract {team_name: [nickname, ...]} from a tab's raw rows."""
rosters: dict[str, list[str]] = {}
for team, players, _game in _scan_sections(rows):
if players:
rosters.setdefault(team, []).extend(players)
return rosters
def parse_team_games(rows: list[list]) -> dict[str, str]:
"""Extract {team_name: game_code} from a tab's raw rows (first game wins)."""
games: dict[str, str] = {}
for team, _players, game in _scan_sections(rows):
if game and team not in games:
games[team] = game
return games
def build_username_index(rosters: dict[str, list[str]]) -> dict[str, str]:
"""Invert {team: [usernames]} into {normalized username: team}.
@@ -371,19 +411,24 @@ def build_username_index(rosters: dict[str, list[str]]) -> dict[str, str]:
def _refresh_teams_sync() -> dict[str, list[str]]:
global _team_roster, _team_by_username, _team_names
global _team_roster, _team_by_username, _team_names, _team_game
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]] = {}
games: dict[str, str] = {}
for ws in spreadsheet.worksheets():
for team, players in parse_team_rosters(ws.get_all_values()).items():
vals = ws.get_all_values()
for team, players in parse_team_rosters(vals).items():
rosters.setdefault(team, []).extend(players)
for team, game in parse_team_games(vals).items():
games.setdefault(team, game)
_team_roster = rosters
_team_by_username = build_username_index(rosters)
_team_names = set(rosters)
_team_game = games
return rosters
@@ -408,6 +453,11 @@ def all_team_names() -> set[str]:
return set(_team_names)
def get_game_for_team(team: str) -> str | None:
"""Return the game code ('CS2'/'LoL') a team is registered under, or None."""
return _team_game.get(team)
def get_team_rosters() -> dict[str, list[str]]:
"""Current {team: [usernames]} cache (mainly for diagnostics/tests)."""
return _team_roster

View File

@@ -82,6 +82,7 @@ CMD: dict[str, str] = {
# ---------------------------------------------------------------------------
OPT: dict[str, str] = {
"teamsync_reposition": "Paiguta ka olemasolevad tiimirollid nende mängu jaotise alla",
"admin_kasutaja": "Kasutaja",
"admin_põhjus": "Põhjus (saadetakse kasutajale DM kaudu)",
"admincoins_kogus": "Positiivne = anna, negatiivne = võta",

View File

@@ -113,4 +113,7 @@ TEAMSYNC_UI: dict[str, str] = {
"no_changes": "✨ Kõik tiimirollid olid juba korras.",
"changes_header": "**Muudatused:**",
"changes_more": "... ja {count} rohkem",
"reposition_header": "**Ümberpaigutus:**",
"repositioned": "📦 Ümber paigutatud rolle: {count}",
"reposition_none": "✨ Olemasolevad tiimirollid olid juba õiges sektsioonis.",
}

View File

@@ -13,6 +13,7 @@ from pathlib import Path
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
@@ -87,6 +88,15 @@ def test_parse_team_rosters_ignores_non_table_content():
assert sheets.parse_team_rosters([["just", "some", "prose"], ["more"]]) == {}
def test_parse_team_games_tags_each_team_with_its_section():
# Teams inherit the game of the stacked section they sit under.
assert sheets.parse_team_games(SHEET_ROWS) == {
"Piirivalvurid": "CS2",
"GENESIS": "CS2",
"Pushing 30s": "LoL",
}
def test_build_username_index_is_case_insensitive():
index = sheets.build_username_index({"GENESIS": ["Kapa", "neaQ"]})
assert index == {"kapa": "GENESIS", "neaq": "GENESIS"}
@@ -95,9 +105,14 @@ def test_build_username_index_is_case_insensitive():
# --- sync_team_role behaviour (roster-independent) -------------------------
class FakeRole:
def __init__(self, rid: int, name: str):
def __init__(self, rid: int, name: str, position: int = 0):
self.id = rid
self.name = name
self.position = position
async def edit(self, position=None, reason=None):
if position is not None:
self.position = position
def __eq__(self, other):
return isinstance(other, FakeRole) and other.id == self.id
@@ -128,6 +143,9 @@ class FakeGuild:
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)
@@ -222,3 +240,79 @@ def test_sync_all_team_roles_aggregates_and_skips_bots(monkeypatch):
assert summary.assigned == 1
assert summary.removed == 0
assert summary.changes == ["kapa: +GENESIS"]
def test_new_team_role_is_positioned_under_its_game_divider(monkeypatch):
divider = FakeRole(500, "===== COUNTER-STRIKE 2 2026 =====", position=10)
member = FakeMember(1, "tft", roles=[])
guild = FakeGuild([divider], members=[member])
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "NEWTEAM")
monkeypatch.setattr(sheets, "all_team_names", lambda: {"NEWTEAM"})
monkeypatch.setattr(sheets, "get_game_for_team", lambda t: "CS2")
monkeypatch.setattr(config, "TEAM_DIVIDER_ROLE_IDS", {"CS2": 500})
result = run(member_sync.sync_team_role(member, guild))
assert result.created == "NEWTEAM"
new_role = next(r for r in guild.roles if r.name == "NEWTEAM")
assert new_role.position == 10 # slotted at the divider
def test_new_team_role_without_known_divider_is_left_in_place(monkeypatch):
member = FakeMember(1, "tft", roles=[])
guild = FakeGuild([], members=[member])
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "NEWTEAM")
monkeypatch.setattr(sheets, "all_team_names", lambda: {"NEWTEAM"})
monkeypatch.setattr(sheets, "get_game_for_team", lambda t: None) # game unknown
monkeypatch.setattr(config, "TEAM_DIVIDER_ROLE_IDS", {"CS2": 500})
result = run(member_sync.sync_team_role(member, guild))
assert result.created == "NEWTEAM" # still created, just not moved
new_role = next(r for r in guild.roles if r.name == "NEWTEAM")
assert new_role.position == 0 # default, untouched
# --- reposition_team_roles (opt-in cleanup of existing roles) ---------------
def test_reposition_moves_out_of_section_roles_and_skips_placed_ones(monkeypatch):
cs2_div = FakeRole(100, "CS2 divider", position=20)
lol_div = FakeRole(200, "LoL divider", position=10)
genesis = FakeRole(1, "GENESIS", position=2) # CS2 team stuck at bottom
pushing = FakeRole(2, "Pushing 30s", position=5) # LoL team already in band
unrelated = FakeRole(3, "Moderator", position=30) # not a team - ignored
guild = FakeGuild([cs2_div, lol_div, genesis, pushing, unrelated])
games = {"GENESIS": "CS2", "Pushing 30s": "LoL"}
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS", "Pushing 30s"})
monkeypatch.setattr(sheets, "get_game_for_team", lambda t: games.get(t))
monkeypatch.setattr(config, "TEAM_DIVIDER_ROLE_IDS", {"CS2": 100, "LoL": 200})
repo = run(member_sync.reposition_team_roles(guild))
assert repo.scanned == 2 # only the two team roles
assert repo.moved == 1 # only GENESIS was out of place
assert repo.moves == ["GENESIS -> CS2"]
assert genesis.position == 20 # moved under the CS2 divider
assert pushing.position == 5 # already in its band, untouched
assert unrelated.position == 30 # non-team role never considered
def test_reposition_leaves_role_whose_team_is_not_in_sheet(monkeypatch):
cs2_div = FakeRole(100, "CS2 divider", position=20)
lol_div = FakeRole(200, "LoL divider", position=10)
ghost = FakeRole(1, "GhostTeam", position=2) # a team role, but gone from sheet
guild = FakeGuild([cs2_div, lol_div, ghost])
# Team no longer registered -> not in all_team_names, so never scanned/moved.
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"})
monkeypatch.setattr(sheets, "get_game_for_team", lambda t: None)
monkeypatch.setattr(config, "TEAM_DIVIDER_ROLE_IDS", {"CS2": 100, "LoL": 200})
repo = run(member_sync.reposition_team_roles(guild))
assert repo.scanned == 0
assert repo.moved == 0
assert ghost.position == 2 # left exactly where it was