Refactor Discord member statistics handling and remove unused components

- Removed the `guild_member_stats` table and related logic from the database and models, as well as the associated webapp routes and templates.
- Cleaned up the Discord bot initialization by eliminating unused imports and functions related to member statistics.
- Updated logging configuration in `run-web.py` to streamline the startup process and improve thread management.
This commit is contained in:
2026-03-25 23:13:14 +01:00
parent b51430e2b2
commit ec296dd226
12 changed files with 10 additions and 662 deletions
-26
View File
@@ -227,31 +227,6 @@ def _doAddColumnMigrations(cursor: Cursor):
except Exception as e: except Exception as e:
logging.warning(f"Table webapp_page_permission: {e}") logging.warning(f"Table webapp_page_permission: {e}")
if not _tableExists('guild_member_stats', cursor):
try:
cursor.execute("""
CREATE TABLE guild_member_stats (
guild_id VARCHAR(64) NOT NULL,
user_id VARCHAR(64) NOT NULL,
message_count INTEGER NOT NULL DEFAULT 0,
voice_seconds INTEGER NOT NULL DEFAULT 0,
updated_at DATETIME,
PRIMARY KEY (guild_id, user_id)
)
""")
logging.info("Table guild_member_stats créée")
except Exception as e:
logging.warning(f"Table guild_member_stats: {e}")
if _tableExists("webapp_page_permission", cursor):
try:
cursor.execute(
"INSERT OR IGNORE INTO webapp_page_permission (page_key, min_level, write_level) VALUES ('discord_members', 1, 1)"
)
except Exception as e:
logging.warning(f"Permission page discord_members: {e}")
def _doSeedAuth(cursor: Cursor): def _doSeedAuth(cursor: Cursor):
"""Seed rôles par défaut et permissions des pages si vides.""" """Seed rôles par défaut et permissions des pages si vides."""
from database.models import ROLE_ORDER from database.models import ROLE_ORDER
@@ -294,7 +269,6 @@ def _doSeedAuth(cursor: Cursor):
("freeloot", 1, 2), ("freeloot", 1, 2),
("patreon", 1, 2), ("patreon", 1, 2),
("moderation", 1, 2), ("moderation", 1, 2),
("discord_members", 1, 1),
("users", 5, 5), ("users", 5, 5),
("settings", 5, 5), ("settings", 5, 5),
] ]
-9
View File
@@ -260,12 +260,3 @@ class ModShoutboxMessage(db.Model):
message = db.Column(db.String(500), nullable=False) message = db.Column(db.String(500), nullable=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
class GuildMemberStats(db.Model):
__tablename__ = 'guild_member_stats'
guild_id = db.Column(db.String(64), primary_key=True)
user_id = db.Column(db.String(64), primary_key=True)
message_count = db.Column(db.Integer, nullable=False, default=0)
voice_seconds = db.Column(db.Integer, nullable=False, default=0)
updated_at = db.Column(db.DateTime, nullable=True)
-9
View File
@@ -99,15 +99,6 @@ CREATE TABLE IF NOT EXISTS `member_invites` (
`join_date` DATETIME NOT NULL `join_date` DATETIME NOT NULL
); );
CREATE TABLE IF NOT EXISTS `guild_member_stats` (
`guild_id` VARCHAR(64) NOT NULL,
`user_id` VARCHAR(64) NOT NULL,
`message_count` INTEGER NOT NULL DEFAULT 0,
`voice_seconds` INTEGER NOT NULL DEFAULT 0,
`updated_at` DATETIME,
PRIMARY KEY (`guild_id`, `user_id`)
);
CREATE TABLE IF NOT EXISTS `twitch_link_filter` ( CREATE TABLE IF NOT EXISTS `twitch_link_filter` (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
`enabled` BOOLEAN NOT NULL DEFAULT FALSE, `enabled` BOOLEAN NOT NULL DEFAULT FALSE,
+2 -8
View File
@@ -29,13 +29,11 @@ from discordbot.welcome import sendWelcomeMessage, sendLeaveMessage, updateInvit
from discordbot.patreon import checkPatreonPosts from discordbot.patreon import checkPatreonPosts
from discordbot.youtube import checkYouTubeVideos from discordbot.youtube import checkYouTubeVideos
from discordbot.auto_rooms import on_voice_state_update_auto_rooms, on_raw_reaction_add_auto_rooms, on_message_auto_rooms, cleanup_orphaned_auto_rooms from discordbot.auto_rooms import on_voice_state_update_auto_rooms, on_raw_reaction_add_auto_rooms, on_message_auto_rooms, cleanup_orphaned_auto_rooms
from discordbot.member_stats import record_message, on_voice_state_update_track_voice
from discordbot.rate_limit_logging import build_discord_http_trace_config
from protondb import searhProtonDb from protondb import searhProtonDb
class DiscordBot(discord.Client): class DiscordBot(discord.Client):
def __init__(self, *, intents: discord.Intents): def __init__(self, *, intents: discord.Intents):
super().__init__(intents=intents, http_trace=build_discord_http_trace_config()) super().__init__(intents=intents)
self.tree = app_commands.CommandTree(self) self.tree = app_commands.CommandTree(self)
self.synced = False self.synced = False
@@ -169,10 +167,7 @@ async def on_message(message: Message):
# Gestion des messages dans les auto rooms (avant le check des commandes !) # Gestion des messages dans les auto rooms (avant le check des commandes !)
await on_message_auto_rooms(bot, message) await on_message_auto_rooms(bot, message)
if message.guild and not message.author.bot:
record_message(message.guild.id, message.author.id)
if not message.content.startswith('!'): if not message.content.startswith('!'):
return return
command_name = message.content.split()[0] command_name = message.content.split()[0]
@@ -336,7 +331,6 @@ async def on_message(message: Message):
@bot.event @bot.event
async def on_voice_state_update(member: Member, before, after): async def on_voice_state_update(member: Member, before, after):
await on_voice_state_update_auto_rooms(bot, member, before, after) await on_voice_state_update_auto_rooms(bot, member, before, after)
on_voice_state_update_track_voice(member, before, after)
@bot.event @bot.event
async def on_raw_reaction_add(payload: discord.RawReactionActionEvent): async def on_raw_reaction_add(payload: discord.RawReactionActionEvent):
-183
View File
@@ -1,183 +0,0 @@
"""Compteurs messages / vocal par membre pour la webapp (table guild_member_stats)."""
import asyncio
import concurrent.futures
import logging
from datetime import datetime, timezone
import discord
from sqlalchemy import text
from webapp import webapp
from database import db
logger = logging.getLogger(__name__)
# (guild_id, user_id) -> datetime début session vocale (UTC)
_voice_join_at: dict[tuple[int, int], datetime] = {}
def _now_utc() -> datetime:
return datetime.now(timezone.utc)
def record_message(guild_id: int, user_id: int) -> None:
"""Incrémente message_count (hors bots)."""
try:
with webapp.app_context():
db.session.execute(
text("""
INSERT INTO guild_member_stats (guild_id, user_id, message_count, voice_seconds, updated_at)
VALUES (:gid, :uid, 1, 0, CURRENT_TIMESTAMP)
ON CONFLICT(guild_id, user_id) DO UPDATE SET
message_count = guild_member_stats.message_count + 1,
updated_at = CURRENT_TIMESTAMP
"""),
{"gid": str(guild_id), "uid": str(user_id)},
)
db.session.commit()
except Exception as e:
logger.warning("record_message: %s", e)
try:
db.session.rollback()
except Exception:
pass
def add_voice_seconds(guild_id: int, user_id: int, seconds: int) -> None:
if seconds <= 0:
return
try:
with webapp.app_context():
db.session.execute(
text("""
INSERT INTO guild_member_stats (guild_id, user_id, message_count, voice_seconds, updated_at)
VALUES (:gid, :uid, 0, :sec, CURRENT_TIMESTAMP)
ON CONFLICT(guild_id, user_id) DO UPDATE SET
voice_seconds = guild_member_stats.voice_seconds + :sec,
updated_at = CURRENT_TIMESTAMP
"""),
{"gid": str(guild_id), "uid": str(user_id), "sec": seconds},
)
db.session.commit()
except Exception as e:
logger.warning("add_voice_seconds: %s", e)
try:
db.session.rollback()
except Exception:
pass
def _finalize_voice_session(guild_id: int, user_id: int, end: datetime) -> None:
key = (guild_id, user_id)
started = _voice_join_at.pop(key, None)
if started is None:
return
delta = (end - started).total_seconds()
add_voice_seconds(guild_id, user_id, int(delta))
def on_voice_state_update_track_voice(member: discord.Member, before: discord.VoiceState, after: discord.VoiceState) -> None:
if member.bot:
return
guild_id = member.guild.id
uid = member.id
bc = before.channel
ac = after.channel
if bc == ac:
return
now = _now_utc()
if bc is not None:
_finalize_voice_session(guild_id, uid, now)
if ac is not None:
_voice_join_at[(guild_id, uid)] = now
async def fetch_guild_members_snapshot(bot: discord.Client, guild_id: int | None) -> tuple[bool, str | None, dict]:
"""
Retourne (ok, erreur, payload) avec payload =
{ guild_id, guild_name, members: [ { id, display_name, name, avatar_url, joined_at, nick, roles } ] }
"""
guilds = list(bot.guilds)
if not guilds:
return False, "Le bot n'est sur aucun serveur.", {}
chosen: discord.Guild | None = None
if guild_id is not None:
chosen = discord.utils.get(guilds, id=guild_id)
if chosen is None:
return False, "Serveur Discord introuvable pour ce bot.", {}
else:
if len(guilds) == 1:
chosen = guilds[0]
else:
return (
False,
"Plusieurs serveurs : précisez ?guild_id=… dans l'URL.",
{"guilds": [{"id": g.id, "name": g.name} for g in guilds]},
)
try:
await asyncio.wait_for(chosen.chunk(cache=True), timeout=120.0)
except asyncio.TimeoutError:
logger.warning(
"guild.chunk timeout (%s), suite avec le cache membres partiel",
chosen.name,
)
except Exception as e:
logger.warning("guild.chunk: %s", e, exc_info=True)
members_out = []
for m in chosen.members:
if m.bot:
continue
role_list = [r for r in m.roles if r.name != "@everyone"]
role_list.sort(key=lambda r: r.position, reverse=True)
roles = ", ".join(r.name for r in role_list[:8])
if len(role_list) > 8:
roles += f" (+{len(role_list) - 8})"
joined = m.joined_at.isoformat() if m.joined_at else None
members_out.append({
"id": str(m.id),
"display_name": m.display_name,
"name": m.name,
"avatar_url": m.display_avatar.url if m.display_avatar else "",
"joined_at": joined,
"nick": m.nick,
"roles": roles or "",
})
members_out.sort(key=lambda x: (x["display_name"] or x["name"]).lower())
return True, None, {
"guild_id": str(chosen.id),
"guild_name": chosen.name,
"members": members_out,
}
def get_discord_members_snapshot_sync(bot: discord.Client, guild_id: int | None = None, timeout: float = 180.0) -> tuple[bool, str | None, dict]:
"""Appel thread-safe depuis Flask (run_coroutine_threadsafe sur la boucle du bot)."""
loop = getattr(bot, "loop", None)
if loop is None:
logger.error("get_discord_members_snapshot_sync: bot.loop est None (bot non démarré ?)")
return False, "Bot Discord non démarré (aucune boucle événements).", {}
if not bot.is_ready():
return False, "Bot Discord pas encore prêt (connexion en cours). Réessayez dans quelques secondes.", {}
try:
future = asyncio.run_coroutine_threadsafe(
fetch_guild_members_snapshot(bot, guild_id),
loop,
)
ok, err, payload = future.result(timeout=timeout)
return ok, err, payload
except (concurrent.futures.TimeoutError, TimeoutError) as e:
logger.error(
"get_discord_members_snapshot_sync: délai dépassé après %.0fs (%s)",
timeout,
type(e).__name__,
exc_info=True,
)
return (
False,
"Délai dépassé pendant le chargement des membres (serveur volumineux ou bot occupé). Réessayez dans un instant.",
{},
)
except Exception as e:
logger.exception("get_discord_members_snapshot_sync: %s", type(e).__name__)
msg = str(e) if str(e) else repr(e)
return False, msg or "Erreur lors de lappel au bot Discord.", {}
-67
View File
@@ -1,67 +0,0 @@
"""
Journalisation des en-têtes HTTP sur les réponses 429 (rate limit Discord).
Réf. Discord : https://support-dev.discord.com/hc/en-us/articles/6223003921559-My-Bot-is-Being-Rate-Limited
discord.py transmet ce TraceConfig aiohttp via loption Client(http_trace=...).
Le corps JSON (retry_after, global) est toujours loggé par le logger discord.http.
"""
import logging
from typing import Any
import aiohttp
logger = logging.getLogger('discord.ratelimit_headers')
# En-têtes utiles pour identifier le type de limite (global / user / shared, etc.)
_HEADER_KEYS = (
'X-RateLimit-Limit',
'X-RateLimit-Remaining',
'X-RateLimit-Reset',
'X-RateLimit-Reset-After',
'X-RateLimit-Scope',
'X-Ratelimit-Bucket',
'X-Ratelimit-Limit',
'X-Ratelimit-Remaining',
'X-Ratelimit-Reset',
'Retry-After',
'Via',
)
def _collect_headers(resp: aiohttp.ClientResponse) -> dict[str, str]:
h = resp.headers
out: dict[str, str] = {}
for key in _HEADER_KEYS:
val = h.get(key)
if val is not None:
out[key] = val
return out
def build_discord_http_trace_config() -> aiohttp.TraceConfig:
trace = aiohttp.TraceConfig()
async def on_request_end(
session: aiohttp.ClientSession,
trace_config_ctx: Any,
params: Any,
) -> None:
try:
resp = getattr(params, 'response', None)
if resp is None or getattr(resp, 'status', None) != 429:
return
method = getattr(params, 'method', '?')
url = getattr(params, 'url', '?')
hdr = _collect_headers(resp)
logger.warning(
'Discord API 429 %s %s | rate_limit_headers=%s',
method,
url,
hdr,
)
except Exception:
logger.debug('rate_limit trace callback failed', exc_info=True)
trace.on_request_end.append(on_request_end)
return trace
+7 -27
View File
@@ -2,7 +2,6 @@ import locale
import logging import logging
import threading import threading
import os import os
import time
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from webapp import webapp from webapp import webapp
@@ -38,25 +37,9 @@ if __name__ == '__main__':
handlers.append(file_handler) handlers.append(file_handler)
logging.basicConfig(level=logging.INFO, handlers=handlers) logging.basicConfig(level=logging.INFO, handlers=handlers)
# Éviter les doublons de lignes discord (ex. discord.http sans [thread] puis avec) :
# discord.py peut attacher des handlers avant basicConfig ; on ne garde que la propagation vers la racine.
for _name in (
'discord',
'discord.client',
'discord.http',
'discord.gateway',
'discord.state',
'discord.webhook',
):
_lg = logging.getLogger(_name)
_lg.handlers.clear()
_lg.propagate = True
# Calmer les logs verbeux de certaines libs si besoin # Calmer les logs verbeux de certaines libs si besoin
logging.getLogger('werkzeug').setLevel(logging.WARNING) logging.getLogger('werkzeug').setLevel(logging.WARNING)
logging.getLogger('discord').setLevel(logging.WARNING) logging.getLogger('discord').setLevel(logging.WARNING)
# 429 : en-têtes X-RateLimit-* (voir discordbot/rate_limit_logging.py + doc Discord rate limits)
logging.getLogger('discord.ratelimit_headers').setLevel(logging.WARNING)
# Hook exceptions non-capturées (threads inclus) # Hook exceptions non-capturées (threads inclus)
def _log_uncaught(exc_type, exc, tb): def _log_uncaught(exc_type, exc, tb):
@@ -70,15 +53,12 @@ if __name__ == '__main__':
locale.setlocale(locale.LC_TIME, 'fr_FR.UTF-8') locale.setlocale(locale.LC_TIME, 'fr_FR.UTF-8')
t_discord = threading.Thread(target=start_discord_bot, name='discord-bot') jobs = []
t_web = threading.Thread(target=start_server, name='web-server') jobs.append(threading.Thread(target=start_discord_bot, name='discord-bot'))
t_twitch = threading.Thread(target=start_twitch_bot, name='twitch-bot') jobs.append(threading.Thread(target=start_server, name='web-server'))
jobs.append(threading.Thread(target=start_twitch_bot, name='twitch-bot'))
# Démarrer Discord en premier : le 429 sur GET /users/@me est souvent un pic au login ; for job in jobs:
# lancer Waitress + Twitch quelques secondes après évite la concurrence au tout début. job.start()
t_discord.start() for job in jobs:
time.sleep(3)
t_web.start()
t_twitch.start()
for job in (t_discord, t_web, t_twitch):
job.join() job.join()
+1 -1
View File
@@ -41,7 +41,7 @@ def load_user(user_id):
except (ValueError, TypeError): except (ValueError, TypeError):
return None return None
from webapp import auth, commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation, discord_members, youtube, announcements, twitch_moderation, link_filter, twitch_events, users, settings, freeloot, patreon from webapp import auth, commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation, youtube, announcements, twitch_moderation, link_filter, twitch_events, users, settings, freeloot, patreon
from flask import request, redirect, url_for from flask import request, redirect, url_for
from flask_login import current_user from flask_login import current_user
-164
View File
@@ -1,164 +0,0 @@
from collections import defaultdict
from datetime import datetime
from flask import redirect, render_template, request, url_for
from sqlalchemy import text
from webapp import webapp
from webapp.auth import require_page
from database import db
from database.models import GuildMemberStats, ModerationEvent
from discordbot import bot
from discordbot.member_stats import get_discord_members_snapshot_sync
def _format_voice_seconds(sec: int) -> str:
if sec <= 0:
return "0 min"
h, sec = divmod(sec, 3600)
m, sec = divmod(sec, 60)
if h:
return f"{h}h {m}min"
if m:
return f"{m}min"
return f"{sec}s"
def _event_to_row(e: ModerationEvent) -> dict:
return {
"type": e.type or "",
"created_at": e.created_at.strftime("%d/%m/%Y %H:%M") if e.created_at else "",
"reason": (e.reason or "")[:500],
"staff_name": e.staff_name or "",
"duration": e.duration,
}
def _load_latest_invites(guild_id_str: str) -> dict[str, dict]:
rows = db.session.execute(
text("""
SELECT user_id, invite_code, inviter_name, join_date FROM (
SELECT user_id, invite_code, inviter_name, join_date,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY join_date DESC, id DESC) AS rn
FROM member_invites WHERE guild_id = :gid
) WHERE rn = 1
"""),
{"gid": guild_id_str},
).mappings().all()
out = {}
for r in rows:
jd = r["join_date"]
out[str(r["user_id"])] = {
"invite_code": r["invite_code"] or "",
"inviter_name": r["inviter_name"] or "",
"join_date": jd.strftime("%d/%m/%Y %H:%M") if jd else "",
}
return out
def _load_sanctions_by_user(user_ids: list[str]) -> dict[str, list]:
by_user: dict[str, list] = defaultdict(list)
chunk = 400
for i in range(0, len(user_ids), chunk):
part = user_ids[i : i + chunk]
if not part:
continue
q = ModerationEvent.query.filter(ModerationEvent.discord_id.in_(part))
for e in q.all():
by_user[e.discord_id].append(e)
for uid in by_user:
by_user[uid].sort(key=lambda x: x.created_at or datetime.min, reverse=True)
return by_user
@webapp.route("/discord-members")
@webapp.route("/discord-members.html")
def discord_members_redirect():
"""Redirection : lURL réelle de la page est /discord-membres (route Flask, pas un fichier .html)."""
return redirect(url_for("discord_members", **request.args), code=302)
@webapp.route("/discord-membres")
@require_page("discord_members")
def discord_members():
status = webapp.config.get("BOT_STATUS", {})
bot_connected = bool(status.get("discord_connected"))
guild_param = request.args.get("guild_id", type=int)
ok, err, payload = get_discord_members_snapshot_sync(bot, guild_id=guild_param)
guild_choices = payload.get("guilds") if payload else None
if not ok and guild_choices:
return render_template(
"discord_members.html",
bot_connected=bot_connected,
load_error=err,
guild_choices=guild_choices,
guild_name=None,
members=[],
)
if not ok:
return render_template(
"discord_members.html",
bot_connected=bot_connected,
load_error=err or "Impossible de charger les membres.",
guild_choices=None,
guild_name=None,
members=[],
)
guild_id_str = payload["guild_id"]
guild_name = payload["guild_name"]
raw_members = payload["members"]
user_ids = [m["id"] for m in raw_members]
stats_map = {
r.user_id: r
for r in GuildMemberStats.query.filter_by(guild_id=guild_id_str).all()
}
invites_map = _load_latest_invites(guild_id_str)
sanctions_by_user = _load_sanctions_by_user(user_ids)
members = []
for m in raw_members:
uid = m["id"]
joined_raw = m.get("joined_at")
joined_display = ""
if joined_raw:
try:
dt = datetime.fromisoformat(joined_raw.replace("Z", "+00:00"))
joined_display = dt.strftime("%d/%m/%Y %H:%M") + " UTC"
except (ValueError, TypeError):
joined_display = joined_raw
st = stats_map.get(uid)
msg_c = st.message_count if st else 0
voice_s = st.voice_seconds if st else 0
inv = invites_map.get(uid, {"invite_code": "", "inviter_name": "", "join_date": ""})
events = sanctions_by_user.get(uid, [])
sanction_rows = [_event_to_row(e) for e in events]
search_blob = f"{m.get('display_name') or ''} {m.get('name') or ''} {uid}".lower()
members.append({
**m,
"joined_display": joined_display,
"search_blob": search_blob,
"message_count": msg_c,
"voice_label": _format_voice_seconds(voice_s),
"voice_seconds": voice_s,
"invite_code": inv["invite_code"],
"inviter_name": inv["inviter_name"],
"invite_join_date": inv["join_date"],
"sanction_count": len(sanction_rows),
"sanctions": sanction_rows,
})
return render_template(
"discord_members.html",
bot_connected=bot_connected,
load_error=None,
guild_choices=None,
guild_name=guild_name,
guild_id=guild_id_str,
members=members,
)
-7
View File
@@ -63,12 +63,6 @@ PAGE_METADATA = {
"description": "Historique de modération Discord", "description": "Historique de modération Discord",
"icon": "shield" "icon": "shield"
}, },
"discord_members": {
"label": "Membres Discord",
"category": "moderation",
"description": "Liste des membres, activité et sanctions",
"icon": "users"
},
"twitch_moderation": { "twitch_moderation": {
"label": "Modération Twitch", "label": "Modération Twitch",
"category": "moderation", "category": "moderation",
@@ -164,7 +158,6 @@ PAGE_KEYS = [
("protondb", "ProtonDB"), ("protondb", "ProtonDB"),
("freeloot", "FreeLoot"), ("freeloot", "FreeLoot"),
("moderation", "Modération Discord"), ("moderation", "Modération Discord"),
("discord_members", "Membres Discord"),
("users", "Utilisateurs"), ("users", "Utilisateurs"),
("settings", "Paramètres"), ("settings", "Paramètres"),
] ]
-153
View File
@@ -1,153 +0,0 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-6">
<h1 class="text-2xl font-semibold text-slate-800 dark:text-white mb-1">Membres Discord</h1>
<p class="text-sm text-slate-600 dark:text-slate-400">
Rapport par membre : arrivée sur le serveur, invitation enregistrée, activité (messages et vocal depuis le suivi activé) et sanctions.
</p>
{% if guild_name %}
<p class="text-sm font-medium text-primary-600 dark:text-primary-400 mt-1">{{ guild_name }}</p>
{% endif %}
</div>
{% if not bot_connected %}
<div class="mb-6 rounded-lg border border-amber-200 dark:border-amber-900/50 bg-amber-50 dark:bg-amber-950/30 px-4 py-3 text-sm text-amber-900 dark:text-amber-200">
Le bot Discord semble déconnecté : la liste des membres peut être indisponible ou incomplète.
</div>
{% endif %}
{% if load_error %}
<div class="mb-6 rounded-lg border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-950/30 px-4 py-3 text-sm text-red-800 dark:text-red-200">
{{ load_error }}
{% if guild_choices %}
<ul class="mt-3 space-y-2">
{% for g in guild_choices %}
<li>
<a href="{{ url_for('discord_members', guild_id=g.id) }}" class="font-medium text-primary-600 dark:text-primary-400 hover:underline">{{ g.name }}</a>
<span class="text-slate-500 dark:text-slate-400 font-mono text-xs ml-2">({{ g.id }})</span>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endif %}
{% if not load_error and members|length == 0 and not guild_choices %}
<div class="rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 px-5 py-8 text-center text-slate-500 dark:text-slate-400 text-sm">
Aucun membre humain à afficher sur ce serveur.
</div>
{% endif %}
{% if members %}
<div class="mb-4 flex flex-col sm:flex-row sm:items-center gap-3">
<label class="sr-only" for="member-search">Rechercher un membre</label>
<input type="search" id="member-search" placeholder="Rechercher par nom ou ID…" autocomplete="off"
class="w-full sm:max-w-md rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 px-4 py-2.5 text-sm text-slate-800 dark:text-white placeholder-slate-400 focus:ring-2 focus:ring-primary-500 focus:border-primary-500" />
<p class="text-xs text-slate-500 dark:text-slate-400">
Les compteurs <strong>messages</strong> et <strong>vocal</strong> sont cumulés à partir du déploiement du suivi (pas dhistorique rétroactif). Linvitation provient du message de bienvenue si celui-ci était activé au moment du join.
</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
{% for m in members %}
<div data-member-card data-search="{{ m.search_blob }}" class="rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 overflow-hidden shadow-sm hover:shadow-md transition-shadow">
<div class="p-4 flex gap-4">
<img src="{{ m.avatar_url }}" alt="" width="64" height="64" class="w-16 h-16 rounded-full flex-shrink-0 ring-2 ring-slate-200 dark:ring-slate-600 object-cover" loading="lazy" />
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<h2 class="text-base font-semibold text-slate-900 dark:text-white truncate">{{ m.display_name }}</h2>
<p class="text-xs text-slate-500 dark:text-slate-400 font-mono truncate">@{{ m.name }} · {{ m.id }}</p>
</div>
{% if m.sanction_count > 0 %}
<span class="flex-shrink-0 text-xs font-semibold px-2 py-0.5 rounded-full bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-200">{{ m.sanction_count }} sanction{% if m.sanction_count > 1 %}s{% endif %}</span>
{% endif %}
</div>
<div class="mt-3 flex flex-wrap gap-2">
<span class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-200">
<svg class="w-3.5 h-3.5 opacity-70" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path></svg>
{{ m.message_count }} msg
</span>
<span class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md bg-violet-100 dark:bg-violet-900/40 text-violet-800 dark:text-violet-200">
<svg class="w-3.5 h-3.5 opacity-70" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"></path></svg>
{{ m.voice_label }}
</span>
{% if m.joined_display != '—' %}
<span class="inline-flex items-center text-xs px-2 py-1 rounded-md bg-emerald-100 dark:bg-emerald-900/40 text-emerald-800 dark:text-emerald-200" title="Membre depuis">{{ m.joined_display }}</span>
{% endif %}
</div>
</div>
</div>
<details class="group border-t border-slate-200 dark:border-slate-700">
<summary class="px-4 py-3 cursor-pointer text-sm font-medium text-primary-600 dark:text-primary-400 hover:bg-slate-50 dark:hover:bg-slate-700/40 flex items-center justify-between list-none">
<span>Rapport détaillé</span>
<svg class="w-4 h-4 transition-transform group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
</summary>
<div class="px-4 pb-4 pt-0 space-y-4 text-sm">
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-slate-600 dark:text-slate-300">
<div>
<dt class="text-xs uppercase tracking-wide text-slate-400 dark:text-slate-500">Pseudo serveur</dt>
<dd>{{ m.nick or '—' }}</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-slate-400 dark:text-slate-500">Arrivée (Discord)</dt>
<dd>{{ m.joined_display }}</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-slate-400 dark:text-slate-500">Invitation (DB)</dt>
<dd><code class="text-xs bg-slate-100 dark:bg-slate-700 px-1 rounded">{{ m.invite_code }}</code></dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-slate-400 dark:text-slate-500">Invité par</dt>
<dd>{{ m.inviter_name }}</dd>
</div>
<div class="sm:col-span-2">
<dt class="text-xs uppercase tracking-wide text-slate-400 dark:text-slate-500">Date join enregistrée (welcome)</dt>
<dd>{{ m.invite_join_date }}</dd>
</div>
<div class="sm:col-span-2">
<dt class="text-xs uppercase tracking-wide text-slate-400 dark:text-slate-500">Rôles</dt>
<dd class="text-xs leading-relaxed">{{ m.roles }}</dd>
</div>
</dl>
{% if m.sanctions %}
<div>
<h3 class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400 mb-2">Sanctions</h3>
<ul class="space-y-2 max-h-48 overflow-y-auto">
{% for s in m.sanctions %}
<li class="rounded-lg border border-slate-200 dark:border-slate-600 px-3 py-2 bg-slate-50 dark:bg-slate-900/50">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<span class="font-medium text-slate-800 dark:text-slate-200">{{ s.type }}</span>
<span class="text-xs text-slate-500">{{ s.created_at }}</span>
</div>
<p class="text-xs text-slate-600 dark:text-slate-400 mt-1">{{ s.reason }}</p>
<p class="text-xs text-slate-500 mt-1">Par {{ s.staff_name }}{% if s.duration is not none %} · durée {{ s.duration }}s{% endif %}</p>
</li>
{% endfor %}
</ul>
</div>
{% else %}
<p class="text-xs text-slate-500 dark:text-slate-400">Aucune sanction enregistrée en base pour ce membre.</p>
{% endif %}
</div>
</details>
</div>
{% endfor %}
</div>
<script>
(function() {
var input = document.getElementById('member-search');
if (!input) return;
input.addEventListener('input', function() {
var q = (input.value || '').toLowerCase().trim();
document.querySelectorAll('[data-member-card]').forEach(function(el) {
var blob = (el.getAttribute('data-search') || '').toLowerCase();
el.style.display = !q || blob.indexOf(q) !== -1 ? '' : 'none';
});
});
})();
</script>
{% endif %}
{% endblock %}
-8
View File
@@ -123,10 +123,6 @@
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
Modération Modération
</a> </a>
<a href="{{ url_for('discord_members') }}" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
Membres
</a>
<a href="/configurations#auto-rooms" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"> <a href="/configurations#auto-rooms" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v3m0 0v-3a7 7 0 017-7"></path></svg> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v3m0 0v-3a7 7 0 017-7"></path></svg>
Auto Rooms Auto Rooms
@@ -249,10 +245,6 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
Modération Modération
</a> </a>
<a href="{{ url_for('discord_members') }}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
Membres Discord
</a>
<a href="/configurations#auto-rooms" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all"> <a href="/configurations#auto-rooms" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v3m0 0v-3a7 7 0 017-7"></path></svg> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v3m0 0v-3a7 7 0 017-7"></path></svg>
Auto Rooms Auto Rooms