Merge branch 'dev' into 'main'

Correction des bugs de Red Dust Redemption et ajout des fonctionnalités demander

See merge request Omega_Kube/kuby!11
This commit is contained in:
Gameur 2026-05-01 16:19:45 +02:00
commit f2e58f6ba0
16 changed files with 827 additions and 359 deletions

View file

@ -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.

View file

@ -16,16 +16,20 @@ REPORTS_FILE = os.path.join(BASE_DIR, "data", "gitlab_reports.json")
def save_report(issue_iid, user_id): def save_report(issue_iid, user_id):
os.makedirs(os.path.dirname(REPORTS_FILE), exist_ok=True) os.makedirs(os.path.dirname(REPORTS_FILE), exist_ok=True)
try: try:
data = {}
if os.path.exists(REPORTS_FILE): if os.path.exists(REPORTS_FILE):
with open(REPORTS_FILE, "r") as f: try:
data = json.load(f) with open(REPORTS_FILE, "r", encoding="utf-8") as f:
else: data = json.load(f)
data = {} except json.JSONDecodeError:
kuby_logger.warning("REPORTS_FILE corrupted, re-initializing to avoid tracking loss.")
data[str(issue_iid)] = user_id 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) json.dump(data, f, indent=4)
os.replace(temp_file, REPORTS_FILE)
except Exception as e: except Exception as e:
kuby_logger.error(f"Error saving report to JSON: {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()) self.bot.loop.create_task(cleanup())
async def handle_bot_event(self, request): async def handle_bot_event(self, request):
# Vérification de la sécurité (Secret Token GitLab) # Sécurité interne désactivée comme demandé (le port 5001 n'est accessible que via localhost)
webhook_secret = os.getenv("GITLAB_WEBHOOK_SECRET") provided_token = request.headers.get("X-Gitlab-Token")
if webhook_secret: # On ignore la vérification du token pour faciliter le relais local
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)
try: try:
# On attend que le bot soit prêt si on vient de rebooter # 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): if not os.path.exists(REPORTS_FILE):
return web.json_response({"status": "no reports file"}, status=200) return web.json_response({"status": "no reports file"}, status=200)
with open(REPORTS_FILE, "r") as f: try:
reports = json.load(f) 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)) user_id = reports.get(str(issue_iid))
if not user_id: if not user_id:
@ -226,6 +230,7 @@ class BugReport(commands.Cog):
current_labels_str = "Mis à jour" current_labels_str = "Mis à jour"
try: try:
# Création de l'embed pour la mise à jour de statut
embed = discord.Embed( embed = discord.Embed(
title="🛠️ Mise à jour de votre signalement !", title="🛠️ Mise à jour de votre signalement !",
description=f"Le statut de votre rapport **{issue_title}** a été mis à jour par l'équipe.", 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: try:
await user.send(embed=embed) 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: 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: 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: if dev and dev.id != user.id:
try: await dev.send(f"⚠️ [DM BLOQUÉ] {user} ({user.id}) a ses MPs fermés. Impossible de notifier pour l'issue #{issue_iid}.")
await dev.send(f"⚠️ [DM BLOQUÉ] Impossible de notifier l'utilisateur {user} ({user.id}) pour l'issue #{issue_iid} (DMs désactivés).") except Exception as e:
except discord.Forbidden: kuby_logger.error(f"Erreur lors de l'envoi du MP de statut à {user}: {e}")
pass
except discord.HTTPException as e:
kuby_logger.error(f"HTTP error sending DM to {user}: {e}")
except Exception as 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 # 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: try:
embed = discord.Embed( embed = discord.Embed(
title="🛠️ Bug / Suggestion Terminé(e) !", title="🛠️ Bug / Suggestion Terminé(e) !",
@ -262,19 +269,17 @@ class BugReport(commands.Cog):
try: try:
await user.send(embed=embed) 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: 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: 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: 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).")
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:
except discord.Forbidden: kuby_logger.error(f"Erreur lors de l'envoi du MP de clôture à {user}: {e}")
pass
except discord.HTTPException as e:
kuby_logger.error(f"HTTP error sending closure DM to {user}: {e}")
except Exception as 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": elif event_type == "note":
note_attr = payload.get("object_attributes", {}) note_attr = payload.get("object_attributes", {})
@ -294,19 +299,17 @@ class BugReport(commands.Cog):
try: try:
await user.send(embed=embed) 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: 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: 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: 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).")
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:
except discord.Forbidden: kuby_logger.error(f"Erreur lors de l'envoi du MP de commentaire à {user}: {e}")
pass
except discord.HTTPException as e:
kuby_logger.error(f"HTTP error sending comment DM to {user}: {e}")
except Exception as 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) return web.json_response({"status": "success"}, status=200)

View file

@ -28,7 +28,8 @@ class Goodbye(commands.Cog):
welcome_banner_background TEXT, welcome_banner_background TEXT,
welcome_channel_message TEXT, welcome_channel_message TEXT,
goodbye_server_name 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: if 'goodbye_message' not in columns:
cursor.execute('ALTER TABLE server_configs ADD COLUMN goodbye_message TEXT') 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.commit()
conn.close() conn.close()
@ -73,7 +77,7 @@ class Goodbye(commands.Cog):
seconds = delta.seconds % 60 seconds = delta.seconds % 60
return f"il y a {seconds} {'seconde' if seconds <= 1 else 'secondes'}" 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__) current_dir = os.path.dirname(__file__)
base_dir = os.path.abspath(os.path.join(current_dir, "..")) 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: async with session.get(avatar_url) as resp:
avatar_bytes = await resp.read() 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) draw = ImageDraw.Draw(background)
font_large = ImageFont.truetype(os.path.join(base_dir, 'custom_font_large.otf'), 70) 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): async def on_member_remove(self, member):
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)
cursor = conn.cursor() 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() result = cursor.fetchone()
conn.close() conn.close()
@ -118,13 +133,15 @@ class Goodbye(commands.Cog):
goodbye_channel = result[0] goodbye_channel = result[0]
server_name = result[1] server_name = result[1]
custom_message = result[2] custom_message = result[2]
banner_background = result[3]
channel = member.guild.get_channel(goodbye_channel) channel = member.guild.get_channel(goodbye_channel)
if channel: if channel:
img = await self.create_goodbye_image( img = await self.create_goodbye_image(
member.name, member.name,
member.avatar.url if member.avatar else member.default_avatar.url, 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) joined_message = self.humanize_time_delta(member.joined_at)
@ -138,23 +155,67 @@ class Goodbye(commands.Cog):
embed.set_image(url="attachment://goodbye.png") embed.set_image(url="attachment://goodbye.png")
await channel.send(embed=embed, file=discord.File(img, filename="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.command(name="setgoodbye", description="Définit le salon d'aurevoir et les paramètres personnalisés")
@app_commands.checks.has_permissions(administrator=True) @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) conn = sqlite3.connect(self.db_path)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute('''INSERT INTO server_configs (server_id, goodbye_channel, goodbye_server_name, goodbye_message) cursor.execute('''INSERT INTO server_configs (server_id, goodbye_channel, goodbye_server_name, goodbye_message, goodbye_banner_background)
VALUES (?, ?, ?, ?) ON CONFLICT(server_id) VALUES (?, ?, ?, ?, ?) ON CONFLICT(server_id)
DO UPDATE SET goodbye_channel=excluded.goodbye_channel, DO UPDATE SET goodbye_channel=excluded.goodbye_channel,
goodbye_server_name=COALESCE(excluded.goodbye_server_name, goodbye_server_name), goodbye_server_name=COALESCE(excluded.goodbye_server_name, goodbye_server_name),
goodbye_message=COALESCE(excluded.goodbye_message, goodbye_message)''', goodbye_message=COALESCE(excluded.goodbye_message, goodbye_message),
(interaction.guild_id, channel.id, server_name, 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.commit()
conn.close() conn.close()
msg = f"✅ Salon d'aurevoir défini sur {channel.mention}" msg = f"✅ Salon d'aurevoir défini sur {channel.mention}"
if server_name: msg += f"\nNom du serveur: {server_name}" if server_name: msg += f"\nNom du serveur: {server_name}"
if message: msg += f"\nMessage: {message}" 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) await interaction.response.send_message(msg, ephemeral=True)

View file

@ -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))

138
commandes/staff_ratings.py Normal file
View file

@ -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))

View file

@ -39,6 +39,17 @@ from .staff_analytics import StaffAnalytics, StaffStatsView
from .utils.permissions import is_staff, is_whitelisted, can_access_config from .utils.permissions import is_staff, is_whitelisted, can_access_config
from src.logger import kuby_logger 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): class TicketPanelView(discord.ui.View):
"""Panel principal avec toutes les options intégrées""" """Panel principal avec toutes les options intégrées"""
@ -83,7 +94,16 @@ class TicketPanelView(discord.ui.View):
) )
analytics_btn.callback = self._analytics_callback analytics_btn.callback = self._analytics_callback
self.add_item(analytics_btn) 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) # Configuration (admin only)
config_btn = discord.ui.Button( config_btn = discord.ui.Button(
style=discord.ButtonStyle.secondary, 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) 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: def _is_staff(self, interaction: discord.Interaction) -> bool:
return is_staff(interaction, self.config) 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) guild.me: discord.PermissionOverwrite(read_messages=True, send_messages=True)
} }
# Add staff roles # Add staff roles (category-specific roles override global ones)
for role_id in self.config.staff_roles: staff_roles_to_add = self.category.staff_roles if self.category.staff_roles else self.config.staff_roles
role = guild.get_role(int(role_id)) for role_id in staff_roles_to_add:
if role and role not in overwrites: try:
overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=True) 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 # Create channel name using username and category without numbers
safe_username = "".join(c for c in user.display_name if not c.isdigit()).strip() 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: try:
category_channel = None 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: try:
category_channel = guild.get_channel(int(self.config.default_category)) category_channel = guild.get_channel(int(self.config.default_category))
except (ValueError, TypeError): except (ValueError, TypeError):
@ -582,11 +694,11 @@ class TicketManagementView(discord.ui.View):
self.ticket = ticket self.ticket = ticket
self.category = config.get_category(ticket.category_id) self.category = config.get_category(ticket.category_id)
# Claim (if enabled and ticket is 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 == TicketStatus.OPEN: 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( claim_btn = discord.ui.Button(
style=discord.ButtonStyle.primary, style=discord.ButtonStyle.primary,
label="Réclamer", label="Réclamer" if ticket.status == TicketStatus.OPEN else "Transférer",
custom_id="ticket_claim" custom_id="ticket_claim"
) )
claim_btn.callback = self._claim_callback 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) await interaction.response.send_message("Réservé au staff.", ephemeral=True)
return return
if self.ticket.claimed_by: if self.ticket.claimed_by == interaction.user.id:
await interaction.response.send_message("Ce ticket est déjà réclamé.", ephemeral=True) await interaction.response.send_message("Vous avez déjà réclamé ce ticket.", ephemeral=True)
return return
old_claimer = self.ticket.claimed_by
self.ticket.claimed_by = interaction.user.id self.ticket.claimed_by = interaction.user.id
self.ticket.status = TicketStatus.CLAIMED self.ticket.status = TicketStatus.CLAIMED
self.storage.save_ticket(self.ticket) self.storage.save_ticket(self.ticket)
# Update view (remove claim button) # Update view (change label)
for item in self.children: for item in self.children:
if item.custom_id == "ticket_claim": if item.custom_id == "ticket_claim":
self.remove_item(item) item.label = "Transférer"
break break
# Force UI update # Force UI update
await interaction.response.edit_message(view=self) 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) await interaction.channel.send(embed=embed)
async def _reopen_callback(self, interaction: discord.Interaction): 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) await interaction.response.send_message("Ticket rouvert!", ephemeral=True)
def _is_staff(self, interaction: discord.Interaction) -> bool: 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: 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: if member.guild_permissions.administrator:
return True return True
if member.id in self.config.staff_users: if member.id in self.config.staff_users:
return True return True
for role_id in self.config.staff_roles: # Check category-specific staff roles first
role = member.guild.get_role(int(role_id)) staff_roles = self.category.staff_roles if self.category and self.category.staff_roles else self.config.staff_roles
if role and role in member.roles: for role_id in staff_roles:
return True 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 return False
@ -788,6 +921,7 @@ class CloseConfirmationView(discord.ui.View):
self.storage = storage self.storage = storage
self.config = config self.config = config
self.ticket = ticket self.ticket = ticket
self.category = config.get_category(ticket.category_id)
@discord.ui.button(label="✅ Confirmer", style=discord.ButtonStyle.green, custom_id="close_confirm") @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): 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() await interaction.delete_original_response()
def _is_staff(self, interaction: discord.Interaction) -> bool: def _is_staff(self, interaction: discord.Interaction) -> bool:
guild = interaction.guild return is_staff(interaction, self.config, self.category)
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
class CloseDelayView(discord.ui.View): class CloseDelayView(discord.ui.View):
@ -934,13 +1062,24 @@ class CloseDelayView(discord.ui.View):
if self.close_reason: if self.close_reason:
dm_embed.add_field(name="Raison de fermeture", value=self.close_reason, inline=False) 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) 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) await user.send(embed=dm_embed, view=view)
except Exception as e: except Exception as e:
print(f"Failed to send feedback DM: {e}") print(f"Failed to send feedback DM: {e}")
# Send transcript to log channel # 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: try:
log_channel = interaction.guild.get_channel(self.config.log_channel_id) log_channel = interaction.guild.get_channel(self.config.log_channel_id)
if log_channel: if log_channel:
@ -961,8 +1100,12 @@ class CloseDelayView(discord.ui.View):
if self.close_reason: if self.close_reason:
log_embed.add_field(name="Raison", value=self.close_reason, inline=False) log_embed.add_field(name="Raison", value=self.close_reason, inline=False)
file = discord.File(transcript_file, filename=os.path.basename(transcript_file)) if transcript_file and os.path.exists(transcript_file):
await log_channel.send(embed=log_embed, file=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: except Exception as e:
print(f"Error sending transcript to log channel: {e}") print(f"Error sending transcript to log channel: {e}")
@ -977,6 +1120,11 @@ class CloseDelayView(discord.ui.View):
color=discord.Color.red() color=discord.Color.red()
) )
await interaction.channel.send(embed=embed) 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): async def _cancel_callback(self, interaction: discord.Interaction):
"""Annule la fermeture du ticket pendant le délai""" """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) await interaction.channel.send(embed=embed, delete_after=5)
def _is_staff(self, interaction: discord.Interaction) -> bool: def _is_staff(self, interaction: discord.Interaction) -> bool:
guild = interaction.guild return is_staff(interaction, self.config, self.category)
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
def _is_staff_member(self, member) -> bool: def _is_staff_member(self, member) -> bool:
guild = member.guild """Check if a specific member is staff, considering category roles"""
for role_id in self.config.staff_roles: if not member:
role = guild.get_role(int(role_id)) return False
if role and role in member.roles:
return True # 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 return False
@ -1125,7 +1285,7 @@ class CloseTicketModal(discord.ui.Modal):
duration = self.ticket.duration_minutes duration = self.ticket.duration_minutes
# Send transcript to log channel if configured # 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: try:
log_channel = interaction.guild.get_channel(self.config.log_channel_id) log_channel = interaction.guild.get_channel(self.config.log_channel_id)
if log_channel: 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) log_embed.add_field(name="Raison de fermeture", value=self.reason.value, inline=False)
# Send transcript file # Send transcript file
file = discord.File(transcript_file, filename=os.path.basename(transcript_file)) if transcript_file and os.path.exists(transcript_file):
await log_channel.send(embed=log_embed, file=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: except Exception as e:
print(f"Error sending transcript to log channel: {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) 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: def _is_staff(self, interaction: discord.Interaction) -> bool:
guild = interaction.guild return is_staff(interaction, self.config, self.category)
user = interaction.user
for role_id in self.config.staff_roles: def _is_staff_member(self, member) -> bool:
role = guild.get_role(int(role_id)) """Check if a specific member is staff, considering category roles"""
if role and role in user.roles: if not member:
return True return False
return user.guild_permissions.administrator
# 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): class TicketSetupView(discord.ui.View):
@ -2039,7 +2222,9 @@ class CategoryActionView(discord.ui.View):
description=f"**ID:** `{self.category.id}`\n" description=f"**ID:** `{self.category.id}`\n"
f"**Emoji:** {self.category.emoji}\n" f"**Emoji:** {self.category.emoji}\n"
f"**Description:** {self.category.description or 'Aucune'}\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() color=discord.Color.blue()
) )
return embed return embed
@ -2049,6 +2234,29 @@ class CategoryActionView(discord.ui.View):
modal = EditCategoryModal(self.bot, self.storage, self.config, self.category, self) modal = EditCategoryModal(self.bot, self.storage, self.config, self.category, self)
await interaction.response.send_modal(modal) 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) @discord.ui.button(label="🗑️ Supprimer", style=discord.ButtonStyle.danger)
async def delete_callback(self, interaction: discord.Interaction, button: discord.ui.Button): 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) 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() self.parent_view._add_category_select()
await interaction.response.edit_message(embed=self.parent_view._get_embed(), view=self.parent_view) 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): class DeleteCategoryConfirmationView(discord.ui.View):
def __init__(self, bot, storage, config, category, parent_view): def __init__(self, bot, storage, config, category, parent_view):

View file

@ -59,7 +59,8 @@ class TicketCategory:
auto_close_days: int = 7, auto_close_days: int = 7,
priority_enabled: bool = True, priority_enabled: bool = True,
allow_claims: bool = True, allow_claims: bool = True,
survey_enabled: bool = True survey_enabled: bool = True,
discord_category_id: Optional[int] = None
): ):
self.id = id self.id = id
self.name = name self.name = name
@ -74,6 +75,7 @@ class TicketCategory:
self.priority_enabled = priority_enabled self.priority_enabled = priority_enabled
self.allow_claims = allow_claims self.allow_claims = allow_claims
self.survey_enabled = survey_enabled self.survey_enabled = survey_enabled
self.discord_category_id = discord_category_id
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
"""Convert category to dictionary for storage""" """Convert category to dictionary for storage"""
@ -90,7 +92,8 @@ class TicketCategory:
"auto_close_days": self.auto_close_days, "auto_close_days": self.auto_close_days,
"priority_enabled": self.priority_enabled, "priority_enabled": self.priority_enabled,
"allow_claims": self.allow_claims, "allow_claims": self.allow_claims,
"survey_enabled": self.survey_enabled "survey_enabled": self.survey_enabled,
"discord_category_id": self.discord_category_id
} }
@classmethod @classmethod
@ -109,7 +112,8 @@ class TicketCategory:
auto_close_days=data.get("auto_close_days", 7), auto_close_days=data.get("auto_close_days", 7),
priority_enabled=data.get("priority_enabled", True), priority_enabled=data.get("priority_enabled", True),
allow_claims=data.get("allow_claims", 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")
) )

View file

@ -2,13 +2,14 @@ import discord
from .data.models import TicketData from .data.models import TicketData
class FeedbackView(discord.ui.View): 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 super().__init__(timeout=86400) # 24h timeout
self.bot = bot self.bot = bot
self.storage = storage self.storage = storage
self.ticket = ticket self.ticket = ticket
self.rating = 0 self.rating = 0
self.comment = "" self.comment = ""
self.staff_rating_only = staff_rating_only
# Add Select Menu for Rating # Add Select Menu for Rating
self.add_item(RatingSelect()) self.add_item(RatingSelect())
@ -32,7 +33,11 @@ class FeedbackView(discord.ui.View):
for child in self.children: for child in self.children:
child.disabled = True 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 # Log feedback if configured
try: try:

View file

@ -9,6 +9,11 @@ from collections import defaultdict
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
import discord 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 PIL import Image, ImageDraw, ImageFont
from .data.storage import get_storage from .data.storage import get_storage
@ -102,213 +107,74 @@ class StaffAnalytics:
return result return result
def create_staff_rating_chart(self, staff_list: List[Dict[str, Any]]) -> io.BytesIO: 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 # Filter staff with ratings
rated_staff = [s for s in staff_list if s['total_ratings'] > 0] rated_staff = [s for s in staff_list if s['total_ratings'] > 0]
if not rated_staff: if not rated_staff:
# Return empty chart # 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() buf = io.BytesIO()
img.save(buf, format='PNG') plt.savefig(buf, format='png', bbox_inches='tight', facecolor='#2F3136')
buf.seek(0) buf.seek(0)
plt.close()
return buf return buf
# Image dimensions # Sort by average rating
width, height = 1200, 700 rated_staff.sort(key=lambda x: x['average_rating'], reverse=True)
img = Image.new('RGB', (width, height), '#36393F') # Discord dark theme
draw = ImageDraw.Draw(img) # Prepare data - limit to top 10 for better readability
top_staff = rated_staff[:10]
# Try to load fonts with better Unicode support names = [s['name'][:15] + '...' if len(s['name']) > 15 else s['name'] for s in top_staff]
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)
ratings = [s['average_rating'] for s in top_staff] ratings = [s['average_rating'] for s in top_staff]
counts = [s['total_ratings'] for s in top_staff] counts = [s['total_ratings'] for s in top_staff]
if not ratings: # Create figure
buf = io.BytesIO() fig, ax = plt.subplots(figsize=(12, 8), facecolor='#2F3136')
img.save(buf, format='PNG') fig.patch.set_facecolor('#2F3136')
buf.seek(0) ax.set_facecolor('#2F3136')
return buf
# Create bars with color coding
max_rating = 5 colors = ['#57F287' if r >= 4.5 else '#FEE75C' if r >= 3.5 else '#FAA61A' if r >= 2.5 else '#ED4245' for r in ratings]
bar_width = min(80, chart_width // len(ratings)) if ratings else 1 bars = ax.bar(range(len(ratings)), ratings, color=colors)
spacing = 20
# Customize
# Draw background grid ax.set_xticks(range(len(names)))
for i in range(6): ax.set_xticklabels(names, rotation=45, ha='right', color='white')
y = chart_bottom - (i * chart_height // 5) ax.set_ylabel('Note moyenne', color='white')
draw.line([chart_left, y, chart_right, y], fill='#72767D', width=1) ax.set_title('Évaluations des Membres du Staff', color='white', fontsize=16)
# Rating labels ax.tick_params(colors='white')
draw.text((chart_left - 40, y), f"{i}", fill='#FFFFFF', font=font_value, anchor='mm') ax.grid(True, alpha=0.3, color='gray')
# Draw bars with gradient effect # Add value labels on bars (avoiding Unicode stars to prevent warnings)
for i, (name, rating, count) in enumerate(zip(names, ratings, counts)): for i, (bar, rating, count) in enumerate(zip(bars, ratings, counts)):
bar_height = int((rating / max_rating) * chart_height) 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)
x1 = chart_left + i * (bar_width + spacing) + spacing//2
y1 = chart_bottom - bar_height # Set y-axis limits
x2 = x1 + bar_width ax.set_ylim(0, 5.2)
y2 = chart_bottom
# Add legend
# Bar color based on rating with better colors legend_elements = [
if rating >= 4.5: plt.Rectangle((0,0),1,1, color='#57F287', label='Excellent (4.5+)'),
color = '#57F287' # Green plt.Rectangle((0,0),1,1, color='#FEE75C', label='Bon (3.5-4.4)'),
shadow_color = '#4CAF50' plt.Rectangle((0,0),1,1, color='#FAA61A', label='Moyen (2.5-3.4)'),
elif rating >= 3.5: plt.Rectangle((0,0),1,1, color='#ED4245', label='À améliorer (<2.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')
] ]
ax.legend(handles=legend_elements, loc='upper right', facecolor='#2F3136', edgecolor='white', labelcolor='white')
for i, (emoji_text, range_text, color) in enumerate(legend_items):
y_pos = legend_y + 25 + i * 20 # Adjust layout
draw.text((legend_x, y_pos), f"{emoji_text} {range_text}", fill=color, font=font_value) plt.tight_layout()
# Add some stats at the bottom # Save to buffer
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')
buf = io.BytesIO() buf = io.BytesIO()
img.save(buf, format='PNG') plt.savefig(buf, format='png', bbox_inches='tight', facecolor='#2F3136')
buf.seek(0) buf.seek(0)
plt.close()
return buf return buf
def create_individual_staff_chart(self, staff_data: Dict[str, Any]) -> io.BytesIO: def create_individual_staff_chart(self, staff_data: Dict[str, Any]) -> io.BytesIO:

View file

@ -7,16 +7,15 @@ import discord
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING: 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. Check if the user has staff permissions.
Checks: If a category is provided and has specific staff_roles defined,
1. Administrator permission only those roles (and admins) are considered staff for this check.
2. Staff roles Otherwise, global staff roles from the config are used.
3. Staff users (specific user IDs)
""" """
# Get user and permissions based on object type # Get user and permissions based on object type
user = getattr(interaction, "author", getattr(interaction, "user", None)) 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: if user.guild_permissions.administrator:
return True return True
# Check specific users (always allowed)
# Check specific users
if user.id in config.staff_users: if user.id in config.staff_users:
return True 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: for role_id in config.staff_roles:
role = interaction.guild.get_role(int(role_id)) try:
if role and role in user.roles: role = interaction.guild.get_role(int(role_id))
return True if role and role in user.roles:
return True
except (ValueError, TypeError):
continue
return False return False

View file

@ -1,6 +1,6 @@
[ [
{ {
"date": "2026-03-31T20:07:49.786458+00:00", "date": "2026-05-01T14:11:08.947818+00:00",
"trigger": "auto", "trigger": "auto",
"serversCount": 1, "serversCount": 1,
"membersCount": 12, "membersCount": 12,
@ -9,7 +9,7 @@
"success": true "success": true
}, },
{ {
"date": "2026-03-31T20:06:47.718241+00:00", "date": "2026-05-01T13:48:18.180388+00:00",
"trigger": "auto", "trigger": "auto",
"serversCount": 1, "serversCount": 1,
"membersCount": 12, "membersCount": 12,
@ -18,7 +18,7 @@
"success": true "success": true
}, },
{ {
"date": "2026-03-31T20:06:07.381254+00:00", "date": "2026-05-01T13:36:45.397039+00:00",
"trigger": "auto", "trigger": "auto",
"serversCount": 1, "serversCount": 1,
"membersCount": 12, "membersCount": 12,
@ -27,7 +27,7 @@
"success": true "success": true
}, },
{ {
"date": "2026-03-31T20:00:43.918120+00:00", "date": "2026-05-01T13:20:32.058899+00:00",
"trigger": "auto", "trigger": "auto",
"serversCount": 1, "serversCount": 1,
"membersCount": 12, "membersCount": 12,
@ -36,7 +36,7 @@
"success": true "success": true
}, },
{ {
"date": "2026-03-29T20:16:08.626593+00:00", "date": "2026-04-23T14:35:15.364216+00:00",
"trigger": "auto", "trigger": "auto",
"serversCount": 1, "serversCount": 1,
"membersCount": 12, "membersCount": 12,
@ -45,7 +45,7 @@
"success": true "success": true
}, },
{ {
"date": "2026-03-29T19:16:08.300074+00:00", "date": "2026-04-23T13:35:15.351819+00:00",
"trigger": "auto", "trigger": "auto",
"serversCount": 1, "serversCount": 1,
"membersCount": 12, "membersCount": 12,
@ -54,7 +54,7 @@
"success": true "success": true
}, },
{ {
"date": "2026-03-29T18:16:08.376405+00:00", "date": "2026-04-23T12:35:15.324906+00:00",
"trigger": "auto", "trigger": "auto",
"serversCount": 1, "serversCount": 1,
"membersCount": 12, "membersCount": 12,
@ -63,7 +63,7 @@
"success": true "success": true
}, },
{ {
"date": "2026-03-29T17:16:08.514497+00:00", "date": "2026-04-23T11:35:15.210303+00:00",
"trigger": "auto", "trigger": "auto",
"serversCount": 1, "serversCount": 1,
"membersCount": 12, "membersCount": 12,
@ -72,7 +72,7 @@
"success": true "success": true
}, },
{ {
"date": "2026-03-29T17:15:42.635599+00:00", "date": "2026-04-23T10:35:15.338465+00:00",
"trigger": "auto", "trigger": "auto",
"serversCount": 1, "serversCount": 1,
"membersCount": 12, "membersCount": 12,
@ -81,7 +81,7 @@
"success": true "success": true
}, },
{ {
"date": "2026-03-29T17:15:41.574258+00:00", "date": "2026-04-23T10:28:55.984163+00:00",
"trigger": "auto", "trigger": "auto",
"serversCount": 1, "serversCount": 1,
"membersCount": 12, "membersCount": 12,
@ -90,7 +90,7 @@
"success": true "success": true
}, },
{ {
"date": "2026-03-29T14:18:49.697634+00:00", "date": "2026-04-02T17:03:30.870748+00:00",
"trigger": "auto", "trigger": "auto",
"serversCount": 1, "serversCount": 1,
"membersCount": 12, "membersCount": 12,
@ -99,7 +99,7 @@
"success": true "success": true
}, },
{ {
"date": "2026-03-29T14:16:08.163385+00:00", "date": "2026-04-01T14:00:13.276907+00:00",
"trigger": "auto", "trigger": "auto",
"serversCount": 1, "serversCount": 1,
"membersCount": 12, "membersCount": 12,
@ -108,7 +108,7 @@
"success": true "success": true
}, },
{ {
"date": "2026-03-29T14:09:18.074641+00:00", "date": "2026-04-01T13:19:44.954622+00:00",
"trigger": "auto", "trigger": "auto",
"serversCount": 1, "serversCount": 1,
"membersCount": 12, "membersCount": 12,
@ -117,7 +117,7 @@
"success": true "success": true
}, },
{ {
"date": "2026-03-29T13:56:24.990061+00:00", "date": "2026-04-01T13:17:23.537128+00:00",
"trigger": "auto", "trigger": "auto",
"serversCount": 1, "serversCount": 1,
"membersCount": 12, "membersCount": 12,
@ -126,7 +126,7 @@
"success": true "success": true
}, },
{ {
"date": "2026-03-29T13:54:43.766970+00:00", "date": "2026-04-01T13:15:14.389369+00:00",
"trigger": "auto", "trigger": "auto",
"serversCount": 1, "serversCount": 1,
"membersCount": 12, "membersCount": 12,
@ -135,7 +135,7 @@
"success": true "success": true
}, },
{ {
"date": "2026-03-29T13:48:39.075696+00:00", "date": "2026-04-01T13:08:00.124432+00:00",
"trigger": "auto", "trigger": "auto",
"serversCount": 1, "serversCount": 1,
"membersCount": 12, "membersCount": 12,
@ -144,7 +144,7 @@
"success": true "success": true
}, },
{ {
"date": "2026-03-29T13:48:08.960324+00:00", "date": "2026-04-01T13:03:42.601666+00:00",
"trigger": "auto", "trigger": "auto",
"serversCount": 1, "serversCount": 1,
"membersCount": 12, "membersCount": 12,
@ -153,7 +153,7 @@
"success": true "success": true
}, },
{ {
"date": "2026-03-29T13:47:39.191350+00:00", "date": "2026-04-01T12:07:50.185260+00:00",
"trigger": "auto", "trigger": "auto",
"serversCount": 1, "serversCount": 1,
"membersCount": 12, "membersCount": 12,
@ -162,7 +162,7 @@
"success": true "success": true
}, },
{ {
"date": "2026-03-29T13:47:09.059722+00:00", "date": "2026-04-01T11:07:49.769763+00:00",
"trigger": "auto", "trigger": "auto",
"serversCount": 1, "serversCount": 1,
"membersCount": 12, "membersCount": 12,
@ -171,7 +171,7 @@
"success": true "success": true
}, },
{ {
"date": "2026-03-29T13:46:39.263054+00:00", "date": "2026-04-01T10:07:49.758778+00:00",
"trigger": "auto", "trigger": "auto",
"serversCount": 1, "serversCount": 1,
"membersCount": 12, "membersCount": 12,

View file

@ -4,3 +4,5 @@ Pillow>=10.0.0
aiohttp>=3.8.0 aiohttp>=3.8.0
flask>=3.0.0 flask>=3.0.0
requests>=2.31.0 requests>=2.31.0
matplotlib>=3.7.0
plotly>=5.15.0

View file

@ -93,9 +93,10 @@ def gitlab_webhook():
# Transférer la payload au bot Discord localement sur le port 5001 # Transférer la payload au bot Discord localement sur le port 5001
payload = request.json payload = request.json
try: try:
# Timeout très court car c'est en local # On tente de relayer au bot discord. Timeout légèrement augmenté pour plus de stabilité.
requests.post(BOT_LOCAL_URL, json=payload, timeout=2) # On ne passe plus de token interne pour simplifier comme demandé.
except requests.exceptions.RequestException as e: requests.post(BOT_LOCAL_URL, json=payload, timeout=5)
except Exception as e:
print(f"[!] Erreur de relais vers le bot Discord : {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 # On renvoie 200 à GitLab quand même car GitLab n'a pas à savoir l'état interne

View file

@ -1,6 +1,7 @@
import discord import discord
import json import json
import os import os
import asyncio
from datetime import datetime from datetime import datetime
from typing import Optional, Dict, List from typing import Optional, Dict, List
from src.logger import kuby_logger from src.logger import kuby_logger

View file

@ -63,7 +63,7 @@ class KubyLogger:
def _load_reported_errors(self): def _load_reported_errors(self):
if os.path.exists(REPORTED_ERRORS_FILE): if os.path.exists(REPORTED_ERRORS_FILE):
try: try:
with open(REPORTED_ERRORS_FILE, "r") as f: with open(REPORTED_ERRORS_FILE, "r", encoding="utf-8") as f:
return json.load(f) return json.load(f)
except Exception as e: except Exception as e:
print(f"Error loading reported errors: {e}") print(f"Error loading reported errors: {e}")
@ -72,8 +72,10 @@ class KubyLogger:
def _save_reported_errors(self): def _save_reported_errors(self):
try: try:
os.makedirs(os.path.dirname(REPORTED_ERRORS_FILE), exist_ok=True) 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) json.dump(self.last_errors, f, indent=4)
os.replace(temp_file, REPORTED_ERRORS_FILE)
except Exception as e: except Exception as e:
print(f"Error saving reported errors: {e}") print(f"Error saving reported errors: {e}")

60
test_staff_ratings.py Normal file
View file

@ -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)