feat/team-divider-placement #4
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -373,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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
}
|
||||
|
||||
@@ -273,3 +273,46 @@ def test_new_team_role_without_known_divider_is_left_in_place(monkeypatch):
|
||||
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