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
+1 -1
View File
@@ -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
-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",
"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"),
]
-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>
Modération
</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">
<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
@@ -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>
Modération
</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">
<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