feat(teams): add Fienta as primary Discord->team source (sheet fallback)
The registration-log sheet only has in-game nicknames, so matching Discord
users to teams failed for ~10 of 40 teams. Fienta collects each competitor's
Discord username (+ sometimes user ID) and team name per ticket, giving a
reliable Discord-identity -> team mapping (validated: 201 usernames, 42 teams).
- core/fienta.py: token-auth client; fetch /events/{id}/tickets?attendees=true,
parse competitor/coach/substitute tickets into {username|id -> team} and
{team -> game}; exclude visitor/supporter/LAN/early-bird/waiting-list. No-op
when FIENTA_API_TOKEN/FIENTA_EVENT_ID unset.
- member_sync: resolve_team() tries Fienta (id, then username) then the sheet;
all_managed_team_names() and team_dividers() merge both sources.
- /teamsync + hourly task refresh Fienta alongside the sheet; enabled when
either source is configured.
- config + .env.example: FIENTA_API_TOKEN, FIENTA_EVENT_ID.
- tests: fienta parsing (game detection, inclusion rules, id/username mapping).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPRsW4tazVtYi2jNzVQkre
This commit is contained in:
@@ -11,9 +11,32 @@ from zoneinfo import ZoneInfo
|
||||
import discord
|
||||
|
||||
import config
|
||||
from . import sheets
|
||||
from . import fienta, sheets
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def resolve_team(member: discord.Member) -> str | None:
|
||||
"""Team a member is registered on: Fienta first (by ID, then username),
|
||||
then the sheet by username. Fienta is authoritative; the sheet is fallback."""
|
||||
return (
|
||||
fienta.get_team_for_userid(member.id)
|
||||
or fienta.get_team_for_username(member.name)
|
||||
or sheets.get_team_for_username(member.name)
|
||||
)
|
||||
|
||||
|
||||
def all_managed_team_names() -> set[str]:
|
||||
"""Union of every team name from Fienta and the sheet - the only role names
|
||||
team sync ever adds or removes."""
|
||||
return fienta.all_team_names() | sheets.all_team_names()
|
||||
|
||||
|
||||
def team_dividers() -> dict[str, int]:
|
||||
"""{team -> divider role id} merged from both sources; Fienta wins on overlap."""
|
||||
merged = dict(sheets.get_team_dividers())
|
||||
merged.update(fienta.get_team_dividers())
|
||||
return merged
|
||||
_PLACEHOLDER = {"-", "x", "n/a", "none", "ei"}
|
||||
_TZ = ZoneInfo("Europe/Tallinn")
|
||||
|
||||
@@ -275,10 +298,10 @@ async def sync_team_role(
|
||||
"""
|
||||
result = TeamSyncResult()
|
||||
|
||||
team_name = sheets.get_team_for_username(member.name)
|
||||
all_teams = sheets.all_team_names()
|
||||
team_name = resolve_team(member)
|
||||
all_teams = all_managed_team_names()
|
||||
if not all_teams:
|
||||
return result # feature switched off (no team sheet loaded)
|
||||
return result # feature switched off (no Fienta token and no team sheet)
|
||||
|
||||
desired: discord.Role | None = None
|
||||
if team_name:
|
||||
@@ -319,7 +342,7 @@ async def sync_team_role(
|
||||
# configured divider role (switched game / dropped out). Matched by ID, so
|
||||
# only the roles named in config.TEAM_DIVIDERS are ever touched.
|
||||
divider_ids = set(config.TEAM_DIVIDERS.values())
|
||||
want_divider_id = sheets.get_team_dividers().get(team_name) if team_name else None
|
||||
want_divider_id = team_dividers().get(team_name) if team_name else None
|
||||
want_divider = guild.get_role(want_divider_id) if want_divider_id else None
|
||||
|
||||
if want_divider is not None and want_divider not in member.roles:
|
||||
@@ -405,8 +428,8 @@ async def apply_team_role_positions(
|
||||
downstream is name-based. Returns ``(roles_moved, errors)``; a no-op returns
|
||||
``(0, [])``.
|
||||
"""
|
||||
team_dividers = sheets.get_team_dividers() # {team: divider role ID}
|
||||
if not team_dividers:
|
||||
team_divider_ids = team_dividers() # {team: divider role ID}, both sources
|
||||
if not team_divider_ids:
|
||||
return 0, [] # no dividers configured, or nothing matched
|
||||
|
||||
errors: list[str] = []
|
||||
@@ -414,7 +437,7 @@ async def apply_team_role_positions(
|
||||
# that role's current name for the name-based positioning maths below.
|
||||
placements: dict[str, list[str]] = {}
|
||||
resolved: dict[int, discord.Role | None] = {}
|
||||
for team, divider_id in team_dividers.items():
|
||||
for team, divider_id in team_divider_ids.items():
|
||||
if divider_id not in resolved:
|
||||
resolved[divider_id] = guild.get_role(divider_id)
|
||||
if resolved[divider_id] is None:
|
||||
|
||||
Reference in New Issue
Block a user