forked from sass/tipibot
Compare commits
2 Commits
fb08bc56d7
...
feat/team-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd3fb7d3b8 | ||
|
|
e179a35fc4 |
@@ -14,6 +14,12 @@ SHEET_ID=your-google-sheet-id-here
|
|||||||
# the economy/community bot. Leave unset to disable team-role sync entirely.
|
# the economy/community bot. Leave unset to disable team-role sync entirely.
|
||||||
TEAM_SHEET_ID=
|
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
|
# Path to Google service account credentials JSON
|
||||||
GOOGLE_CREDS_PATH=credentials.json
|
GOOGLE_CREDS_PATH=credentials.json
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from discord import app_commands
|
|||||||
|
|
||||||
from core import sheets
|
from core import sheets
|
||||||
from core.admin import bot_admin_check
|
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
|
import strings as S
|
||||||
|
|
||||||
|
|
||||||
@@ -27,8 +27,9 @@ def register_economy_team_commands(
|
|||||||
) -> None:
|
) -> None:
|
||||||
@tree.command(name="teamsync", description=S.CMD["teamsync"])
|
@tree.command(name="teamsync", description=S.CMD["teamsync"])
|
||||||
@app_commands.guild_only()
|
@app_commands.guild_only()
|
||||||
|
@app_commands.describe(reposition=S.OPT["teamsync_reposition"])
|
||||||
@bot_admin_check()
|
@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)
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
|
||||||
guild = interaction.guild
|
guild = interaction.guild
|
||||||
@@ -48,14 +49,22 @@ def register_economy_team_commands(
|
|||||||
return
|
return
|
||||||
|
|
||||||
summary = await sync_all_team_roles(guild, log)
|
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(
|
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.scanned,
|
||||||
summary.assigned,
|
summary.assigned,
|
||||||
summary.removed,
|
summary.removed,
|
||||||
len(summary.created),
|
len(summary.created),
|
||||||
len(summary.errors),
|
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"]
|
text += "\n\n" + S.TEAMSYNC_UI["no_changes"]
|
||||||
|
|
||||||
return text
|
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)
|
||||||
|
|||||||
13
config.py
13
config.py
@@ -45,6 +45,19 @@ BIRTHDAY_CHANNEL_ID = (
|
|||||||
BIRTHDAY_WINDOW_DAYS = int(os.getenv("BIRTHDAY_WINDOW_DAYS", "7"))
|
BIRTHDAY_WINDOW_DAYS = int(os.getenv("BIRTHDAY_WINDOW_DAYS", "7"))
|
||||||
BASE_ROLE_IDS: list[int] = [1478304631930228779, 1478302278862766190]
|
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]]:
|
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...".
|
"""Parse DISCORD_ADMIN_ROLES env var as "guild_id:role_id[:role_id...],guild_id:role_id...".
|
||||||
|
|||||||
@@ -83,6 +83,15 @@ class TeamSyncSummary:
|
|||||||
errors: list[str] = field(default_factory=list)
|
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:
|
def _format_nickname(full_name: str) -> str:
|
||||||
"""Format a nickname from a full name: first name + last name initial.
|
"""Format a nickname from a full name: first name + last name initial.
|
||||||
|
|
||||||
@@ -247,6 +256,35 @@ async def sync_member(
|
|||||||
return result
|
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(
|
async def sync_team_role(
|
||||||
member: discord.Member,
|
member: discord.Member,
|
||||||
guild: discord.Guild,
|
guild: discord.Guild,
|
||||||
@@ -284,6 +322,8 @@ async def sync_team_role(
|
|||||||
result.errors.append(f"Tiimirolli '{team_name}' loomiseks puudub õigus")
|
result.errors.append(f"Tiimirolli '{team_name}' loomiseks puudub õigus")
|
||||||
except discord.HTTPException as e:
|
except discord.HTTPException as e:
|
||||||
result.errors.append(f"Tiimirolli '{team_name}' loomine ebaõnnestus: {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).
|
# 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]
|
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
|
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(
|
async def announce_birthday(
|
||||||
member: discord.Member,
|
member: discord.Member,
|
||||||
bot: discord.Client,
|
bot: discord.Client,
|
||||||
|
|||||||
@@ -275,10 +275,20 @@ _CITIZENSHIP_RE = re.compile(r"\(\s*[A-Za-z]{2,4}\s*\)")
|
|||||||
_TEAM_NAME_HEADER = "team name"
|
_TEAM_NAME_HEADER = "team name"
|
||||||
_LINEUP_HEADER_PREFIX = "lineup"
|
_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-sheet caches (mirrors the member-roster cache above)
|
||||||
_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_game: dict[str, str] = {} # team name -> game code ("CS2"/"LoL")
|
||||||
|
|
||||||
|
|
||||||
def parse_lineup(cell: str) -> list[str]:
|
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 ""
|
return str(row[idx]) if 0 <= idx < len(row) else ""
|
||||||
|
|
||||||
|
|
||||||
def parse_team_rosters(rows: list[list]) -> dict[str, list[str]]:
|
def _detect_game(row: list) -> str | None:
|
||||||
"""Extract {team_name: [nickname, ...]} from a tab's raw rows.
|
"""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...'
|
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
|
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.
|
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)
|
i, n = 0, len(rows)
|
||||||
while i < n:
|
while i < n:
|
||||||
name_col = _find_col(rows[i], lambda c: c.strip().lower() == _TEAM_NAME_HEADER)
|
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))
|
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:
|
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
|
i += 1
|
||||||
continue
|
continue
|
||||||
i += 1 # move past the header into the data block
|
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:
|
if not team or team.lower() == _TEAM_NAME_HEADER:
|
||||||
break # blank team-name (or a new header) ends this section
|
break # blank team-name (or a new header) ends this section
|
||||||
players = parse_lineup(_cell(rows[i], lineup_col))
|
players = parse_lineup(_cell(rows[i], lineup_col))
|
||||||
if players:
|
yield team, players, current_game
|
||||||
rosters.setdefault(team, []).extend(players)
|
|
||||||
i += 1
|
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
|
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]:
|
def build_username_index(rosters: dict[str, list[str]]) -> dict[str, str]:
|
||||||
"""Invert {team: [usernames]} into {normalized username: team}.
|
"""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]]:
|
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)
|
creds = Credentials.from_service_account_file(config.GOOGLE_CREDS_PATH, scopes=SCOPES)
|
||||||
client = gspread.authorize(creds)
|
client = gspread.authorize(creds)
|
||||||
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]] = {}
|
||||||
|
games: dict[str, str] = {}
|
||||||
for ws in spreadsheet.worksheets():
|
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)
|
rosters.setdefault(team, []).extend(players)
|
||||||
|
for team, game in parse_team_games(vals).items():
|
||||||
|
games.setdefault(team, game)
|
||||||
|
|
||||||
_team_roster = rosters
|
_team_roster = rosters
|
||||||
_team_by_username = build_username_index(rosters)
|
_team_by_username = build_username_index(rosters)
|
||||||
_team_names = set(rosters)
|
_team_names = set(rosters)
|
||||||
|
_team_game = games
|
||||||
return rosters
|
return rosters
|
||||||
|
|
||||||
|
|
||||||
@@ -408,6 +453,11 @@ def all_team_names() -> set[str]:
|
|||||||
return set(_team_names)
|
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]]:
|
def get_team_rosters() -> dict[str, list[str]]:
|
||||||
"""Current {team: [usernames]} cache (mainly for diagnostics/tests)."""
|
"""Current {team: [usernames]} cache (mainly for diagnostics/tests)."""
|
||||||
return _team_roster
|
return _team_roster
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ CMD: dict[str, str] = {
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
OPT: dict[str, str] = {
|
OPT: dict[str, str] = {
|
||||||
|
"teamsync_reposition": "Paiguta ka olemasolevad tiimirollid nende mängu jaotise alla",
|
||||||
"admin_kasutaja": "Kasutaja",
|
"admin_kasutaja": "Kasutaja",
|
||||||
"admin_põhjus": "Põhjus (saadetakse kasutajale DM kaudu)",
|
"admin_põhjus": "Põhjus (saadetakse kasutajale DM kaudu)",
|
||||||
"admincoins_kogus": "Positiivne = anna, negatiivne = võta",
|
"admincoins_kogus": "Positiivne = anna, negatiivne = võta",
|
||||||
|
|||||||
@@ -113,4 +113,7 @@ TEAMSYNC_UI: dict[str, str] = {
|
|||||||
"no_changes": "✨ Kõik tiimirollid olid juba korras.",
|
"no_changes": "✨ Kõik tiimirollid olid juba korras.",
|
||||||
"changes_header": "**Muudatused:**",
|
"changes_header": "**Muudatused:**",
|
||||||
"changes_more": "... ja {count} rohkem",
|
"changes_more": "... ja {count} rohkem",
|
||||||
|
"reposition_header": "**Ümberpaigutus:**",
|
||||||
|
"repositioned": "📦 Ümber paigutatud rolle: {count}",
|
||||||
|
"reposition_none": "✨ Olemasolevad tiimirollid olid juba õiges sektsioonis.",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
import config # noqa: E402
|
||||||
from core import member_sync, sheets # noqa: E402
|
from core import member_sync, sheets # noqa: E402
|
||||||
from tests.conftest import run # 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"]]) == {}
|
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():
|
def test_build_username_index_is_case_insensitive():
|
||||||
index = sheets.build_username_index({"GENESIS": ["Kapa", "neaQ"]})
|
index = sheets.build_username_index({"GENESIS": ["Kapa", "neaQ"]})
|
||||||
assert index == {"kapa": "GENESIS", "neaq": "GENESIS"}
|
assert index == {"kapa": "GENESIS", "neaq": "GENESIS"}
|
||||||
@@ -95,9 +105,14 @@ def test_build_username_index_is_case_insensitive():
|
|||||||
# --- sync_team_role behaviour (roster-independent) -------------------------
|
# --- sync_team_role behaviour (roster-independent) -------------------------
|
||||||
|
|
||||||
class FakeRole:
|
class FakeRole:
|
||||||
def __init__(self, rid: int, name: str):
|
def __init__(self, rid: int, name: str, position: int = 0):
|
||||||
self.id = rid
|
self.id = rid
|
||||||
self.name = name
|
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):
|
def __eq__(self, other):
|
||||||
return isinstance(other, FakeRole) and other.id == self.id
|
return isinstance(other, FakeRole) and other.id == self.id
|
||||||
@@ -128,6 +143,9 @@ class FakeGuild:
|
|||||||
self._next = 9000
|
self._next = 9000
|
||||||
self.created: list[str] = []
|
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):
|
async def create_role(self, name, reason=None):
|
||||||
self._next += 1
|
self._next += 1
|
||||||
role = FakeRole(self._next, name)
|
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.assigned == 1
|
||||||
assert summary.removed == 0
|
assert summary.removed == 0
|
||||||
assert summary.changes == ["kapa: +GENESIS"]
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user