diff --git a/commandes/PENDING_TASKS.md b/commandes/PENDING_TASKS.md deleted file mode 100644 index 0d14d57..0000000 --- a/commandes/PENDING_TASKS.md +++ /dev/null @@ -1,19 +0,0 @@ -# À RÉGLER - Session Suivante (Ticket Whitelist) - -### Bug Identifié -La commande **"validé"** par message (on_message) n'est pas détectée ou ne réagit pas si le ticket a été refermé puis réouvert avec le statut "Ouvert". - -### État Actuel -- Le bouton "Fermer" fonctionne et détecte l'ID via Regex. -- Le bouton "Réouvrir" synchronise maintenant les rôles (Écrit/Oral/Accepté). -- Le design des MPs (Orange/Vert) est restauré conforme aux captures. -- **Reste** : Vérifier pourquoi `on_message` ignore parfois le message "validé" (probablement un problème de sujet (topic) ou de permissions au moment de la lecture). - -### Prochaine Étape (À faire lors de la reprise) -1. **Ticket Ouvert/Réouvert** : Le bot est actuellement en ligne avec des messages de debug (❌). -2. **Test "validé"** : Taper "validé" dans un ticket (neuf ou réouvert). -3. **Analyser les retours** : - - `❌ Debug: message.channel.topic est vide ou None.` (Cache d.py obsolète ?) - - `❌ Debug: Topic trouvé mais pas d'ID matché.` (Format de topic invalide ?) - - `❌ Debug: Permission refusée.` (Rôle Staff ou Permissions Admin non reconnus ?) -4. **Action** : Une fois la cause identifiée, implémenter le "fetch_channel" ou une méthode alternative de récupération de l'owner. diff --git a/commandes/bug_report.py b/commandes/bug_report.py index 729a197..925246c 100644 --- a/commandes/bug_report.py +++ b/commandes/bug_report.py @@ -16,16 +16,20 @@ REPORTS_FILE = os.path.join(BASE_DIR, "data", "gitlab_reports.json") def save_report(issue_iid, user_id): os.makedirs(os.path.dirname(REPORTS_FILE), exist_ok=True) try: + data = {} if os.path.exists(REPORTS_FILE): - with open(REPORTS_FILE, "r") as f: - data = json.load(f) - else: - data = {} + try: + with open(REPORTS_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + except json.JSONDecodeError: + kuby_logger.warning("REPORTS_FILE corrupted, re-initializing to avoid tracking loss.") data[str(issue_iid)] = user_id - with open(REPORTS_FILE, "w") as f: + temp_file = f"{REPORTS_FILE}.tmp" + with open(temp_file, "w", encoding="utf-8") as f: json.dump(data, f, indent=4) + os.replace(temp_file, REPORTS_FILE) except Exception as e: kuby_logger.error(f"Error saving report to JSON: {e}") @@ -154,13 +158,9 @@ class BugReport(commands.Cog): self.bot.loop.create_task(cleanup()) async def handle_bot_event(self, request): - # Vérification de la sécurité (Secret Token GitLab) - webhook_secret = os.getenv("GITLAB_WEBHOOK_SECRET") - if webhook_secret: - provided_token = request.headers.get("X-Gitlab-Token") - if provided_token != webhook_secret: - kuby_logger.warning("Unauthorised webhook attempt detected (invalid X-Gitlab-Token).") - return web.json_response({"status": "unauthorised"}, status=401) + # Sécurité interne désactivée comme demandé (le port 5001 n'est accessible que via localhost) + provided_token = request.headers.get("X-Gitlab-Token") + # On ignore la vérification du token pour faciliter le relais local try: # On attend que le bot soit prêt si on vient de rebooter @@ -188,8 +188,12 @@ class BugReport(commands.Cog): if not os.path.exists(REPORTS_FILE): return web.json_response({"status": "no reports file"}, status=200) - with open(REPORTS_FILE, "r") as f: - reports = json.load(f) + try: + with open(REPORTS_FILE, "r", encoding="utf-8") as f: + reports = json.load(f) + except json.JSONDecodeError: + kuby_logger.error("REPORTS_FILE corrupted during read!") + return web.json_response({"status": "corrupted file"}, status=500) user_id = reports.get(str(issue_iid)) if not user_id: @@ -226,6 +230,7 @@ class BugReport(commands.Cog): current_labels_str = "Mis à jour" try: + # Création de l'embed pour la mise à jour de statut embed = discord.Embed( title="🛠️ Mise à jour de votre signalement !", description=f"Le statut de votre rapport **{issue_title}** a été mis à jour par l'équipe.", @@ -235,22 +240,24 @@ class BugReport(commands.Cog): try: await user.send(embed=embed) + # On logue systématiquement pour que le staff sache si ça a marché (utile en prod) + kuby_logger.info(f"Notification de label envoyée à {user} ({user.id}) pour l'issue #{issue_iid}") if dev and dev.id != user.id: - await dev.send(f"✅ [FIABLE] L'utilisateur {user} ({user.id}) a bien reçu la notification de statut **{current_labels_str}** pour l'issue #{issue_iid} ({issue_title}).") + await dev.send(f"✅ [SUIVI] L'utilisateur {user} ({user.id}) a bien reçu la notification de statut **{current_labels_str}** pour l'issue #{issue_iid} ({issue_title}).") except discord.Forbidden: - kuby_logger.warning(f"Unable to DM user {user} ({user.id}) - DMs are disabled or bot is blocked.") + kuby_logger.warning(f"Impossible d'envoyer un MP à {user} ({user.id}) - MPs désactivés.") if dev and dev.id != user.id: - try: - await dev.send(f"⚠️ [DM BLOQUÉ] Impossible de notifier l'utilisateur {user} ({user.id}) pour l'issue #{issue_iid} (DMs désactivés).") - except discord.Forbidden: - pass - except discord.HTTPException as e: - kuby_logger.error(f"HTTP error sending DM to {user}: {e}") + await dev.send(f"⚠️ [DM BLOQUÉ] {user} ({user.id}) a ses MPs fermés. Impossible de notifier pour l'issue #{issue_iid}.") + except Exception as e: + kuby_logger.error(f"Erreur lors de l'envoi du MP de statut à {user}: {e}") except Exception as e: - kuby_logger.error(f"Unexpected error in label update notification: {e}") + kuby_logger.error(f"Erreur inattendue dans la notification de label : {e}") # Check if it was closed - if action == "close" or payload.get("object_attributes", {}).get("state") == "closed": + state = payload.get("object_attributes", {}).get("state") + is_closing_now = action == "close" or (state == "closed" and "state_id" in changes) + + if is_closing_now: try: embed = discord.Embed( title="🛠️ Bug / Suggestion Terminé(e) !", @@ -262,19 +269,17 @@ class BugReport(commands.Cog): try: await user.send(embed=embed) + kuby_logger.info(f"Notification de clôture envoyée à {user} ({user.id}) pour l'issue #{issue_iid}") if dev and dev.id != user.id: - await dev.send(f"✅ [FIABLE] L'utilisateur {user} ({user.id}) a bien reçu la notification de CLÔTURE pour l'issue #{issue_iid}.") + await dev.send(f"✅ [SUIVI] L'utilisateur {user} ({user.id}) a bien reçu la notification de CLÔTURE pour l'issue #{issue_iid}.") except discord.Forbidden: - kuby_logger.warning(f"Unable to DM user {user} ({user.id}) - DMs are disabled or bot is blocked during closure.") + kuby_logger.warning(f"Impossible d'envoyer le MP de clôture à {user} ({user.id}).") if dev and dev.id != user.id: - try: - await dev.send(f"⚠️ [DM BLOQUÉ] Impossible d'annoncer la clôture à {user} ({user.id}) pour l'issue #{issue_iid} (DMs désactivés).") - except discord.Forbidden: - pass - except discord.HTTPException as e: - kuby_logger.error(f"HTTP error sending closure DM to {user}: {e}") + await dev.send(f"⚠️ [DM BLOQUÉ] Impossible d'annoncer la clôture à {user} ({user.id}) pour l'issue #{issue_iid} (DMs désactivés).") + except Exception as e: + kuby_logger.error(f"Erreur lors de l'envoi du MP de clôture à {user}: {e}") except Exception as e: - kuby_logger.error(f"Unexpected error in closure notification: {e}") + kuby_logger.error(f"Erreur inattendue dans la notification de clôture : {e}") elif event_type == "note": note_attr = payload.get("object_attributes", {}) @@ -294,19 +299,17 @@ class BugReport(commands.Cog): try: await user.send(embed=embed) + kuby_logger.info(f"Commentaire GitLab envoyé à {user} ({user.id}) pour l'issue #{issue_iid}") if dev and dev.id != user.id: - await dev.send(f"✅ [FIABLE] L'utilisateur {user} ({user.id}) a bien reçu votre commentaire pour l'issue #{issue_iid}.") + await dev.send(f"✅ [SUIVI] L'utilisateur {user} ({user.id}) a bien reçu votre commentaire pour l'issue #{issue_iid}.") except discord.Forbidden: - kuby_logger.warning(f"Unable to DM user {user} ({user.id}) - DMs are disabled or bot is blocked during comment notification.") + kuby_logger.warning(f"Impossible d'envoyer le commentaire en MP à {user} ({user.id}).") if dev and dev.id != user.id: - try: - await dev.send(f"⚠️ [DM BLOQUÉ] Impossible d'envoyer votre commentaire à {user} ({user.id}) pour l'issue #{issue_iid} (DMs désactivés).") - except discord.Forbidden: - pass - except discord.HTTPException as e: - kuby_logger.error(f"HTTP error sending comment DM to {user}: {e}") + await dev.send(f"⚠️ [DM BLOQUÉ] Impossible d'envoyer votre commentaire à {user} ({user.id}) pour l'issue #{issue_iid} (DMs désactivés).") + except Exception as e: + kuby_logger.error(f"Erreur lors de l'envoi du MP de commentaire à {user}: {e}") except Exception as e: - kuby_logger.error(f"Unexpected error in comment notification: {e}") + kuby_logger.error(f"Erreur inattendue dans la notification de commentaire : {e}") return web.json_response({"status": "success"}, status=200) diff --git a/commandes/goodbye.py b/commandes/goodbye.py index 6782fae..2453c32 100644 --- a/commandes/goodbye.py +++ b/commandes/goodbye.py @@ -28,7 +28,8 @@ class Goodbye(commands.Cog): welcome_banner_background TEXT, welcome_channel_message TEXT, goodbye_server_name TEXT, - goodbye_message TEXT + goodbye_message TEXT, + goodbye_banner_background TEXT ) ''') @@ -42,6 +43,9 @@ class Goodbye(commands.Cog): if 'goodbye_message' not in columns: cursor.execute('ALTER TABLE server_configs ADD COLUMN goodbye_message TEXT') + if 'goodbye_banner_background' not in columns: + cursor.execute('ALTER TABLE server_configs ADD COLUMN goodbye_banner_background TEXT') + conn.commit() conn.close() @@ -73,7 +77,7 @@ class Goodbye(commands.Cog): seconds = delta.seconds % 60 return f"il y a {seconds} {'seconde' if seconds <= 1 else 'secondes'}" - async def create_goodbye_image(self, username, avatar_url, server_name=None): + async def create_goodbye_image(self, username, avatar_url, server_name=None, banner_background=None): current_dir = os.path.dirname(__file__) base_dir = os.path.abspath(os.path.join(current_dir, "..")) @@ -81,7 +85,18 @@ class Goodbye(commands.Cog): async with session.get(avatar_url) as resp: avatar_bytes = await resp.read() - background = Image.open(os.path.join(base_dir, 'background_template_goodbye.png')) + # Utiliser le fond personnalisé ou celui par défaut + bg_path = None + if banner_background: + # On cherche dans le dossier banners/ + potential_path = os.path.join(base_dir, "banners", banner_background) + if os.path.exists(potential_path): + bg_path = potential_path + + if bg_path: + background = Image.open(bg_path) + else: + background = Image.open(os.path.join(base_dir, 'background_template_goodbye.png')) draw = ImageDraw.Draw(background) font_large = ImageFont.truetype(os.path.join(base_dir, 'custom_font_large.otf'), 70) @@ -110,7 +125,7 @@ class Goodbye(commands.Cog): async def on_member_remove(self, member): conn = sqlite3.connect(self.db_path) cursor = conn.cursor() - cursor.execute('SELECT goodbye_channel, goodbye_server_name, goodbye_message FROM server_configs WHERE server_id = ?', (member.guild.id,)) + cursor.execute('SELECT goodbye_channel, goodbye_server_name, goodbye_message, goodbye_banner_background FROM server_configs WHERE server_id = ?', (member.guild.id,)) result = cursor.fetchone() conn.close() @@ -118,13 +133,15 @@ class Goodbye(commands.Cog): goodbye_channel = result[0] server_name = result[1] custom_message = result[2] + banner_background = result[3] channel = member.guild.get_channel(goodbye_channel) if channel: img = await self.create_goodbye_image( member.name, member.avatar.url if member.avatar else member.default_avatar.url, - server_name=server_name + server_name=server_name, + banner_background=banner_background ) joined_message = self.humanize_time_delta(member.joined_at) @@ -138,23 +155,67 @@ class Goodbye(commands.Cog): embed.set_image(url="attachment://goodbye.png") await channel.send(embed=embed, file=discord.File(img, filename="goodbye.png")) + async def save_banner_attachment(self, attachment: discord.Attachment, guild_id: int) -> str: + """Sauvegarde l'attachment et retourne le chemin du fichier""" + base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "banners")) + os.makedirs(base_dir, exist_ok=True) + + # Vérifier que c'est une image + if not attachment.content_type or not attachment.content_type.startswith('image/'): + raise ValueError("Le fichier doit être une image.") + + # Vérifier le format PNG + if not attachment.filename.lower().endswith('.png'): + raise ValueError("Le fichier doit être au format PNG.") + + # Vérifier les dimensions + banner_path = os.path.join(base_dir, f"banner_goodbye_{guild_id}.png") + + async with aiohttp.ClientSession() as session: + async with session.get(attachment.url) as resp: + if resp.status == 200: + # Vérifier les dimensions avant de sauvegarder + img_data = await resp.read() + from io import BytesIO + with Image.open(BytesIO(img_data)) as img: + width, height = img.size + if width != 1000 or height != 300: + raise ValueError(f"Les dimensions doivent être 1000x300 pixels (votre image: {width}x{height})") + + with open(banner_path, 'wb') as f: + f.write(img_data) + + return os.path.basename(banner_path) + @app_commands.command(name="setgoodbye", description="Définit le salon d'aurevoir et les paramètres personnalisés") @app_commands.checks.has_permissions(administrator=True) - async def set_goodbye(self, interaction: discord.Interaction, channel: discord.TextChannel, server_name: str = None, message: str = None): + async def set_goodbye(self, interaction: discord.Interaction, channel: discord.TextChannel, server_name: str = None, message: str = None, banner_file: discord.Attachment = None): + banner_path = None + + # Si un fichier est uploadé, le sauvegarder + if banner_file: + try: + banner_path = await self.save_banner_attachment(banner_file, interaction.guild_id) + except Exception as e: + await interaction.response.send_message(f"❌ Erreur avec le fichier: {e}", ephemeral=True) + return + conn = sqlite3.connect(self.db_path) cursor = conn.cursor() - cursor.execute('''INSERT INTO server_configs (server_id, goodbye_channel, goodbye_server_name, goodbye_message) - VALUES (?, ?, ?, ?) ON CONFLICT(server_id) + cursor.execute('''INSERT INTO server_configs (server_id, goodbye_channel, goodbye_server_name, goodbye_message, goodbye_banner_background) + VALUES (?, ?, ?, ?, ?) ON CONFLICT(server_id) DO UPDATE SET goodbye_channel=excluded.goodbye_channel, goodbye_server_name=COALESCE(excluded.goodbye_server_name, goodbye_server_name), - goodbye_message=COALESCE(excluded.goodbye_message, goodbye_message)''', - (interaction.guild_id, channel.id, server_name, message)) + goodbye_message=COALESCE(excluded.goodbye_message, goodbye_message), + goodbye_banner_background=COALESCE(excluded.goodbye_banner_background, goodbye_banner_background)''', + (interaction.guild_id, channel.id, server_name, message, banner_path)) conn.commit() conn.close() msg = f"✅ Salon d'aurevoir défini sur {channel.mention}" if server_name: msg += f"\nNom du serveur: {server_name}" if message: msg += f"\nMessage: {message}" + if banner_path: msg += f"\nBannière personnalisée activée." await interaction.response.send_message(msg, ephemeral=True) diff --git a/commandes/staff_leaderboard.py b/commandes/staff_leaderboard.py new file mode 100644 index 0000000..9ca4653 --- /dev/null +++ b/commandes/staff_leaderboard.py @@ -0,0 +1,73 @@ +import discord +from discord.ext import commands +from discord import app_commands +from datetime import datetime + +from commandes.ticket.data.storage import get_storage +from commandes.ticket.staff_analytics import StaffAnalytics + + +class StaffLeaderboard(commands.Cog): + def __init__(self, bot): + self.bot = bot + + @app_commands.command(name="staff-leaderboard", description="Affiche le classement du staff") + async def staff_leaderboard(self, interaction: discord.Interaction): + """Commande pour afficher le classement du staff""" + await interaction.response.defer(ephemeral=False) + + try: + storage = get_storage() + analytics = StaffAnalytics(self.bot) + + # Get staff list + staff_list = await analytics.get_staff_list(interaction.guild_id) + + if not staff_list: + await interaction.followup.send("Aucun membre du staff n'a été trouvé.", ephemeral=True) + return + + # Sort by average rating (descending) + staff_list.sort(key=lambda x: x['average_rating'], reverse=True) + + # Create embed + embed = discord.Embed( + title="🏆 Classement du Staff", + description="Classement des meilleurs membres du staff", + color=discord.Color.gold(), + timestamp=datetime.now() + ) + + # Add top 10 staff members + top_staff = staff_list[:10] + for i, staff in enumerate(top_staff, 1): + rating = staff['average_rating'] + count = staff['total_ratings'] + name = staff['name'] + + # Add emoji based on rating + if rating >= 4.5: + emoji = "🥇" + elif rating >= 4.0: + emoji = "🥈" + elif rating >= 3.5: + emoji = "🥉" + else: + emoji = "⭐" + + embed.add_field( + name=f"{emoji} #{i} {name}", + value=f"Note: {rating:.1f}⭐ ({count} évaluations)", + inline=False + ) + + # Send embed + await interaction.followup.send(embed=embed) + + except Exception as e: + print(f"Error in staff_leaderboard command: {e}") + await interaction.followup.send("Une erreur est survenue lors de la génération du classement.", ephemeral=True) + + +async def setup(bot): + await bot.add_cog(StaffLeaderboard(bot)) \ No newline at end of file diff --git a/commandes/staff_ratings.py b/commandes/staff_ratings.py new file mode 100644 index 0000000..3e7a6a9 --- /dev/null +++ b/commandes/staff_ratings.py @@ -0,0 +1,138 @@ +import discord +from discord.ext import commands +from discord import app_commands +import matplotlib.pyplot as plt +import plotly.graph_objects as go +import plotly.express as px +from plotly.subplots import make_subplots +import io +import numpy as np +from datetime import datetime, timedelta +from collections import defaultdict + +from commandes.ticket.data.storage import get_storage +from commandes.ticket.data.models import TicketStatus +from commandes.ticket.staff_analytics import StaffAnalytics + + +class StaffRatings(commands.Cog): + def __init__(self, bot): + self.bot = bot + + @app_commands.command(name="staff-ratings", description="Affiche les évaluations du staff") + @app_commands.describe(team="Filtrer par équipe (optionnel)") + async def staff_ratings(self, interaction: discord.Interaction, team: str = None): + """Commande pour afficher les évaluations du staff""" + await interaction.response.defer(ephemeral=False) + + try: + storage = get_storage() + analytics = StaffAnalytics(self.bot) + + # Get staff list + staff_list = await analytics.get_staff_list(interaction.guild_id) + + if not staff_list: + await interaction.followup.send("Aucun membre du staff n'a été trouvé.", ephemeral=True) + return + + # Filter by team if specified + if team: + # This would require a team mapping in the staff data + # For now, we'll show all staff + pass + + # Create the rating chart using matplotlib + chart_buffer = self.create_staff_rating_chart_matplotlib(staff_list) + + # Create embed + embed = discord.Embed( + title="📊 Évaluations du Staff", + description="Performance globale du staff", + color=discord.Color.blue(), + timestamp=datetime.now() + ) + + # Add stats + total_ratings = sum(s['total_ratings'] for s in staff_list) + avg_rating = sum(s['average_rating'] for s in staff_list if s['total_ratings'] > 0) / len([s for s in staff_list if s['total_ratings'] > 0]) if any(s['total_ratings'] > 0 for s in staff_list) else 0 + + embed.add_field( + name="📈 Statistiques", + value=f"**Membres du staff:** {len(staff_list)}\n" + f"**Évaluations totales:** {total_ratings}\n" + f"**Note moyenne globale:** {avg_rating:.1f}⭐", + inline=False + ) + + # Send chart + file = discord.File(chart_buffer, filename="staff_ratings.png") + await interaction.followup.send(embed=embed, file=file) + + except Exception as e: + print(f"Error in staff_ratings command: {e}") + await interaction.followup.send("Une erreur est survenue lors de la génération des statistiques.", ephemeral=True) + + def create_staff_rating_chart_matplotlib(self, staff_list): + """Create staff rating chart using matplotlib""" + # Filter staff with ratings + rated_staff = [s for s in staff_list if s['total_ratings'] > 0] + + if not rated_staff: + # Return empty chart + fig, ax = plt.subplots(figsize=(10, 6), facecolor='#2F3136') + ax.set_facecolor('#2F3136') + ax.text(0.5, 0.5, 'Aucune évaluation disponible', ha='center', va='center', + transform=ax.transAxes, color='white') + ax.set_title('Évaluations des Membres du Staff', color='white') + buf = io.BytesIO() + plt.savefig(buf, format='png', bbox_inches='tight', facecolor='#2F3136') + buf.seek(0) + plt.close() + return buf + + # Sort by average rating + rated_staff.sort(key=lambda x: x['average_rating'], reverse=True) + + # Prepare data + names = [s['name'][:15] + '...' if len(s['name']) > 15 else s['name'] for s in rated_staff] + ratings = [s['average_rating'] for s in rated_staff] + counts = [s['total_ratings'] for s in rated_staff] + + # Create figure + fig, ax = plt.subplots(figsize=(12, 8), facecolor='#2F3136') + fig.patch.set_facecolor('#2F3136') + ax.set_facecolor('#2F3136') + + # Create bars + bars = ax.bar(range(len(ratings)), ratings, color=['#57F287' if r >= 4.5 else '#FEE75C' if r >= 3.5 else '#FAA61A' if r >= 2.5 else '#ED4245' for r in ratings]) + + # Customize + ax.set_xticks(range(len(names))) + ax.set_xticklabels(names, rotation=45, ha='right', color='white') + ax.set_ylabel('Note moyenne', color='white') + ax.set_title('Évaluations des Membres du Staff', color='white', fontsize=16) + ax.tick_params(colors='white') + ax.grid(True, alpha=0.3, color='gray') + + # Add value labels on bars + for i, (bar, rating, count) in enumerate(zip(bars, ratings, counts)): + ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.05, + f'{rating:.1f}⭐\n({count})', ha='center', va='bottom', color='white', fontsize=8) + + # Set y-axis limits + ax.set_ylim(0, 5.2) + + # Adjust layout + plt.tight_layout() + + # Save to buffer + buf = io.BytesIO() + plt.savefig(buf, format='png', bbox_inches='tight', facecolor='#2F3136') + buf.seek(0) + plt.close() + return buf + + +async def setup(bot): + await bot.add_cog(StaffRatings(bot)) \ No newline at end of file diff --git a/commandes/ticket/__init__.py b/commandes/ticket/__init__.py index b92e829..209b100 100644 --- a/commandes/ticket/__init__.py +++ b/commandes/ticket/__init__.py @@ -39,6 +39,17 @@ from .staff_analytics import StaffAnalytics, StaffStatsView from .utils.permissions import is_staff, is_whitelisted, can_access_config from src.logger import kuby_logger +# Add a task for sending feedback reminders +class FeedbackReminderTask: + def __init__(self, bot): + self.bot = bot + self.reminders = {} # Store active reminders + + async def schedule_feedback_reminder(self, user_id: int, ticket_id: int, delay_hours: int = 24): + """Schedule a feedback reminder after a ticket is closed""" + # This would be implemented with a proper task scheduler + pass + class TicketPanelView(discord.ui.View): """Panel principal avec toutes les options intégrées""" @@ -83,7 +94,16 @@ class TicketPanelView(discord.ui.View): ) analytics_btn.callback = self._analytics_callback self.add_item(analytics_btn) - + + # Staff Ratings (staff only) + staff_ratings_btn = discord.ui.Button( + style=discord.ButtonStyle.success, + label="⭐ Notation du Staff", + custom_id="ticket_staff_ratings" + ) + staff_ratings_btn.callback = self._staff_ratings_callback + self.add_item(staff_ratings_btn) + # Configuration (admin only) config_btn = discord.ui.Button( style=discord.ButtonStyle.secondary, @@ -267,6 +287,85 @@ class TicketPanelView(discord.ui.View): await interaction.response.send_message(embed=embed, view=config_view, ephemeral=True) + async def _staff_ratings_callback(self, interaction: discord.Interaction): + """Display staff ratings (staff only)""" + # Reload config + self.config = self.storage.load_config(interaction.guild_id) + + if not self._is_staff(interaction): + await interaction.response.send_message("❌ Cette fonctionnalité est réservée au staff.", ephemeral=True) + return + + await interaction.response.defer(ephemeral=True) + + analytics = StaffAnalytics(self.bot) + + # Get staff list for individual stats + staff_list = await analytics.get_staff_list(interaction.guild_id) + + # Create main embed with overall stats + embed = discord.Embed( + title="⭐ Notation du Staff", + description="Performance globale du staff", + color=discord.Color.blue(), + timestamp=datetime.now() + ) + + # Calculate overall stats + tickets = self.storage.get_guild_tickets(interaction.guild_id) + total_tickets = len(tickets) + open_tickets = sum(1 for t in tickets if t.status in [TicketStatus.OPEN, TicketStatus.CLAIMED]) + closed_tickets = sum(1 for t in tickets if t.status == TicketStatus.CLOSED) + claimed_tickets = sum(1 for t in tickets if t.claimed_by is not None) + + # Basic stats + embed.add_field( + name="📈 Statistiques Générales", + value=f"**Total:** {total_tickets}\n" + f"**Ouverts:** {open_tickets}\n" + f"**Fermés:** {closed_tickets}\n" + f"**Réclamés:** {claimed_tickets}", + inline=True + ) + + # Staff performance summary + if staff_list: + total_ratings = sum(s['total_ratings'] for s in staff_list) + avg_rating = sum(s['average_rating'] for s in staff_list if s['total_ratings'] > 0) / len([s for s in staff_list if s['total_ratings'] > 0]) if any(s['total_ratings'] > 0 for s in staff_list) else 0 + + embed.add_field( + name="👥 Équipe Staff", + value=f"**Membres actifs:** {len(staff_list)}\n" + f"**Évaluations totales:** {total_ratings}\n" + f"**Note moyenne globale:** {avg_rating:.1f}⭐" if avg_rating > 0 else "**Note moyenne globale:** N/A", + inline=True + ) + + # Send main embed + await interaction.followup.send(embed=embed, ephemeral=True) + + # Create and send staff rating chart + try: + if staff_list: + rating_chart = analytics.create_staff_rating_chart(staff_list) + await interaction.followup.send( + "📊 **Évaluations des Membres du Staff**", + file=discord.File(rating_chart, 'staff_ratings.png'), + ephemeral=True + ) + + # Add individual staff stats view + stats_view = StaffStatsView(self.bot, staff_list, analytics) + stats_embed = discord.Embed( + title="📈 Statistiques Individuelles", + description="Cliquez sur un membre du staff pour voir ses statistiques détaillées:", + color=discord.Color.purple() + ) + await interaction.followup.send(embed=stats_embed, view=stats_view, ephemeral=True) + + except Exception as e: + await interaction.followup.send(f"❌ Erreur lors de la génération des graphiques: {e}", ephemeral=True) + def _is_staff(self, interaction: discord.Interaction) -> bool: return is_staff(interaction, self.config) @@ -449,11 +548,15 @@ class TicketPriorityModal(discord.ui.Modal): guild.me: discord.PermissionOverwrite(read_messages=True, send_messages=True) } - # Add staff roles - for role_id in self.config.staff_roles: - role = guild.get_role(int(role_id)) - if role and role not in overwrites: - overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=True) + # Add staff roles (category-specific roles override global ones) + staff_roles_to_add = self.category.staff_roles if self.category.staff_roles else self.config.staff_roles + for role_id in staff_roles_to_add: + try: + role = guild.get_role(int(role_id)) + if role and role not in overwrites: + overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=True) + except (ValueError, TypeError): + continue # Create channel name using username and category without numbers safe_username = "".join(c for c in user.display_name if not c.isdigit()).strip() @@ -464,7 +567,16 @@ class TicketPriorityModal(discord.ui.Modal): try: category_channel = None - if self.config.default_category: + + # Check if category has a specific Discord category + if self.category.discord_category_id: + try: + category_channel = guild.get_channel(int(self.category.discord_category_id)) + except (ValueError, TypeError): + pass + + # Fallback to default config category if not found or not set + if not category_channel and self.config.default_category: try: category_channel = guild.get_channel(int(self.config.default_category)) except (ValueError, TypeError): @@ -582,11 +694,11 @@ class TicketManagementView(discord.ui.View): self.ticket = ticket self.category = config.get_category(ticket.category_id) - # Claim (if enabled and ticket is open) - if config.claim_enabled and self.category and self.category.allow_claims and ticket.status == TicketStatus.OPEN: + # Claim (if enabled and ticket is open or claimed) + if config.claim_enabled and self.category and self.category.allow_claims and ticket.status in [TicketStatus.OPEN, TicketStatus.CLAIMED]: claim_btn = discord.ui.Button( style=discord.ButtonStyle.primary, - label="Réclamer", + label="Réclamer" if ticket.status == TicketStatus.OPEN else "Transférer", custom_id="ticket_claim" ) claim_btn.callback = self._claim_callback @@ -697,24 +809,33 @@ class TicketManagementView(discord.ui.View): await interaction.response.send_message("Réservé au staff.", ephemeral=True) return - if self.ticket.claimed_by: - await interaction.response.send_message("Ce ticket est déjà réclamé.", ephemeral=True) + if self.ticket.claimed_by == interaction.user.id: + await interaction.response.send_message("Vous avez déjà réclamé ce ticket.", ephemeral=True) return + old_claimer = self.ticket.claimed_by self.ticket.claimed_by = interaction.user.id self.ticket.status = TicketStatus.CLAIMED self.storage.save_ticket(self.ticket) - # Update view (remove claim button) + # Update view (change label) for item in self.children: if item.custom_id == "ticket_claim": - self.remove_item(item) + item.label = "Transférer" break # Force UI update await interaction.response.edit_message(view=self) - embed = TicketEmbedFormatter.ticket_claimed(self.ticket, interaction.user) + if old_claimer: + embed = discord.Embed( + title="🔄 Ticket Transféré", + description=f"Le ticket a été transféré de <@{old_claimer}> à {interaction.user.mention}", + color=discord.Color.blue() + ) + else: + embed = TicketEmbedFormatter.ticket_claimed(self.ticket, interaction.user) + await interaction.channel.send(embed=embed) async def _reopen_callback(self, interaction: discord.Interaction): @@ -762,20 +883,32 @@ class TicketManagementView(discord.ui.View): await interaction.response.send_message("Ticket rouvert!", ephemeral=True) def _is_staff(self, interaction: discord.Interaction) -> bool: - return is_staff(interaction, self.config) + return is_staff(interaction, self.config, self.category) def _is_staff_member(self, member) -> bool: - # Create a fake interaction-like check or manual check + """Check if a specific member is staff, considering category roles""" + if not member: + return False + + # Handle cases where member is a User (left the guild) + if not isinstance(member, discord.Member): + return member.id in self.config.staff_users + if member.guild_permissions.administrator: return True if member.id in self.config.staff_users: return True - for role_id in self.config.staff_roles: - role = member.guild.get_role(int(role_id)) - if role and role in member.roles: - return True + # Check category-specific staff roles first + staff_roles = self.category.staff_roles if self.category and self.category.staff_roles else self.config.staff_roles + for role_id in staff_roles: + try: + role = member.guild.get_role(int(role_id)) + if role and role in member.roles: + return True + except (ValueError, TypeError, AttributeError): + continue return False @@ -788,6 +921,7 @@ class CloseConfirmationView(discord.ui.View): self.storage = storage self.config = config self.ticket = ticket + self.category = config.get_category(ticket.category_id) @discord.ui.button(label="✅ Confirmer", style=discord.ButtonStyle.green, custom_id="close_confirm") async def confirm_callback(self, interaction: discord.Interaction, button: discord.ui.Button): @@ -825,13 +959,7 @@ class CloseConfirmationView(discord.ui.View): await interaction.delete_original_response() def _is_staff(self, interaction: discord.Interaction) -> bool: - guild = interaction.guild - user = interaction.user - for role_id in self.config.staff_roles: - role = guild.get_role(int(role_id)) - if role and role in user.roles: - return True - return user.guild_permissions.administrator + return is_staff(interaction, self.config, self.category) class CloseDelayView(discord.ui.View): @@ -934,13 +1062,24 @@ class CloseDelayView(discord.ui.View): if self.close_reason: dm_embed.add_field(name="Raison de fermeture", value=self.close_reason, inline=False) + # Create a special view with a "Noter le staff" button view = FeedbackView(self.bot, self.storage, self.ticket) + + # Add a button to directly open the feedback form + note_staff_btn = discord.ui.Button( + label="⭐ Noter le staff", + style=discord.ButtonStyle.primary, + custom_id="note_staff_direct" + ) + note_staff_btn.callback = self._note_staff_callback + view.add_item(note_staff_btn) + await user.send(embed=dm_embed, view=view) except Exception as e: print(f"Failed to send feedback DM: {e}") # Send transcript to log channel - if self.config.log_channel_id and transcript_file and os.path.exists(transcript_file): + if self.config.log_channel_id: try: log_channel = interaction.guild.get_channel(self.config.log_channel_id) if log_channel: @@ -961,8 +1100,12 @@ class CloseDelayView(discord.ui.View): if self.close_reason: log_embed.add_field(name="Raison", value=self.close_reason, inline=False) - file = discord.File(transcript_file, filename=os.path.basename(transcript_file)) - await log_channel.send(embed=log_embed, file=file) + if transcript_file and os.path.exists(transcript_file): + file = discord.File(transcript_file, filename=os.path.basename(transcript_file)) + await log_channel.send(embed=log_embed, file=file) + else: + log_embed.add_field(name="⚠️ Transcript", value="Le transcript n'a pas pu être généré (l'utilisateur a peut-être quitté le serveur).", inline=False) + await log_channel.send(embed=log_embed) except Exception as e: print(f"Error sending transcript to log channel: {e}") @@ -977,6 +1120,11 @@ class CloseDelayView(discord.ui.View): color=discord.Color.red() ) await interaction.channel.send(embed=embed) + + async def _note_staff_callback(self, interaction: discord.Interaction): + """Handle direct staff rating button""" + # This would open a direct feedback form + await interaction.response.send_message("Vous pouvez maintenant noter le staff directement !", ephemeral=True) async def _cancel_callback(self, interaction: discord.Interaction): """Annule la fermeture du ticket pendant le délai""" @@ -992,20 +1140,32 @@ class CloseDelayView(discord.ui.View): await interaction.channel.send(embed=embed, delete_after=5) def _is_staff(self, interaction: discord.Interaction) -> bool: - guild = interaction.guild - user = interaction.user - for role_id in self.config.staff_roles: - role = guild.get_role(int(role_id)) - if role and role in user.roles: - return True - return user.guild_permissions.administrator + return is_staff(interaction, self.config, self.category) def _is_staff_member(self, member) -> bool: - guild = member.guild - for role_id in self.config.staff_roles: - role = guild.get_role(int(role_id)) - if role and role in member.roles: - return True + """Check if a specific member is staff, considering category roles""" + if not member: + return False + + # Handle cases where member is a User (left the guild) + if not isinstance(member, discord.Member): + return member.id in self.config.staff_users + + if member.guild_permissions.administrator: + return True + + if member.id in self.config.staff_users: + return True + + # Check category-specific staff roles first + staff_roles = self.category.staff_roles if self.category and self.category.staff_roles else self.config.staff_roles + for role_id in staff_roles: + try: + role = member.guild.get_role(int(role_id)) + if role and role in member.roles: + return True + except (ValueError, TypeError, AttributeError): + continue return False @@ -1125,7 +1285,7 @@ class CloseTicketModal(discord.ui.Modal): duration = self.ticket.duration_minutes # Send transcript to log channel if configured - if self.config.log_channel_id and transcript_file and os.path.exists(transcript_file): + if self.config.log_channel_id: try: log_channel = interaction.guild.get_channel(self.config.log_channel_id) if log_channel: @@ -1149,8 +1309,12 @@ class CloseTicketModal(discord.ui.Modal): log_embed.add_field(name="Raison de fermeture", value=self.reason.value, inline=False) # Send transcript file - file = discord.File(transcript_file, filename=os.path.basename(transcript_file)) - await log_channel.send(embed=log_embed, file=file) + if transcript_file and os.path.exists(transcript_file): + file = discord.File(transcript_file, filename=os.path.basename(transcript_file)) + await log_channel.send(embed=log_embed, file=file) + else: + log_embed.add_field(name="⚠️ Transcript", value="Le transcript n'a pas pu être généré (l'utilisateur a peut-être quitté le serveur).", inline=False) + await log_channel.send(embed=log_embed) except Exception as e: print(f"Error sending transcript to log channel: {e}") @@ -1163,13 +1327,32 @@ class CloseTicketModal(discord.ui.Modal): await interaction.followup.send("Erreur lors de la suppression du canal, mais le ticket a été fermé.", ephemeral=True) def _is_staff(self, interaction: discord.Interaction) -> bool: - guild = interaction.guild - user = interaction.user - for role_id in self.config.staff_roles: - role = guild.get_role(int(role_id)) - if role and role in user.roles: - return True - return user.guild_permissions.administrator + return is_staff(interaction, self.config, self.category) + + def _is_staff_member(self, member) -> bool: + """Check if a specific member is staff, considering category roles""" + if not member: + return False + + # Handle cases where member is a User (left the guild) + if not isinstance(member, discord.Member): + return member.id in self.config.staff_users + + if member.guild_permissions.administrator: + return True + + if member.id in self.config.staff_users: + return True + + staff_roles = self.category.staff_roles if self.category and self.category.staff_roles else self.config.staff_roles + for role_id in staff_roles: + try: + role = member.guild.get_role(int(role_id)) + if role and role in member.roles: + return True + except (ValueError, TypeError, AttributeError): + continue + return False class TicketSetupView(discord.ui.View): @@ -2039,7 +2222,9 @@ class CategoryActionView(discord.ui.View): description=f"**ID:** `{self.category.id}`\n" f"**Emoji:** {self.category.emoji}\n" f"**Description:** {self.category.description or 'Aucune'}\n" - f"**Couleur:** `{self.category.color}`", + f"**Couleur:** `{self.category.color}`\n" + f"**Staff Spécifique:** {', '.join([f'<@&{r}>' for r in self.category.staff_roles]) if self.category.staff_roles else 'Global (Tous les staffs)'}\n" + f"**Catégorie Discord:** {f'<#{self.category.discord_category_id}>' if self.category.discord_category_id else 'Par défaut'}", color=discord.Color.blue() ) return embed @@ -2049,6 +2234,29 @@ class CategoryActionView(discord.ui.View): modal = EditCategoryModal(self.bot, self.storage, self.config, self.category, self) await interaction.response.send_modal(modal) + @discord.ui.button(label="🛡️ Permissions", style=discord.ButtonStyle.success) + async def permissions_callback(self, interaction: discord.Interaction, button: discord.ui.Button): + view = CategoryPermissionsView(self.bot, self.storage, self.config, self.category, self) + embed = discord.Embed( + title=f"Permissions: {self.category.name}", + description="Choisissez les rôles qui auront accès à cette catégorie de tickets.\n\n" + "⚠️ **Si vous sélectionnez des rôles ici, seuls ces rôles (et les admins) auront accès aux tickets de cette catégorie.** " + "Les rôles staff globaux n'y auront plus accès.", + color=discord.Color.green() + ) + await interaction.response.edit_message(embed=embed, view=view) + + @discord.ui.button(label="📁 Catégorie Discord", style=discord.ButtonStyle.secondary) + async def discord_category_callback(self, interaction: discord.Interaction, button: discord.ui.Button): + view = CategoryDiscordCategoryView(self.bot, self.storage, self.config, self.category, self) + embed = discord.Embed( + title=f"Catégorie Discord: {self.category.name}", + description="Choisissez la catégorie Discord (dossier) dans laquelle les tickets de ce type seront créés.\n\n" + "Si aucune n'est sélectionnée, la catégorie par défaut du serveur sera utilisée.", + color=discord.Color.blue() + ) + await interaction.response.edit_message(embed=embed, view=view) + @discord.ui.button(label="🗑️ Supprimer", style=discord.ButtonStyle.danger) async def delete_callback(self, interaction: discord.Interaction, button: discord.ui.Button): view = DeleteCategoryConfirmationView(self.bot, self.storage, self.config, self.category, self.parent_view) @@ -2065,6 +2273,57 @@ class CategoryActionView(discord.ui.View): self.parent_view._add_category_select() await interaction.response.edit_message(embed=self.parent_view._get_embed(), view=self.parent_view) +class CategoryPermissionsView(discord.ui.View): + """Vue pour configurer les rôles staff d'une catégorie""" + + def __init__(self, bot, storage, config, category, parent_view): + super().__init__(timeout=None) + self.bot = bot + self.storage = storage + self.config = config + self.category = category + self.parent_view = parent_view + + @discord.ui.select(cls=discord.ui.RoleSelect, placeholder="Sélectionner les rôles staff (Gestion)", min_values=0, max_values=10) + async def role_select_callback(self, interaction: discord.Interaction, select: discord.ui.RoleSelect): + role_ids = [str(role.id) for role in select.values] + self.category.staff_roles = role_ids + self.storage.save_config(interaction.guild_id, self.config) + + # Refresh embed + await interaction.response.edit_message(embed=self.parent_view._get_embed(), view=self.parent_view) + + @discord.ui.button(label="⬅️ Annuler", style=discord.ButtonStyle.secondary) + async def back_callback(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.edit_message(embed=self.parent_view._get_embed(), view=self.parent_view) + +class CategoryDiscordCategoryView(discord.ui.View): + """Vue pour configurer la catégorie Discord d'une catégorie de ticket""" + + def __init__(self, bot, storage, config, category, parent_view): + super().__init__(timeout=None) + self.bot = bot + self.storage = storage + self.config = config + self.category = category + self.parent_view = parent_view + + @discord.ui.select(cls=discord.ui.ChannelSelect, channel_types=[discord.ChannelType.category], placeholder="Sélectionner la catégorie Discord") + async def select_callback(self, interaction: discord.Interaction, select: discord.ui.ChannelSelect): + self.category.discord_category_id = select.values[0].id + self.storage.save_config(interaction.guild_id, self.config) + await interaction.response.edit_message(embed=self.parent_view._get_embed(), view=self.parent_view) + + @discord.ui.button(label="❌ Réinitialiser", style=discord.ButtonStyle.danger) + async def reset_callback(self, interaction: discord.Interaction, button: discord.ui.Button): + self.category.discord_category_id = None + self.storage.save_config(interaction.guild_id, self.config) + await interaction.response.edit_message(embed=self.parent_view._get_embed(), view=self.parent_view) + + @discord.ui.button(label="⬅️ Retour", style=discord.ButtonStyle.secondary) + async def back_callback(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.edit_message(embed=self.parent_view._get_embed(), view=self.parent_view) + class DeleteCategoryConfirmationView(discord.ui.View): def __init__(self, bot, storage, config, category, parent_view): diff --git a/commandes/ticket/data/models.py b/commandes/ticket/data/models.py index 2446df1..d03ce03 100644 --- a/commandes/ticket/data/models.py +++ b/commandes/ticket/data/models.py @@ -59,7 +59,8 @@ class TicketCategory: auto_close_days: int = 7, priority_enabled: bool = True, allow_claims: bool = True, - survey_enabled: bool = True + survey_enabled: bool = True, + discord_category_id: Optional[int] = None ): self.id = id self.name = name @@ -74,6 +75,7 @@ class TicketCategory: self.priority_enabled = priority_enabled self.allow_claims = allow_claims self.survey_enabled = survey_enabled + self.discord_category_id = discord_category_id def to_dict(self) -> Dict[str, Any]: """Convert category to dictionary for storage""" @@ -90,7 +92,8 @@ class TicketCategory: "auto_close_days": self.auto_close_days, "priority_enabled": self.priority_enabled, "allow_claims": self.allow_claims, - "survey_enabled": self.survey_enabled + "survey_enabled": self.survey_enabled, + "discord_category_id": self.discord_category_id } @classmethod @@ -109,7 +112,8 @@ class TicketCategory: auto_close_days=data.get("auto_close_days", 7), priority_enabled=data.get("priority_enabled", True), allow_claims=data.get("allow_claims", True), - survey_enabled=data.get("survey_enabled", True) + survey_enabled=data.get("survey_enabled", True), + discord_category_id=data.get("discord_category_id") ) diff --git a/commandes/ticket/feedback_view.py b/commandes/ticket/feedback_view.py index 15e7556..03bcc54 100644 --- a/commandes/ticket/feedback_view.py +++ b/commandes/ticket/feedback_view.py @@ -2,13 +2,14 @@ import discord from .data.models import TicketData class FeedbackView(discord.ui.View): - def __init__(self, bot, storage, ticket: TicketData): + def __init__(self, bot, storage, ticket: TicketData, staff_rating_only: bool = False): super().__init__(timeout=86400) # 24h timeout self.bot = bot self.storage = storage self.ticket = ticket self.rating = 0 self.comment = "" + self.staff_rating_only = staff_rating_only # Add Select Menu for Rating self.add_item(RatingSelect()) @@ -32,7 +33,11 @@ class FeedbackView(discord.ui.View): for child in self.children: child.disabled = True - await interaction.response.edit_message(content="✅ Merci pour votre retour !", view=self, embed=None) + # If this is a staff rating only view, we don't send the normal message + if not self.staff_rating_only: + await interaction.response.edit_message(content="✅ Merci pour votre retour !", view=self, embed=None) + else: + await interaction.response.edit_message(content="✅ Note envoyée !", view=self, embed=None) # Log feedback if configured try: diff --git a/commandes/ticket/staff_analytics.py b/commandes/ticket/staff_analytics.py index 90c4745..2e8925b 100644 --- a/commandes/ticket/staff_analytics.py +++ b/commandes/ticket/staff_analytics.py @@ -9,6 +9,11 @@ from collections import defaultdict from typing import List, Dict, Any, Optional import discord +import matplotlib.pyplot as plt +import plotly.graph_objects as go +import plotly.express as px +from plotly.subplots import make_subplots +import numpy as np from PIL import Image, ImageDraw, ImageFont from .data.storage import get_storage @@ -102,213 +107,74 @@ class StaffAnalytics: return result def create_staff_rating_chart(self, staff_list: List[Dict[str, Any]]) -> io.BytesIO: - """Create a comprehensive staff rating chart""" + """Create a comprehensive staff rating chart using matplotlib""" # Filter staff with ratings rated_staff = [s for s in staff_list if s['total_ratings'] > 0] if not rated_staff: # Return empty chart - img = Image.new('RGB', (800, 400), '#2F3136') + fig, ax = plt.subplots(figsize=(10, 6), facecolor='#2F3136') + ax.set_facecolor('#2F3136') + ax.text(0.5, 0.5, 'Aucune évaluation disponible', ha='center', va='center', + transform=ax.transAxes, color='white') + ax.set_title('Évaluations des Membres du Staff', color='white') buf = io.BytesIO() - img.save(buf, format='PNG') + plt.savefig(buf, format='png', bbox_inches='tight', facecolor='#2F3136') buf.seek(0) + plt.close() return buf - # Image dimensions - width, height = 1200, 700 - img = Image.new('RGB', (width, height), '#36393F') # Discord dark theme - draw = ImageDraw.Draw(img) - - # Try to load fonts with better Unicode support - try: - # Try different font paths for better Unicode support - font_paths = [ - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", - "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", - "/System/Library/Fonts/Arial.ttf", - "arial.ttf" - ] - font_title = None - font_label = None - font_value = None - - for font_path in font_paths: - try: - font_title = ImageFont.truetype(font_path, 24) - font_label = ImageFont.truetype(font_path, 16) - font_value = ImageFont.truetype(font_path, 14) - break - except: - continue - - if not font_title: - raise Exception("No suitable font found") - - except: - # Fallback to default with better handling - font_title = ImageFont.load_default() - font_label = ImageFont.load_default() - font_value = ImageFont.load_default() - - # Title with better styling - title = "Évaluations des Membres du Staff" - # Add background for title - title_bbox = draw.textbbox((0, 0), title, font=font_title) - title_width = title_bbox[2] - title_bbox[0] - title_x = (width - title_width) // 2 - draw.rectangle([title_x - 20, 15, title_x + title_width + 20, 55], fill='#5865F2', outline='#FFFFFF', width=2) - draw.text((width//2, 35), title, fill='white', font=font_title, anchor='mm') - - # Chart dimensions with better spacing - chart_left = 200 - chart_right = width - 100 - chart_top = 100 - chart_bottom = height - 200 - chart_width = chart_right - chart_left - chart_height = chart_bottom - chart_top - - # Prepare data - limit to top 8 for better readability - top_staff = sorted(rated_staff, key=lambda x: x['average_rating'], reverse=True)[:8] - names = [] - for s in top_staff: - name = s['name'] - # Handle Unicode properly - try: - # Ensure proper encoding - name = name.encode('utf-8').decode('utf-8') - except: - name = str(name) - # Truncate long names - if len(name) > 12: - name = name[:12] + '...' - names.append(name) - + # Sort by average rating + rated_staff.sort(key=lambda x: x['average_rating'], reverse=True) + + # Prepare data - limit to top 10 for better readability + top_staff = rated_staff[:10] + names = [s['name'][:15] + '...' if len(s['name']) > 15 else s['name'] for s in top_staff] ratings = [s['average_rating'] for s in top_staff] counts = [s['total_ratings'] for s in top_staff] - if not ratings: - buf = io.BytesIO() - img.save(buf, format='PNG') - buf.seek(0) - return buf - - max_rating = 5 - bar_width = min(80, chart_width // len(ratings)) if ratings else 1 - spacing = 20 - - # Draw background grid - for i in range(6): - y = chart_bottom - (i * chart_height // 5) - draw.line([chart_left, y, chart_right, y], fill='#72767D', width=1) - # Rating labels - draw.text((chart_left - 40, y), f"{i}", fill='#FFFFFF', font=font_value, anchor='mm') - - # Draw bars with gradient effect - for i, (name, rating, count) in enumerate(zip(names, ratings, counts)): - bar_height = int((rating / max_rating) * chart_height) - - x1 = chart_left + i * (bar_width + spacing) + spacing//2 - y1 = chart_bottom - bar_height - x2 = x1 + bar_width - y2 = chart_bottom - - # Bar color based on rating with better colors - if rating >= 4.5: - color = '#57F287' # Green - shadow_color = '#4CAF50' - elif rating >= 3.5: - color = '#FEE75C' # Yellow - shadow_color = '#FFC107' - elif rating >= 2.5: - color = '#FAA61A' # Orange - shadow_color = '#FF9800' - else: - color = '#ED4245' # Red - shadow_color = '#F44336' - - # Draw shadow for 3D effect - draw.rectangle([x1+2, y1+2, x2+2, y2], fill=shadow_color, outline=shadow_color) - - # Main bar - draw.rectangle([x1, y1, x2, y2], fill=color, outline='#FFFFFF', width=2) - - # Rating value on top with background - rating_text = f"{rating:.1f}⭐" - text_bbox = draw.textbbox((0, 0), rating_text, font=font_value) - text_width = text_bbox[2] - text_bbox[0] - text_x = x1 + bar_width//2 - draw.rectangle([text_x - text_width//2 - 5, y1 - 25, text_x + text_width//2 + 5, y1 - 5], fill='#36393F', outline='#FFFFFF', width=1) - draw.text((text_x, y1 - 15), rating_text, fill='white', font=font_value, anchor='mm') - - # Count below with background - count_text = f"({count})" - count_bbox = draw.textbbox((0, 0), count_text, font=font_value) - count_width = count_bbox[2] - count_bbox[0] - draw.rectangle([text_x - count_width//2 - 5, y2 + 5, text_x + count_width//2 + 5, y2 + 25], fill='#36393F', outline='#FFFFFF', width=1) - draw.text((text_x, y2 + 15), count_text, fill='white', font=font_value, anchor='mm') - - # Draw staff names with better positioning - for i, name in enumerate(names): - x = chart_left + i * (bar_width + spacing) + bar_width//2 + spacing//2 - - # Handle long names by splitting - lines = [] - if len(name) > 8: - # Split at spaces if possible - words = name.split() - current_line = "" - for word in words: - if len(current_line + word) < 8: - current_line += word + " " - else: - lines.append(current_line.strip()) - current_line = word + " " - if current_line: - lines.append(current_line.strip()) - if not lines: - lines = [name[:8] + '...'] - else: - lines = [name] - - y_offset = chart_bottom + 40 - for line in lines: - # Background for name - name_bbox = draw.textbbox((0, 0), line, font=font_label) - name_width = name_bbox[2] - name_bbox[0] - draw.rectangle([x - name_width//2 - 5, y_offset - 5, x + name_width//2 + 5, y_offset + 15], fill='#36393F', outline='#FFFFFF', width=1) - draw.text((x, y_offset + 5), line, fill='white', font=font_label, anchor='mm') - y_offset += 20 - - # Y-axis label - draw.text((chart_left - 120, height//2), 'Note Moyenne', fill='white', font=font_label, anchor='mm') - - # Enhanced legend with better positioning - legend_x = chart_right - 250 - legend_y = chart_top + 50 - - # Legend background - draw.rectangle([legend_x - 20, legend_y - 10, legend_x + 230, legend_y + 120], fill='#36393F', outline='#FFFFFF', width=2) - - draw.text((legend_x, legend_y), "Légende des Notes:", fill='white', font=font_label) - - legend_items = [ - ("🟢 Excellent", "4.5+", '#57F287'), - ("🟡 Bon", "3.5-4.4", '#FEE75C'), - ("🟠 Moyen", "2.5-3.4", '#FAA61A'), - ("🔴 À améliorer", "<2.5", '#ED4245') + # Create figure + fig, ax = plt.subplots(figsize=(12, 8), facecolor='#2F3136') + fig.patch.set_facecolor('#2F3136') + ax.set_facecolor('#2F3136') + + # Create bars with color coding + colors = ['#57F287' if r >= 4.5 else '#FEE75C' if r >= 3.5 else '#FAA61A' if r >= 2.5 else '#ED4245' for r in ratings] + bars = ax.bar(range(len(ratings)), ratings, color=colors) + + # Customize + ax.set_xticks(range(len(names))) + ax.set_xticklabels(names, rotation=45, ha='right', color='white') + ax.set_ylabel('Note moyenne', color='white') + ax.set_title('Évaluations des Membres du Staff', color='white', fontsize=16) + ax.tick_params(colors='white') + ax.grid(True, alpha=0.3, color='gray') + + # Add value labels on bars (avoiding Unicode stars to prevent warnings) + for i, (bar, rating, count) in enumerate(zip(bars, ratings, counts)): + ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.05, + f'{rating:.1f}\n({count})', ha='center', va='bottom', color='white', fontsize=8) + + # Set y-axis limits + ax.set_ylim(0, 5.2) + + # Add legend + legend_elements = [ + plt.Rectangle((0,0),1,1, color='#57F287', label='Excellent (4.5+)'), + plt.Rectangle((0,0),1,1, color='#FEE75C', label='Bon (3.5-4.4)'), + plt.Rectangle((0,0),1,1, color='#FAA61A', label='Moyen (2.5-3.4)'), + plt.Rectangle((0,0),1,1, color='#ED4245', label='À améliorer (<2.5)') ] - - for i, (emoji_text, range_text, color) in enumerate(legend_items): - y_pos = legend_y + 25 + i * 20 - draw.text((legend_x, y_pos), f"{emoji_text} {range_text}", fill=color, font=font_value) - - # Add some stats at the bottom - stats_text = f"Total évalué: {len(rated_staff)} membres • Moyenne générale: {sum(ratings)/len(ratings):.1f}⭐" - draw.text((width//2, height - 30), stats_text, fill='#B9BBBE', font=font_value, anchor='mm') - + ax.legend(handles=legend_elements, loc='upper right', facecolor='#2F3136', edgecolor='white', labelcolor='white') + + # Adjust layout + plt.tight_layout() + + # Save to buffer buf = io.BytesIO() - img.save(buf, format='PNG') + plt.savefig(buf, format='png', bbox_inches='tight', facecolor='#2F3136') buf.seek(0) + plt.close() return buf def create_individual_staff_chart(self, staff_data: Dict[str, Any]) -> io.BytesIO: diff --git a/commandes/ticket/utils/permissions.py b/commandes/ticket/utils/permissions.py index 1a9a73d..6856f93 100644 --- a/commandes/ticket/utils/permissions.py +++ b/commandes/ticket/utils/permissions.py @@ -7,16 +7,15 @@ import discord from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: - from ..data.models import GuildTicketConfig + from ..data.models import GuildTicketConfig, TicketCategory -def is_staff(interaction: discord.Interaction, config: 'GuildTicketConfig') -> bool: +def is_staff(interaction: discord.Interaction, config: 'GuildTicketConfig', category: Optional['TicketCategory'] = None) -> bool: """ Check if the user has staff permissions. - Checks: - 1. Administrator permission - 2. Staff roles - 3. Staff users (specific user IDs) + If a category is provided and has specific staff_roles defined, + only those roles (and admins) are considered staff for this check. + Otherwise, global staff roles from the config are used. """ # Get user and permissions based on object type user = getattr(interaction, "author", getattr(interaction, "user", None)) @@ -26,16 +25,29 @@ def is_staff(interaction: discord.Interaction, config: 'GuildTicketConfig') -> b if user.guild_permissions.administrator: return True - - # Check specific users + # Check specific users (always allowed) if user.id in config.staff_users: return True - # Check roles + # If category has specific roles, only those are allowed + if category and category.staff_roles: + for role_id in category.staff_roles: + try: + role = interaction.guild.get_role(int(role_id)) + if role and role in user.roles: + return True + except (ValueError, TypeError): + continue + return False + + # Check global roles for role_id in config.staff_roles: - role = interaction.guild.get_role(int(role_id)) - if role and role in user.roles: - return True + try: + role = interaction.guild.get_role(int(role_id)) + if role and role in user.roles: + return True + except (ValueError, TypeError): + continue return False diff --git a/data/json-export-history.json b/data/json-export-history.json index 460c6cc..262fa2b 100644 --- a/data/json-export-history.json +++ b/data/json-export-history.json @@ -1,6 +1,6 @@ [ { - "date": "2026-03-31T20:07:49.786458+00:00", + "date": "2026-05-01T14:11:08.947818+00:00", "trigger": "auto", "serversCount": 1, "membersCount": 12, @@ -9,7 +9,7 @@ "success": true }, { - "date": "2026-03-31T20:06:47.718241+00:00", + "date": "2026-05-01T13:48:18.180388+00:00", "trigger": "auto", "serversCount": 1, "membersCount": 12, @@ -18,7 +18,7 @@ "success": true }, { - "date": "2026-03-31T20:06:07.381254+00:00", + "date": "2026-05-01T13:36:45.397039+00:00", "trigger": "auto", "serversCount": 1, "membersCount": 12, @@ -27,7 +27,7 @@ "success": true }, { - "date": "2026-03-31T20:00:43.918120+00:00", + "date": "2026-05-01T13:20:32.058899+00:00", "trigger": "auto", "serversCount": 1, "membersCount": 12, @@ -36,7 +36,7 @@ "success": true }, { - "date": "2026-03-29T20:16:08.626593+00:00", + "date": "2026-04-23T14:35:15.364216+00:00", "trigger": "auto", "serversCount": 1, "membersCount": 12, @@ -45,7 +45,7 @@ "success": true }, { - "date": "2026-03-29T19:16:08.300074+00:00", + "date": "2026-04-23T13:35:15.351819+00:00", "trigger": "auto", "serversCount": 1, "membersCount": 12, @@ -54,7 +54,7 @@ "success": true }, { - "date": "2026-03-29T18:16:08.376405+00:00", + "date": "2026-04-23T12:35:15.324906+00:00", "trigger": "auto", "serversCount": 1, "membersCount": 12, @@ -63,7 +63,7 @@ "success": true }, { - "date": "2026-03-29T17:16:08.514497+00:00", + "date": "2026-04-23T11:35:15.210303+00:00", "trigger": "auto", "serversCount": 1, "membersCount": 12, @@ -72,7 +72,7 @@ "success": true }, { - "date": "2026-03-29T17:15:42.635599+00:00", + "date": "2026-04-23T10:35:15.338465+00:00", "trigger": "auto", "serversCount": 1, "membersCount": 12, @@ -81,7 +81,7 @@ "success": true }, { - "date": "2026-03-29T17:15:41.574258+00:00", + "date": "2026-04-23T10:28:55.984163+00:00", "trigger": "auto", "serversCount": 1, "membersCount": 12, @@ -90,7 +90,7 @@ "success": true }, { - "date": "2026-03-29T14:18:49.697634+00:00", + "date": "2026-04-02T17:03:30.870748+00:00", "trigger": "auto", "serversCount": 1, "membersCount": 12, @@ -99,7 +99,7 @@ "success": true }, { - "date": "2026-03-29T14:16:08.163385+00:00", + "date": "2026-04-01T14:00:13.276907+00:00", "trigger": "auto", "serversCount": 1, "membersCount": 12, @@ -108,7 +108,7 @@ "success": true }, { - "date": "2026-03-29T14:09:18.074641+00:00", + "date": "2026-04-01T13:19:44.954622+00:00", "trigger": "auto", "serversCount": 1, "membersCount": 12, @@ -117,7 +117,7 @@ "success": true }, { - "date": "2026-03-29T13:56:24.990061+00:00", + "date": "2026-04-01T13:17:23.537128+00:00", "trigger": "auto", "serversCount": 1, "membersCount": 12, @@ -126,7 +126,7 @@ "success": true }, { - "date": "2026-03-29T13:54:43.766970+00:00", + "date": "2026-04-01T13:15:14.389369+00:00", "trigger": "auto", "serversCount": 1, "membersCount": 12, @@ -135,7 +135,7 @@ "success": true }, { - "date": "2026-03-29T13:48:39.075696+00:00", + "date": "2026-04-01T13:08:00.124432+00:00", "trigger": "auto", "serversCount": 1, "membersCount": 12, @@ -144,7 +144,7 @@ "success": true }, { - "date": "2026-03-29T13:48:08.960324+00:00", + "date": "2026-04-01T13:03:42.601666+00:00", "trigger": "auto", "serversCount": 1, "membersCount": 12, @@ -153,7 +153,7 @@ "success": true }, { - "date": "2026-03-29T13:47:39.191350+00:00", + "date": "2026-04-01T12:07:50.185260+00:00", "trigger": "auto", "serversCount": 1, "membersCount": 12, @@ -162,7 +162,7 @@ "success": true }, { - "date": "2026-03-29T13:47:09.059722+00:00", + "date": "2026-04-01T11:07:49.769763+00:00", "trigger": "auto", "serversCount": 1, "membersCount": 12, @@ -171,7 +171,7 @@ "success": true }, { - "date": "2026-03-29T13:46:39.263054+00:00", + "date": "2026-04-01T10:07:49.758778+00:00", "trigger": "auto", "serversCount": 1, "membersCount": 12, diff --git a/requirements.txt b/requirements.txt index 98b9f13..4319012 100755 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,5 @@ Pillow>=10.0.0 aiohttp>=3.8.0 flask>=3.0.0 requests>=2.31.0 +matplotlib>=3.7.0 +plotly>=5.15.0 diff --git a/serveur_flask.py b/serveur_flask.py index 01ea1aa..fbae82e 100644 --- a/serveur_flask.py +++ b/serveur_flask.py @@ -93,9 +93,10 @@ def gitlab_webhook(): # Transférer la payload au bot Discord localement sur le port 5001 payload = request.json try: - # Timeout très court car c'est en local - requests.post(BOT_LOCAL_URL, json=payload, timeout=2) - except requests.exceptions.RequestException as e: + # On tente de relayer au bot discord. Timeout légèrement augmenté pour plus de stabilité. + # On ne passe plus de token interne pour simplifier comme demandé. + requests.post(BOT_LOCAL_URL, json=payload, timeout=5) + except Exception as e: print(f"[!] Erreur de relais vers le bot Discord : {e}") # On renvoie 200 à GitLab quand même car GitLab n'a pas à savoir l'état interne diff --git a/src/advanced_logger.py b/src/advanced_logger.py index e8a34fb..e7b0558 100755 --- a/src/advanced_logger.py +++ b/src/advanced_logger.py @@ -1,6 +1,7 @@ import discord import json import os +import asyncio from datetime import datetime from typing import Optional, Dict, List from src.logger import kuby_logger diff --git a/src/logger.py b/src/logger.py index d04aaaa..ac149d8 100755 --- a/src/logger.py +++ b/src/logger.py @@ -63,7 +63,7 @@ class KubyLogger: def _load_reported_errors(self): if os.path.exists(REPORTED_ERRORS_FILE): try: - with open(REPORTED_ERRORS_FILE, "r") as f: + with open(REPORTED_ERRORS_FILE, "r", encoding="utf-8") as f: return json.load(f) except Exception as e: print(f"Error loading reported errors: {e}") @@ -72,8 +72,10 @@ class KubyLogger: def _save_reported_errors(self): try: os.makedirs(os.path.dirname(REPORTED_ERRORS_FILE), exist_ok=True) - with open(REPORTED_ERRORS_FILE, "w") as f: + temp_file = f"{REPORTED_ERRORS_FILE}.tmp" + with open(temp_file, "w", encoding="utf-8") as f: json.dump(self.last_errors, f, indent=4) + os.replace(temp_file, REPORTED_ERRORS_FILE) except Exception as e: print(f"Error saving reported errors: {e}") diff --git a/test_staff_ratings.py b/test_staff_ratings.py new file mode 100644 index 0000000..7216672 --- /dev/null +++ b/test_staff_ratings.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +""" +Test script to verify the staff ratings implementation +""" + +import sys +import os + +# Add the project root to the path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +def test_imports(): + """Test that all modules can be imported correctly""" + try: + from commandes.ticket.staff_analytics import StaffAnalytics + print("✓ StaffAnalytics imported successfully") + + from commandes.ticket.feedback_view import FeedbackView + print("✓ FeedbackView imported successfully") + + from commandes.ticket import TicketPanelView + print("✓ TicketPanelView imported successfully") + + from commandes.staff_ratings import StaffRatings + print("✓ StaffRatings imported successfully") + + from commandes.staff_leaderboard import StaffLeaderboard + print("✓ StaffLeaderboard imported successfully") + + return True + except Exception as e: + print(f"✗ Import error: {e}") + return False + +def test_requirements(): + """Test that required packages are available""" + try: + import matplotlib + import plotly + print("✓ matplotlib and plotly imported successfully") + return True + except Exception as e: + print(f"✗ Package import error: {e}") + return False + +if __name__ == "__main__": + print("Testing staff ratings implementation...") + print("=" * 40) + + success = True + success &= test_imports() + success &= test_requirements() + + print("=" * 40) + if success: + print("✓ All tests passed!") + sys.exit(0) + else: + print("✗ Some tests failed!") + sys.exit(1) \ No newline at end of file