diff --git a/bot.py b/bot.py index 9303c6f..67a3a0a 100755 --- a/bot.py +++ b/bot.py @@ -130,6 +130,7 @@ class MyBot(commands.Bot): "commandes.moderation", "commandes.invites", "commandes.taches", + "commandes.rapports", ] for extension in extensions: diff --git a/commandes/rapports.py b/commandes/rapports.py new file mode 100644 index 0000000..8f1bf12 --- /dev/null +++ b/commandes/rapports.py @@ -0,0 +1,904 @@ +import os +import sqlite3 +import logging +import asyncio +from datetime import datetime, timedelta +try: + from zoneinfo import ZoneInfo +except ImportError: + from backports.zoneinfo import ZoneInfo + +import disnake +from disnake.ext import commands, tasks + +kuby_logger = logging.getLogger("KubyBot") +PARIS_TZ = ZoneInfo("Europe/Paris") + +class ReportsDB: + def __init__(self, db_path): + self.db_path = db_path + self._init_db() + + def _init_db(self): + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + # Table pour les rapports + cursor.execute(''' + CREATE TABLE IF NOT EXISTS reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id INTEGER NOT NULL, + author_id INTEGER NOT NULL, + title TEXT NOT NULL, + subject TEXT NOT NULL, + content TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'En attente', + created_at TEXT NOT NULL, + last_edited_at TEXT, + channel_id INTEGER NOT NULL, + role_id_auteur INTEGER + ) + ''') + + # Table pour la configuration du module + cursor.execute(''' + CREATE TABLE IF NOT EXISTS reports_config ( + guild_id INTEGER PRIMARY KEY, + viewer_role_id INTEGER + ) + ''') + conn.commit() + conn.close() + + def get_config(self, guild_id): + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cursor.execute("SELECT viewer_role_id FROM reports_config WHERE guild_id = ?", (guild_id,)) + row = cursor.fetchone() + conn.close() + return row[0] if row else None + + def set_config(self, guild_id, viewer_role_id): + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO reports_config (guild_id, viewer_role_id) + VALUES (?, ?) + ON CONFLICT(guild_id) DO UPDATE SET viewer_role_id = excluded.viewer_role_id + ''', (guild_id, viewer_role_id)) + conn.commit() + conn.close() + + def add_report(self, guild_id, author_id, title, subject, content, channel_id, role_id_auteur): + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + created_at = datetime.now(PARIS_TZ).isoformat() + cursor.execute(''' + INSERT INTO reports (guild_id, author_id, title, subject, content, status, created_at, channel_id, role_id_auteur) + VALUES (?, ?, ?, ?, ?, 'En attente', ?, ?, ?) + ''', (guild_id, author_id, title, subject, content, created_at, channel_id, role_id_auteur)) + report_id = cursor.lastrowid + conn.commit() + conn.close() + return report_id + + def get_report(self, report_id): + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute("SELECT * FROM reports WHERE id = ?", (report_id,)) + row = cursor.fetchone() + conn.close() + return dict(row) if row else None + + def update_report_status(self, report_id, status): + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + last_edited_at = datetime.now(PARIS_TZ).isoformat() + cursor.execute(''' + UPDATE reports + SET status = ?, last_edited_at = ? + WHERE id = ? + ''', (status, last_edited_at, report_id)) + conn.commit() + conn.close() + + def update_report_content(self, report_id, title, subject, content): + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + last_edited_at = datetime.now(PARIS_TZ).isoformat() + cursor.execute(''' + UPDATE reports + SET title = ?, subject = ?, content = ?, last_edited_at = ? + WHERE id = ? + ''', (title, subject, content, last_edited_at, report_id)) + conn.commit() + conn.close() + + def delete_report(self, report_id): + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cursor.execute("DELETE FROM reports WHERE id = ?", (report_id,)) + conn.commit() + conn.close() + + def get_reports(self, guild_id, author_id=None, subject=None, status=None, role_id=None): + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + query = "SELECT * FROM reports WHERE guild_id = ?" + params = [guild_id] + + if author_id is not None: + query += " AND author_id = ?" + params.append(author_id) + if subject is not None: + query += " AND subject LIKE ?" + params.append(f"%{subject}%") + if status is not None: + query += " AND status = ?" + params.append(status) + if role_id is not None: + query += " AND role_id_auteur = ?" + params.append(role_id) + + query += " ORDER BY id ASC" + + cursor.execute(query, params) + rows = cursor.fetchall() + conn.close() + return [dict(r) for r in rows] + + def get_distinct_subjects(self, guild_id): + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cursor.execute("SELECT DISTINCT subject FROM reports WHERE guild_id = ? ORDER BY subject ASC", (guild_id,)) + rows = cursor.fetchall() + conn.close() + return [row[0] for row in rows if row[0]] + + def purge_manual(self, guild_id): + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cutoff = (datetime.now(PARIS_TZ) - timedelta(days=7)).isoformat() + cursor.execute(''' + DELETE FROM reports + WHERE guild_id = ? AND status != 'Classé' AND created_at < ? + ''', (guild_id, cutoff)) + deleted_count = cursor.rowcount + conn.commit() + conn.close() + return deleted_count + + def purge_automatic(self): + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cutoff = (datetime.now(PARIS_TZ) - timedelta(days=90)).isoformat() + cursor.execute(''' + DELETE FROM reports + WHERE status != 'Classé' AND created_at < ? + ''', (cutoff,)) + deleted_count = cursor.rowcount + conn.commit() + conn.close() + return deleted_count + +class ReportsConfigView(disnake.ui.View): + def __init__(self, cog, guild_id: int): + super().__init__(timeout=300) + self.cog = cog + self.guild_id = guild_id + + @disnake.ui.role_select(placeholder="Modifier le rôle Lecteur (Viewer)", min_values=1, max_values=1, custom_id="reports_select_viewer_role") + async def select_viewer_role(self, select: disnake.ui.RoleSelect, interaction: disnake.MessageInteraction): + if not interaction.author.guild_permissions.administrator: + return await interaction.response.send_message("❌ Vous devez être administrateur pour configurer ce module.", ephemeral=True) + + role = select.values[0] + self.cog.db.set_config(self.guild_id, role.id) + + embed = interaction.message.embeds[0] + embed.set_field_at( + 0, + name="👁️ Rôle Lecteur (Viewer)", + value=f"{role.mention}\n*(Les membres ayant ce rôle peuvent consulter tous les rapports du serveur)*", + inline=False + ) + await interaction.response.edit_message(embed=embed, view=self) + await interaction.followup.send(f"✅ Rôle Lecteur configuré sur {role.mention}", ephemeral=True) + + @disnake.ui.button(label="Purge Manuelle", style=disnake.ButtonStyle.red, emoji="🗑️", custom_id="reports_manual_purge_btn") + async def manual_purge(self, button: disnake.ui.Button, interaction: disnake.MessageInteraction): + if not interaction.author.guild_permissions.administrator: + return await interaction.response.send_message("❌ Vous devez être administrateur pour lancer la purge.", ephemeral=True) + + await interaction.response.defer(ephemeral=True) + deleted_count = self.cog.db.purge_manual(self.guild_id) + await interaction.followup.send(f"✅ Purge terminée ! **{deleted_count}** rapports (créés il y a > 7 jours et non-classés) ont été définitivement supprimés.", ephemeral=True) + +class ReportModal(disnake.ui.Modal): + def __init__(self, cog): + self.cog = cog + components = [ + disnake.ui.TextInput( + label="Titre du rapport", + placeholder="Entrez le titre du rapport...", + custom_id="report_title", + required=True, + max_length=100 + ), + disnake.ui.TextInput( + label="Objet du rapport", + placeholder="Ex: Bug, Plainte, Suggestion...", + custom_id="report_subject", + required=True, + max_length=100 + ), + disnake.ui.TextInput( + label="Contenu", + style=disnake.TextInputStyle.paragraph, + placeholder="Décrivez en détail...", + custom_id="report_content", + required=True, + max_length=2000 + ) + ] + super().__init__(title="Créer un Rapport", custom_id="report_modal", components=components) + + async def callback(self, interaction: disnake.ModalInteraction): + title = interaction.text_values["report_title"].strip() + subject = interaction.text_values["report_subject"].strip() + content = interaction.text_values["report_content"].strip() + + role_id_auteur = interaction.author.top_role.id if interaction.author.roles else None + + report_id = self.cog.db.add_report( + guild_id=interaction.guild.id, + author_id=interaction.author.id, + title=title, + subject=subject, + content=content, + channel_id=interaction.channel.id, + role_id_auteur=role_id_auteur + ) + + await interaction.response.send_message( + f"✅ Votre rapport a été enregistré avec succès sous l'ID **#{report_id}**.", + ephemeral=True + ) + +class EditReportModal(disnake.ui.Modal): + def __init__(self, cog, report_id, browser_view): + self.cog = cog + self.report_id = report_id + self.browser_view = browser_view + + report = self.cog.db.get_report(report_id) + + components = [ + disnake.ui.TextInput( + label="Modifier le titre", + value=report['title'], + custom_id="edit_title", + required=True, + max_length=100 + ), + disnake.ui.TextInput( + label="Modifier l'objet", + value=report['subject'], + custom_id="edit_subject", + required=True, + max_length=100 + ), + disnake.ui.TextInput( + label="Modifier le contenu", + style=disnake.TextInputStyle.paragraph, + value=report['content'], + custom_id="edit_content", + required=True, + max_length=2000 + ) + ] + super().__init__(title=f"Modifier le Rapport #{report_id}", custom_id=f"edit_modal_{report_id}", components=components) + + async def callback(self, interaction: disnake.ModalInteraction): + report = self.cog.db.get_report(self.report_id) + if not report: + return await interaction.response.send_message("❌ Ce rapport n'existe plus.", ephemeral=True) + if report['status'] in ['Traité', 'Classé']: + return await interaction.response.send_message("❌ Ce rapport a été traité ou classé et ne peut plus être modifié.", ephemeral=True) + + title = interaction.text_values["edit_title"].strip() + subject = interaction.text_values["edit_subject"].strip() + content = interaction.text_values["edit_content"].strip() + + self.cog.db.update_report_content(self.report_id, title, subject, content) + + updated_report = self.cog.db.get_report(self.report_id) + + for idx, r in enumerate(self.browser_view.reports): + if r['id'] == self.report_id: + self.browser_view.reports[idx] = updated_report + break + + self.browser_view.update_components() + + await interaction.response.defer(ephemeral=True) + await interaction.message.edit(embed=self.browser_view.get_embed(), view=self.browser_view) + await interaction.followup.send("✅ Votre rapport a été modifié avec succès.", ephemeral=True) + +class ReportsFilterView(disnake.ui.View): + def __init__(self, cog, interaction): + super().__init__(timeout=300) + self.cog = cog + self.interaction = interaction + self.viewer_id = interaction.author.id + self.guild_id = interaction.guild.id + self.is_admin = interaction.author.guild_permissions.administrator + + viewer_role_id = self.cog.db.get_config(self.guild_id) + self.is_viewer = viewer_role_id in [r.id for r in interaction.author.roles] if viewer_role_id else False + self.has_global_access = self.is_admin or self.is_viewer + + self.selected_user = None + self.selected_role = None + self.selected_status = None + self.selected_subject = None + + self.update_components() + + def update_components(self): + self.clear_items() + + # Row 0: Menu Status + status_options = [ + disnake.SelectOption(label="Tous les statuts", emoji="⚪", value="all", default=(self.selected_status is None)), + disnake.SelectOption(label="En attente", emoji="🟡", value="En attente", default=(self.selected_status == "En attente")), + disnake.SelectOption(label="Traité", emoji="🟢", value="Traité", default=(self.selected_status == "Traité")), + disnake.SelectOption(label="Classé", emoji="🔴", value="Classé", default=(self.selected_status == "Classé")) + ] + status_select = disnake.ui.StringSelect( + placeholder="Filtrer par statut...", + options=status_options, + custom_id="reports_filter_status", + row=0 + ) + async def status_callback(inter: disnake.MessageInteraction): + val = inter.values[0] + self.selected_status = None if val == "all" else val + self.update_components() + await inter.response.edit_message(components=self.get_components_v2()) + status_select.callback = status_callback + self.add_item(status_select) + + # Row 1: Menu Catégorie + subjects = self.cog.db.get_distinct_subjects(self.guild_id) + if subjects: + subject_options = [ + disnake.SelectOption(label="Toutes les catégories", emoji="📁", value="all", default=(self.selected_subject is None)) + ] + for subj in subjects[:24]: + subject_options.append( + disnake.SelectOption(label=subj, value=subj, default=(self.selected_subject == subj)) + ) + subject_select = disnake.ui.StringSelect( + placeholder="Filtrer par catégorie...", + options=subject_options, + custom_id="reports_filter_subject", + row=1 + ) + async def subject_callback(inter: disnake.MessageInteraction): + val = inter.values[0] + self.selected_subject = None if val == "all" else val + self.update_components() + await inter.response.edit_message(components=self.get_components_v2()) + subject_select.callback = subject_callback + self.add_item(subject_select) + + current_row = 2 + + # Row 2 & 3: Filtres utilisateur / rôle (globaux uniquement) + if self.has_global_access: + user_select = disnake.ui.UserSelect( + placeholder="Filtrer par auteur...", + min_values=0, + max_values=1, + custom_id="reports_filter_user", + row=current_row + ) + async def user_callback(inter: disnake.MessageInteraction): + if inter.values: + val = inter.values[0] + if isinstance(val, str): + self.selected_user = inter.guild.get_member(int(val)) or val + else: + self.selected_user = val + else: + self.selected_user = None + self.update_components() + await inter.response.edit_message(components=self.get_components_v2()) + user_select.callback = user_callback + self.add_item(user_select) + current_row += 1 + + role_select = disnake.ui.RoleSelect( + placeholder="Filtrer par rôle auteur...", + min_values=0, + max_values=1, + custom_id="reports_filter_role", + row=current_row + ) + async def role_callback(inter: disnake.MessageInteraction): + if inter.values: + val = inter.values[0] + if isinstance(val, str): + self.selected_role = inter.guild.get_role(int(val)) or val + else: + self.selected_role = val + else: + self.selected_role = None + self.update_components() + await inter.response.edit_message(components=self.get_components_v2()) + role_select.callback = role_callback + self.add_item(role_select) + current_row += 1 + + # Row 4: Boutons Recherche / Reset + search_btn = disnake.ui.Button( + label="Rechercher", + style=disnake.ButtonStyle.green, + emoji="🔍", + custom_id="reports_filter_search", + row=current_row + ) + async def search_callback(inter: disnake.MessageInteraction): + await inter.response.defer(ephemeral=True) + + author_filter = None + if not self.has_global_access: + author_filter = self.viewer_id + elif self.selected_user: + author_filter = self.selected_user.id if hasattr(self.selected_user, "id") else int(self.selected_user) + + role_filter_id = None + if self.selected_role: + role_filter_id = self.selected_role.id if hasattr(self.selected_role, "id") else int(self.selected_role) + + reports = self.cog.db.get_reports( + guild_id=self.guild_id, + author_id=author_filter, + subject=self.selected_subject, + status=self.selected_status, + role_id=role_filter_id + ) + + if not reports: + await inter.followup.send("❌ Aucun rapport ne correspond à votre recherche.", ephemeral=True) + return + + browser = ReportsBrowserView(self.cog, self.interaction, reports, filter_view=self) + await inter.edit_original_message(content=None, embed=browser.get_embed(), view=browser) + + search_btn.callback = search_callback + self.add_item(search_btn) + + reset_btn = disnake.ui.Button( + label="Réinitialiser", + style=disnake.ButtonStyle.red, + emoji="❌", + custom_id="reports_filter_reset", + row=current_row + ) + async def reset_callback(inter: disnake.MessageInteraction): + self.selected_user = None + self.selected_role = None + self.selected_status = None + self.selected_subject = None + self.update_components() + await inter.response.edit_message(components=self.get_components_v2()) + reset_btn.callback = reset_callback + self.add_item(reset_btn) + + def get_components_v2(self): + sections = [ + disnake.ui.Section( + "# 🔍 Recherche & Filtres de Rapports", + accessory=disnake.ui.Thumbnail(self.interaction.author.display_avatar.url) + ), + disnake.ui.Separator(divider=True) + ] + + filters_desc = [] + if self.selected_status: + filters_desc.append(f"🚦 **Statut** : {self.selected_status}") + else: + filters_desc.append("🚦 **Statut** : Tous") + + if self.has_global_access: + if self.selected_user: + mention = self.selected_user.mention if hasattr(self.selected_user, "mention") else f"<@{self.selected_user}>" + filters_desc.append(f"👤 **Auteur** : {mention}") + else: + filters_desc.append("👤 **Auteur** : Tous") + + if self.selected_role: + mention = self.selected_role.mention if hasattr(self.selected_role, "mention") else f"<@&{self.selected_role}>" + filters_desc.append(f"🛡️ **Rôle Auteur** : {mention}") + else: + filters_desc.append("🛡️ **Rôle Auteur** : Tous") + else: + filters_desc.append(f"👤 **Auteur** : {self.interaction.author.mention} *(Accès restreint à vos propres rapports)*") + + if self.selected_subject: + filters_desc.append(f"📁 **Objet / Catégorie** : `{self.selected_subject}`") + else: + filters_desc.append("📁 **Objet / Catégorie** : Tous") + + sections.append(disnake.ui.TextDisplay(content="\n".join(filters_desc))) + sections.append(disnake.ui.Separator(divider=True)) + + return [disnake.ui.Container(*sections)] + self.children + + async def interaction_check(self, interaction: disnake.MessageInteraction) -> bool: + if interaction.author.id != self.viewer_id: + await interaction.response.send_message("❌ Vous ne pouvez pas interagir avec ce menu.", ephemeral=True) + return False + return True + +class ReportsBrowserView(disnake.ui.View): + def __init__(self, cog, interaction, reports, filter_view=None, current_index=0): + super().__init__(timeout=600) + self.cog = cog + self.interaction = interaction + self.guild_id = interaction.guild.id + self.viewer_id = interaction.author.id + self.is_admin = interaction.author.guild_permissions.administrator + self.reports = reports + self.filter_view = filter_view + self.current_index = current_index + self.page_index = current_index // 25 + self.update_components() + + def update_components(self): + self.clear_items() + + if not self.reports: + return + + report = self.reports[self.current_index] + + # Row 0: StringSelectMenu de selection du rapport + total_pages = (len(self.reports) + 24) // 25 + start = self.page_index * 25 + end = min(start + 25, len(self.reports)) + + options = [] + for i in range(start, end): + rep = self.reports[i] + title = rep['title'] + if len(title) > 60: + title = title[:57] + "..." + label = f"#{rep['id']} - {title}" + description = f"Statut: {rep['status']} | Objet: {rep['subject']}" + if len(description) > 100: + description = description[:97] + "..." + options.append( + disnake.SelectOption( + label=label, + description=description, + value=str(i), + default=(i == self.current_index) + ) + ) + + select_menu = disnake.ui.StringSelect( + placeholder="Sélectionnez un rapport à lire...", + options=options, + custom_id="reports_browser_select", + row=0 + ) + + async def select_callback(inter: disnake.MessageInteraction): + self.current_index = int(inter.values[0]) + self.page_index = self.current_index // 25 + self.update_components() + await inter.response.edit_message(embed=self.get_embed(), view=self) + + select_menu.callback = select_callback + self.add_item(select_menu) + + # Row 1: Boutons de pagination de pages (si > 25 rapports) + if total_pages > 1: + prev_btn = disnake.ui.Button( + label="⬅️ Page précédente", + style=disnake.ButtonStyle.secondary, + disabled=(self.page_index == 0), + custom_id="reports_browser_prev_page", + row=1 + ) + next_btn = disnake.ui.Button( + label="➡️ Page suivante", + style=disnake.ButtonStyle.secondary, + disabled=(self.page_index >= total_pages - 1), + custom_id="reports_browser_next_page", + row=1 + ) + + async def prev_callback(inter: disnake.MessageInteraction): + self.page_index -= 1 + self.current_index = self.page_index * 25 + self.update_components() + await inter.response.edit_message(embed=self.get_embed(), view=self) + + async def next_callback(inter: disnake.MessageInteraction): + self.page_index += 1 + self.current_index = self.page_index * 25 + self.update_components() + await inter.response.edit_message(embed=self.get_embed(), view=self) + + prev_btn.callback = prev_callback + next_btn.callback = next_callback + self.add_item(prev_btn) + self.add_item(next_btn) + + # Row 2: Boutons d'Action (Modifier, Supprimer, Retour) + is_author = (self.viewer_id == report['author_id']) + is_blocked = report['status'] in ['Traité', 'Classé'] + + edit_disabled = not is_author or is_blocked + delete_disabled = not (is_author or self.is_admin) or is_blocked + + edit_btn = disnake.ui.Button( + label="Modifier", + style=disnake.ButtonStyle.primary, + emoji="✏️", + disabled=edit_disabled, + custom_id="reports_browser_edit", + row=2 + ) + delete_btn = disnake.ui.Button( + label="Supprimer", + style=disnake.ButtonStyle.danger, + emoji="🗑️", + disabled=delete_disabled, + custom_id="reports_browser_delete", + row=2 + ) + back_btn = disnake.ui.Button( + label="Retour aux filtres", + style=disnake.ButtonStyle.secondary, + emoji="⬅️", + custom_id="reports_browser_back", + row=2 + ) + + async def edit_callback(inter: disnake.MessageInteraction): + report_fresh = self.cog.db.get_report(report['id']) + if not report_fresh: + return await inter.response.send_message("❌ Ce rapport n'existe plus.", ephemeral=True) + if report_fresh['status'] in ['Traité', 'Classé']: + return await inter.response.send_message("❌ Ce rapport a été traité ou classé et ne peut plus être modifié.", ephemeral=True) + if inter.author.id != report_fresh['author_id']: + return await inter.response.send_message("❌ Seul l'auteur du rapport peut le modifier.", ephemeral=True) + + modal = EditReportModal(self.cog, report_fresh['id'], self) + await inter.response.send_modal(modal) + + async def delete_callback(inter: disnake.MessageInteraction): + report_fresh = self.cog.db.get_report(report['id']) + if not report_fresh: + return await inter.response.send_message("❌ Ce rapport n'existe plus.", ephemeral=True) + if report_fresh['status'] in ['Traité', 'Classé']: + return await inter.response.send_message("❌ Ce rapport a été traité ou classé et ne peut pas être supprimé.", ephemeral=True) + is_auth = (inter.author.id == report_fresh['author_id']) + if not (is_auth or inter.author.guild_permissions.administrator): + return await inter.response.send_message("❌ Vous n'avez pas la permission de supprimer ce rapport.", ephemeral=True) + + self.cog.db.delete_report(report['id']) + del self.reports[self.current_index] + + if not self.reports: + if self.filter_view: + self.filter_view.update_components() + await inter.response.edit_message(content=None, embed=None, components=self.filter_view.get_components_v2()) + else: + await inter.response.edit_message(content="✅ Le rapport a été supprimé. Plus aucun rapport dans cette recherche.", embed=None, view=None) + return + + if self.current_index >= len(self.reports): + self.current_index = len(self.reports) - 1 + self.page_index = self.current_index // 25 + + self.update_components() + await inter.response.edit_message(embed=self.get_embed(), view=self) + await inter.followup.send("✅ Rapport supprimé avec succès.", ephemeral=True) + + async def back_callback(inter: disnake.MessageInteraction): + if self.filter_view: + self.filter_view.update_components() + await inter.response.edit_message(embed=None, components=self.filter_view.get_components_v2()) + else: + new_filter_view = ReportsFilterView(self.cog, self.interaction) + await inter.response.edit_message(embed=None, components=new_filter_view.get_components_v2()) + + edit_btn.callback = edit_callback + delete_btn.callback = delete_callback + back_btn.callback = back_callback + self.add_item(edit_btn) + self.add_item(delete_btn) + self.add_item(back_btn) + + # Row 3: Menu Déroulant "Changer l'état" (Admin uniquement) + if self.is_admin: + status_options = [ + disnake.SelectOption(label="En attente", emoji="🟡", value="En attente", default=(report['status'] == "En attente")), + disnake.SelectOption(label="Traité", emoji="🟢", value="Traité", default=(report['status'] == "Traité")), + disnake.SelectOption(label="Classé", emoji="🔴", value="Classé", default=(report['status'] == "Classé")) + ] + status_menu = disnake.ui.StringSelect( + placeholder="Changer le statut...", + options=status_options, + custom_id="reports_browser_status", + row=3 + ) + + async def status_callback(inter: disnake.MessageInteraction): + if not inter.author.guild_permissions.administrator: + return await inter.response.send_message("❌ Seuls les administrateurs peuvent modifier le statut.", ephemeral=True) + + new_status = inter.values[0] + self.cog.db.update_report_status(report['id'], new_status) + + updated_rep = self.cog.db.get_report(report['id']) + self.reports[self.current_index] = updated_rep + + self.update_components() + await inter.response.edit_message(embed=self.get_embed(), view=self) + await inter.followup.send(f"✅ Statut du rapport #{report['id']} mis à jour vers : **{new_status}**.", ephemeral=True) + + status_menu.callback = status_callback + self.add_item(status_menu) + + def get_embed(self): + if not self.reports: + return disnake.Embed( + title="Aucun rapport disponible", + description="Il n'y a aucun rapport correspondant aux critères.", + color=disnake.Color.red() + ) + + report = self.reports[self.current_index] + + status_colors = { + "En attente": 0xF1C40F, + "Traité": 0x2ECC71, + "Classé": 0x95A5A6 + } + color = status_colors.get(report['status'], 0x3498DB) + + status_emojis = { + "En attente": "🟡 En attente", + "Traité": "🟢 Traité", + "Classé": "🔴 Classé" + } + status_display = status_emojis.get(report['status'], report['status']) + + embed = disnake.Embed( + title=f"Rapport #{report['id']} : {report['title']}", + description=report['content'], + color=color + ) + embed.add_field(name="📁 Objet / Catégorie", value=report['subject'], inline=True) + embed.add_field(name="🚦 Statut", value=status_display, inline=True) + embed.add_field(name="👤 Auteur", value=f"<@{report['author_id']}>", inline=True) + + try: + created_dt = datetime.fromisoformat(report['created_at']) + created_ts = int(created_dt.timestamp()) + embed.add_field(name="📅 Date de création", value=f" ()", inline=False) + except Exception: + embed.add_field(name="📅 Date de création", value=report['created_at'], inline=False) + + if report['last_edited_at']: + try: + edited_dt = datetime.fromisoformat(report['last_edited_at']) + edited_str = edited_dt.strftime("%d/%m/%Y à %H:%M") + embed.set_footer(text=f"Dernière action le {edited_str}") + except Exception: + embed.set_footer(text=f"Dernière action le {report['last_edited_at']}") + else: + embed.set_footer(text="Rapports Hybrides") + + return embed + + async def interaction_check(self, interaction: disnake.MessageInteraction) -> bool: + if interaction.author.id != self.viewer_id: + await interaction.response.send_message("❌ Vous ne pouvez pas interagir avec ce menu.", ephemeral=True) + return False + return True + +class Rapports(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + self.db_path = os.path.join(self.base_dir, "config.db") + self.db = ReportsDB(self.db_path) + self.retention_loop.start() + + def cog_unload(self): + self.retention_loop.cancel() + + @tasks.loop(hours=24) + async def retention_loop(self): + try: + deleted_count = self.db.purge_automatic() + if deleted_count > 0: + kuby_logger.info(f"🧹 [Rétention] Nettoyage automatique effectué : {deleted_count} rapports de plus de 90 jours (non classés) supprimés.") + except Exception as e: + kuby_logger.error(f"❌ [Rétention] Erreur lors du nettoyage automatique: {e}", exc_info=True) + + @retention_loop.before_loop + async def before_retention_loop(self): + await self.bot.wait_until_ready() + + @commands.slash_command(name="config-system", description="Configuration du module de rapports (Admin uniquement)") + @commands.has_permissions(administrator=True) + async def config_system(self, interaction: disnake.ApplicationCommandInteraction, role_viewer: disnake.Role = None): + guild_id = interaction.guild.id + + if role_viewer: + self.db.set_config(guild_id, role_viewer.id) + viewer_text = f"{role_viewer.mention}" + else: + viewer_role_id = self.db.get_config(guild_id) + viewer_text = f"<@&{viewer_role_id}>" if viewer_role_id else "Aucun rôle configuré" + + embed = disnake.Embed( + title="⚙️ Configuration du Système de Rapports", + description="Gérez les autorisations de consultation globale et effectuez la maintenance.", + color=disnake.Color.blue() + ) + embed.add_field( + name="👁️ Rôle Lecteur (Viewer)", + value=f"{viewer_text}\n*(Les membres ayant ce rôle peuvent consulter tous les rapports du serveur)*", + inline=False + ) + embed.add_field( + name="🧹 Maintenance & Rétention", + value="Les rapports vieux de plus de **90 jours** sont automatiquement nettoyés chaque jour, sauf s'ils sont **Classés**.\n\nVous pouvez également lancer une purge manuelle pour supprimer les rapports de plus de **7 jours** non classés.", + inline=False + ) + embed.set_footer(text="Système de Rapports Hybride") + + view = ReportsConfigView(self, guild_id) + await interaction.response.send_message(embed=embed, view=view, ephemeral=True) + + @commands.slash_command(name="rapport", description="Ouvrir le formulaire de saisie de rapport") + async def rapport(self, interaction: disnake.ApplicationCommandInteraction): + await interaction.response.send_modal(ReportModal(self)) + + @commands.slash_command(name="consulter_rapport", description="Consulter les rapports du serveur via une interface interactive") + async def consulter_rapport( + self, + interaction: disnake.ApplicationCommandInteraction, + utilisateur: disnake.Member = None, + categorie: str = None, + statut: str = commands.Param(None, choices=["En attente", "Traité", "Classé"]), + role: disnake.Role = None + ): + filter_view = ReportsFilterView(self, interaction) + if utilisateur: + filter_view.selected_user = utilisateur + if categorie: + filter_view.selected_subject = categorie + if statut: + filter_view.selected_status = statut + if role: + filter_view.selected_role = role + + filter_view.update_components() + await interaction.response.send_message(components=filter_view.get_components_v2(), ephemeral=True) + +def setup(bot): + bot.add_cog(Rapports(bot)) diff --git a/commandes/ticket/__init__.py b/commandes/ticket/__init__.py index def4775..0f632c3 100644 --- a/commandes/ticket/__init__.py +++ b/commandes/ticket/__init__.py @@ -3159,14 +3159,17 @@ class TicketCommands(commands.Cog): return # Accept both ticket_* / catv2_* / close_* custom_ids - if not ( - str(custom_id).startswith("ticket_") + + + if not (str(custom_id).startswith("ticket_") or str(custom_id).startswith("catv2_") or str(custom_id).startswith("cat_") - or str(custom_id) in {"close_confirm", "close_cancel", "delay_cancel"} - ): + or str(custom_id).startswith("reports_filter_") + or str(custom_id) in {"close_confirm", "close_cancel", "delay_cancel"}): return + + if not inter.guild: return diff --git a/data/convocation_backups.json b/data/convocation_backups.json index b1f3572..f2e450d 100644 --- a/data/convocation_backups.json +++ b/data/convocation_backups.json @@ -1,5 +1,13 @@ { "1369669999345537145_1337733859814543410": [ 1370452086193655840 - ] + ], + "1369669999345537145_1508391404831903836": { + "roles": [], + "overrides": { + "1510558364420149310": { + "view_channel": false + } + } + } } \ No newline at end of file