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:
commit
f2e58f6ba0
16 changed files with 827 additions and 359 deletions
|
|
@ -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.
|
|
||||||
|
|
@ -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:
|
||||||
if os.path.exists(REPORTS_FILE):
|
|
||||||
with open(REPORTS_FILE, "r") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
else:
|
|
||||||
data = {}
|
data = {}
|
||||||
|
if os.path.exists(REPORTS_FILE):
|
||||||
|
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
|
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")
|
|
||||||
if webhook_secret:
|
|
||||||
provided_token = request.headers.get("X-Gitlab-Token")
|
provided_token = request.headers.get("X-Gitlab-Token")
|
||||||
if provided_token != webhook_secret:
|
# On ignore la vérification du token pour faciliter le relais local
|
||||||
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:
|
||||||
|
with open(REPORTS_FILE, "r", encoding="utf-8") as f:
|
||||||
reports = json.load(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 discord.Forbidden:
|
|
||||||
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 lors de l'envoi du MP de statut à {user}: {e}")
|
||||||
|
except Exception as 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 discord.Forbidden:
|
|
||||||
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 lors de l'envoi du MP de clôture à {user}: {e}")
|
||||||
|
except Exception as 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 discord.Forbidden:
|
|
||||||
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 lors de l'envoi du MP de commentaire à {user}: {e}")
|
||||||
|
except Exception as 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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,6 +85,17 @@ 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()
|
||||||
|
|
||||||
|
# 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'))
|
background = Image.open(os.path.join(base_dir, 'background_template_goodbye.png'))
|
||||||
draw = ImageDraw.Draw(background)
|
draw = ImageDraw.Draw(background)
|
||||||
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
73
commandes/staff_leaderboard.py
Normal file
73
commandes/staff_leaderboard.py
Normal 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
138
commandes/staff_ratings.py
Normal 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))
|
||||||
|
|
@ -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"""
|
||||||
|
|
@ -84,6 +95,15 @@ 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
|
||||||
|
for role_id in staff_roles_to_add:
|
||||||
|
try:
|
||||||
role = guild.get_role(int(role_id))
|
role = guild.get_role(int(role_id))
|
||||||
if role and role not in overwrites:
|
if role and role not in overwrites:
|
||||||
overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=True)
|
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)
|
||||||
|
|
||||||
|
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)
|
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
|
||||||
|
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))
|
role = member.guild.get_role(int(role_id))
|
||||||
if role and role in member.roles:
|
if role and role in member.roles:
|
||||||
return True
|
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)
|
||||||
|
|
||||||
|
if transcript_file and os.path.exists(transcript_file):
|
||||||
file = discord.File(transcript_file, filename=os.path.basename(transcript_file))
|
file = discord.File(transcript_file, filename=os.path.basename(transcript_file))
|
||||||
await log_channel.send(embed=log_embed, file=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}")
|
||||||
|
|
||||||
|
|
@ -978,6 +1121,11 @@ class CloseDelayView(discord.ui.View):
|
||||||
)
|
)
|
||||||
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"""
|
||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
# 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:
|
if role and role in member.roles:
|
||||||
return True
|
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
|
||||||
|
if transcript_file and os.path.exists(transcript_file):
|
||||||
file = discord.File(transcript_file, filename=os.path.basename(transcript_file))
|
file = discord.File(transcript_file, filename=os.path.basename(transcript_file))
|
||||||
await log_channel.send(embed=log_embed, file=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 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
|
return True
|
||||||
return user.guild_permissions.administrator
|
|
||||||
|
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):
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
# 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)
|
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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
|
# 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]
|
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
|
|
||||||
|
|
||||||
max_rating = 5
|
# Create bars with color coding
|
||||||
bar_width = min(80, chart_width // len(ratings)) if ratings else 1
|
colors = ['#57F287' if r >= 4.5 else '#FEE75C' if r >= 3.5 else '#FAA61A' if r >= 2.5 else '#ED4245' for r in ratings]
|
||||||
spacing = 20
|
bars = ax.bar(range(len(ratings)), ratings, color=colors)
|
||||||
|
|
||||||
# Draw background grid
|
# Customize
|
||||||
for i in range(6):
|
ax.set_xticks(range(len(names)))
|
||||||
y = chart_bottom - (i * chart_height // 5)
|
ax.set_xticklabels(names, rotation=45, ha='right', color='white')
|
||||||
draw.line([chart_left, y, chart_right, y], fill='#72767D', width=1)
|
ax.set_ylabel('Note moyenne', color='white')
|
||||||
# Rating labels
|
ax.set_title('Évaluations des Membres du Staff', color='white', fontsize=16)
|
||||||
draw.text((chart_left - 40, y), f"{i}", fill='#FFFFFF', font=font_value, anchor='mm')
|
ax.tick_params(colors='white')
|
||||||
|
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
|
# Set y-axis limits
|
||||||
y1 = chart_bottom - bar_height
|
ax.set_ylim(0, 5.2)
|
||||||
x2 = x1 + bar_width
|
|
||||||
y2 = chart_bottom
|
|
||||||
|
|
||||||
# Bar color based on rating with better colors
|
# Add legend
|
||||||
if rating >= 4.5:
|
legend_elements = [
|
||||||
color = '#57F287' # Green
|
plt.Rectangle((0,0),1,1, color='#57F287', label='Excellent (4.5+)'),
|
||||||
shadow_color = '#4CAF50'
|
plt.Rectangle((0,0),1,1, color='#FEE75C', label='Bon (3.5-4.4)'),
|
||||||
elif rating >= 3.5:
|
plt.Rectangle((0,0),1,1, color='#FAA61A', label='Moyen (2.5-3.4)'),
|
||||||
color = '#FEE75C' # Yellow
|
plt.Rectangle((0,0),1,1, color='#ED4245', label='À améliorer (<2.5)')
|
||||||
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):
|
# Adjust layout
|
||||||
y_pos = legend_y + 25 + i * 20
|
plt.tight_layout()
|
||||||
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')
|
|
||||||
|
|
||||||
|
# Save to buffer
|
||||||
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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
for role_id in config.staff_roles:
|
if category and category.staff_roles:
|
||||||
|
for role_id in category.staff_roles:
|
||||||
|
try:
|
||||||
role = interaction.guild.get_role(int(role_id))
|
role = interaction.guild.get_role(int(role_id))
|
||||||
if role and role in user.roles:
|
if role and role in user.roles:
|
||||||
return True
|
return True
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check global roles
|
||||||
|
for role_id in config.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
|
return False
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
60
test_staff_ratings.py
Normal 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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue