diff --git a/database/__init__.py b/database/__init__.py index 6226e9a..e739374 100644 --- a/database/__init__.py +++ b/database/__init__.py @@ -227,31 +227,6 @@ def _doAddColumnMigrations(cursor: Cursor): except Exception as 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): """Seed rôles par défaut et permissions des pages si vides.""" from database.models import ROLE_ORDER @@ -294,7 +269,6 @@ def _doSeedAuth(cursor: Cursor): ("freeloot", 1, 2), ("patreon", 1, 2), ("moderation", 1, 2), - ("discord_members", 1, 1), ("users", 5, 5), ("settings", 5, 5), ] diff --git a/database/models.py b/database/models.py index 04303f7..7b42a4c 100644 --- a/database/models.py +++ b/database/models.py @@ -260,12 +260,3 @@ class ModShoutboxMessage(db.Model): message = db.Column(db.String(500), nullable=False) 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) - diff --git a/database/schema.sql b/database/schema.sql index a2ddfae..87a1926 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -99,15 +99,6 @@ CREATE TABLE IF NOT EXISTS `member_invites` ( `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` ( id INTEGER PRIMARY KEY AUTOINCREMENT, `enabled` BOOLEAN NOT NULL DEFAULT FALSE, diff --git a/discordbot/__init__.py b/discordbot/__init__.py index 34a4789..64ac489 100644 --- a/discordbot/__init__.py +++ b/discordbot/__init__.py @@ -29,13 +29,11 @@ from discordbot.welcome import sendWelcomeMessage, sendLeaveMessage, updateInvit from discordbot.patreon import checkPatreonPosts 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.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 class DiscordBot(discord.Client): 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.synced = False @@ -169,10 +167,7 @@ async def on_message(message: Message): # Gestion des messages dans les auto rooms (avant le check des commandes !) 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('!'): return command_name = message.content.split()[0] @@ -336,7 +331,6 @@ async def on_message(message: Message): @bot.event async def on_voice_state_update(member: 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 async def on_raw_reaction_add(payload: discord.RawReactionActionEvent): diff --git a/discordbot/member_stats.py b/discordbot/member_stats.py deleted file mode 100644 index 129f79a..0000000 --- a/discordbot/member_stats.py +++ /dev/null @@ -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 l’appel au bot Discord.", {} diff --git a/discordbot/rate_limit_logging.py b/discordbot/rate_limit_logging.py deleted file mode 100644 index b42f8ee..0000000 --- a/discordbot/rate_limit_logging.py +++ /dev/null @@ -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 l’option 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 diff --git a/run-web.py b/run-web.py index 291d658..b37d3f9 100644 --- a/run-web.py +++ b/run-web.py @@ -2,7 +2,6 @@ import locale import logging import threading import os -import time from logging.handlers import RotatingFileHandler from webapp import webapp @@ -38,25 +37,9 @@ if __name__ == '__main__': handlers.append(file_handler) 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 logging.getLogger('werkzeug').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) def _log_uncaught(exc_type, exc, tb): @@ -70,15 +53,12 @@ if __name__ == '__main__': locale.setlocale(locale.LC_TIME, 'fr_FR.UTF-8') - t_discord = threading.Thread(target=start_discord_bot, name='discord-bot') - t_web = threading.Thread(target=start_server, name='web-server') - t_twitch = threading.Thread(target=start_twitch_bot, name='twitch-bot') + jobs = [] + jobs.append(threading.Thread(target=start_discord_bot, name='discord-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 ; - # lancer Waitress + Twitch quelques secondes après évite la concurrence au tout début. - t_discord.start() - time.sleep(3) - t_web.start() - t_twitch.start() - for job in (t_discord, t_web, t_twitch): + for job in jobs: + job.start() + for job in jobs: job.join() diff --git a/webapp/__init__.py b/webapp/__init__.py index 6a1d033..67e43c2 100644 --- a/webapp/__init__.py +++ b/webapp/__init__.py @@ -41,7 +41,7 @@ def load_user(user_id): except (ValueError, TypeError): 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_login import current_user diff --git a/webapp/discord_members.py b/webapp/discord_members.py deleted file mode 100644 index 5e1eb76..0000000 --- a/webapp/discord_members.py +++ /dev/null @@ -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 : l’URL 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, - ) diff --git a/webapp/settings.py b/webapp/settings.py index 21466de..52a4f72 100644 --- a/webapp/settings.py +++ b/webapp/settings.py @@ -63,12 +63,6 @@ PAGE_METADATA = { "description": "Historique de modération Discord", "icon": "shield" }, - "discord_members": { - "label": "Membres Discord", - "category": "moderation", - "description": "Liste des membres, activité et sanctions", - "icon": "users" - }, "twitch_moderation": { "label": "Modération Twitch", "category": "moderation", @@ -164,7 +158,6 @@ PAGE_KEYS = [ ("protondb", "ProtonDB"), ("freeloot", "FreeLoot"), ("moderation", "Modération Discord"), - ("discord_members", "Membres Discord"), ("users", "Utilisateurs"), ("settings", "Paramètres"), ] diff --git a/webapp/templates/discord_members.html b/webapp/templates/discord_members.html deleted file mode 100644 index a1d6414..0000000 --- a/webapp/templates/discord_members.html +++ /dev/null @@ -1,153 +0,0 @@ -{% extends "template.html" %} - -{% block content %} -
-

Membres Discord

-

- Rapport par membre : arrivée sur le serveur, invitation enregistrée, activité (messages et vocal depuis le suivi activé) et sanctions. -

- {% if guild_name %} -

{{ guild_name }}

- {% endif %} -
- -{% if not bot_connected %} -
- Le bot Discord semble déconnecté : la liste des membres peut être indisponible ou incomplète. -
-{% endif %} - -{% if load_error %} -
- {{ load_error }} - {% if guild_choices %} - - {% endif %} -
-{% endif %} - -{% if not load_error and members|length == 0 and not guild_choices %} -
- Aucun membre humain à afficher sur ce serveur. -
-{% endif %} - -{% if members %} -
- - -

- Les compteurs messages et vocal sont cumulés à partir du déploiement du suivi (pas d’historique rétroactif). L’invitation provient du message de bienvenue si celui-ci était activé au moment du join. -

-
- -
- {% for m in members %} -
-
- -
-
-
-

{{ m.display_name }}

-

@{{ m.name }} · {{ m.id }}

-
- {% if m.sanction_count > 0 %} - {{ m.sanction_count }} sanction{% if m.sanction_count > 1 %}s{% endif %} - {% endif %} -
-
- - - {{ m.message_count }} msg - - - - {{ m.voice_label }} - - {% if m.joined_display != '—' %} - {{ m.joined_display }} - {% endif %} -
-
-
-
- - Rapport détaillé - - -
-
-
-
Pseudo serveur
-
{{ m.nick or '—' }}
-
-
-
Arrivée (Discord)
-
{{ m.joined_display }}
-
-
-
Invitation (DB)
-
{{ m.invite_code }}
-
-
-
Invité par
-
{{ m.inviter_name }}
-
-
-
Date join enregistrée (welcome)
-
{{ m.invite_join_date }}
-
-
-
Rôles
-
{{ m.roles }}
-
-
- {% if m.sanctions %} -
-

Sanctions

-
    - {% for s in m.sanctions %} -
  • -
    - {{ s.type }} - {{ s.created_at }} -
    -

    {{ s.reason }}

    -

    Par {{ s.staff_name }}{% if s.duration is not none %} · durée {{ s.duration }}s{% endif %}

    -
  • - {% endfor %} -
-
- {% else %} -

Aucune sanction enregistrée en base pour ce membre.

- {% endif %} -
-
-
- {% endfor %} -
- - -{% endif %} -{% endblock %} diff --git a/webapp/templates/template.html b/webapp/templates/template.html index 72f1b39..bc65760 100644 --- a/webapp/templates/template.html +++ b/webapp/templates/template.html @@ -123,10 +123,6 @@ Modération - - - Membres - Auto Rooms @@ -249,10 +245,6 @@ Modération - - - Membres Discord - Auto Rooms