Correction des multiples bugs

This commit is contained in:
Mathis 2026-05-01 16:16:33 +02:00
parent 5fe70d754b
commit 07198c20b7
15 changed files with 707 additions and 348 deletions

View file

@ -39,6 +39,17 @@ from .staff_analytics import StaffAnalytics, StaffStatsView
from .utils.permissions import is_staff, is_whitelisted, can_access_config
from src.logger import kuby_logger
# Add a task for sending feedback reminders
class FeedbackReminderTask:
def __init__(self, bot):
self.bot = bot
self.reminders = {} # Store active reminders
async def schedule_feedback_reminder(self, user_id: int, ticket_id: int, delay_hours: int = 24):
"""Schedule a feedback reminder after a ticket is closed"""
# This would be implemented with a proper task scheduler
pass
class TicketPanelView(discord.ui.View):
"""Panel principal avec toutes les options intégrées"""
@ -83,7 +94,16 @@ class TicketPanelView(discord.ui.View):
)
analytics_btn.callback = self._analytics_callback
self.add_item(analytics_btn)
# Staff Ratings (staff only)
staff_ratings_btn = discord.ui.Button(
style=discord.ButtonStyle.success,
label="⭐ Notation du Staff",
custom_id="ticket_staff_ratings"
)
staff_ratings_btn.callback = self._staff_ratings_callback
self.add_item(staff_ratings_btn)
# Configuration (admin only)
config_btn = discord.ui.Button(
style=discord.ButtonStyle.secondary,
@ -267,6 +287,85 @@ class TicketPanelView(discord.ui.View):
await interaction.response.send_message(embed=embed, view=config_view, ephemeral=True)
async def _staff_ratings_callback(self, interaction: discord.Interaction):
"""Display staff ratings (staff only)"""
# Reload config
self.config = self.storage.load_config(interaction.guild_id)
if not self._is_staff(interaction):
await interaction.response.send_message("❌ Cette fonctionnalité est réservée au staff.", ephemeral=True)
return
await interaction.response.defer(ephemeral=True)
analytics = StaffAnalytics(self.bot)
# Get staff list for individual stats
staff_list = await analytics.get_staff_list(interaction.guild_id)
# Create main embed with overall stats
embed = discord.Embed(
title="⭐ Notation du Staff",
description="Performance globale du staff",
color=discord.Color.blue(),
timestamp=datetime.now()
)
# Calculate overall stats
tickets = self.storage.get_guild_tickets(interaction.guild_id)
total_tickets = len(tickets)
open_tickets = sum(1 for t in tickets if t.status in [TicketStatus.OPEN, TicketStatus.CLAIMED])
closed_tickets = sum(1 for t in tickets if t.status == TicketStatus.CLOSED)
claimed_tickets = sum(1 for t in tickets if t.claimed_by is not None)
# Basic stats
embed.add_field(
name="📈 Statistiques Générales",
value=f"**Total:** {total_tickets}\n"
f"**Ouverts:** {open_tickets}\n"
f"**Fermés:** {closed_tickets}\n"
f"**Réclamés:** {claimed_tickets}",
inline=True
)
# Staff performance summary
if staff_list:
total_ratings = sum(s['total_ratings'] for s in staff_list)
avg_rating = sum(s['average_rating'] for s in staff_list if s['total_ratings'] > 0) / len([s for s in staff_list if s['total_ratings'] > 0]) if any(s['total_ratings'] > 0 for s in staff_list) else 0
embed.add_field(
name="👥 Équipe Staff",
value=f"**Membres actifs:** {len(staff_list)}\n"
f"**Évaluations totales:** {total_ratings}\n"
f"**Note moyenne globale:** {avg_rating:.1f}" if avg_rating > 0 else "**Note moyenne globale:** N/A",
inline=True
)
# Send main embed
await interaction.followup.send(embed=embed, ephemeral=True)
# Create and send staff rating chart
try:
if staff_list:
rating_chart = analytics.create_staff_rating_chart(staff_list)
await interaction.followup.send(
"📊 **Évaluations des Membres du Staff**",
file=discord.File(rating_chart, 'staff_ratings.png'),
ephemeral=True
)
# Add individual staff stats view
stats_view = StaffStatsView(self.bot, staff_list, analytics)
stats_embed = discord.Embed(
title="📈 Statistiques Individuelles",
description="Cliquez sur un membre du staff pour voir ses statistiques détaillées:",
color=discord.Color.purple()
)
await interaction.followup.send(embed=stats_embed, view=stats_view, ephemeral=True)
except Exception as e:
await interaction.followup.send(f"❌ Erreur lors de la génération des graphiques: {e}", ephemeral=True)
def _is_staff(self, interaction: discord.Interaction) -> bool:
return is_staff(interaction, self.config)
@ -449,11 +548,15 @@ class TicketPriorityModal(discord.ui.Modal):
guild.me: discord.PermissionOverwrite(read_messages=True, send_messages=True)
}
# Add staff roles
for role_id in self.config.staff_roles:
role = guild.get_role(int(role_id))
if role and role not in overwrites:
overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=True)
# Add staff roles (category-specific roles override global ones)
staff_roles_to_add = self.category.staff_roles if self.category.staff_roles else self.config.staff_roles
for role_id in staff_roles_to_add:
try:
role = guild.get_role(int(role_id))
if role and role not in overwrites:
overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=True)
except (ValueError, TypeError):
continue
# Create channel name using username and category without numbers
safe_username = "".join(c for c in user.display_name if not c.isdigit()).strip()
@ -582,11 +685,11 @@ class TicketManagementView(discord.ui.View):
self.ticket = ticket
self.category = config.get_category(ticket.category_id)
# Claim (if enabled and ticket is open)
if config.claim_enabled and self.category and self.category.allow_claims and ticket.status == TicketStatus.OPEN:
# Claim (if enabled and ticket is open or claimed)
if config.claim_enabled and self.category and self.category.allow_claims and ticket.status in [TicketStatus.OPEN, TicketStatus.CLAIMED]:
claim_btn = discord.ui.Button(
style=discord.ButtonStyle.primary,
label="Réclamer",
label="Réclamer" if ticket.status == TicketStatus.OPEN else "Transférer",
custom_id="ticket_claim"
)
claim_btn.callback = self._claim_callback
@ -697,24 +800,33 @@ class TicketManagementView(discord.ui.View):
await interaction.response.send_message("Réservé au staff.", ephemeral=True)
return
if self.ticket.claimed_by:
await interaction.response.send_message("Ce ticket est déjà réclamé.", ephemeral=True)
if self.ticket.claimed_by == interaction.user.id:
await interaction.response.send_message("Vous avez déjà réclamé ce ticket.", ephemeral=True)
return
old_claimer = self.ticket.claimed_by
self.ticket.claimed_by = interaction.user.id
self.ticket.status = TicketStatus.CLAIMED
self.storage.save_ticket(self.ticket)
# Update view (remove claim button)
# Update view (change label)
for item in self.children:
if item.custom_id == "ticket_claim":
self.remove_item(item)
item.label = "Transférer"
break
# Force UI update
await interaction.response.edit_message(view=self)
embed = TicketEmbedFormatter.ticket_claimed(self.ticket, interaction.user)
if old_claimer:
embed = discord.Embed(
title="🔄 Ticket Transféré",
description=f"Le ticket a été transféré de <@{old_claimer}> à {interaction.user.mention}",
color=discord.Color.blue()
)
else:
embed = TicketEmbedFormatter.ticket_claimed(self.ticket, interaction.user)
await interaction.channel.send(embed=embed)
async def _reopen_callback(self, interaction: discord.Interaction):
@ -762,20 +874,32 @@ class TicketManagementView(discord.ui.View):
await interaction.response.send_message("Ticket rouvert!", ephemeral=True)
def _is_staff(self, interaction: discord.Interaction) -> bool:
return is_staff(interaction, self.config)
return is_staff(interaction, self.config, self.category)
def _is_staff_member(self, member) -> bool:
# Create a fake interaction-like check or manual check
"""Check if a specific member is staff, considering category roles"""
if not member:
return False
# Handle cases where member is a User (left the guild)
if not isinstance(member, discord.Member):
return member.id in self.config.staff_users
if member.guild_permissions.administrator:
return True
if member.id in self.config.staff_users:
return True
for role_id in self.config.staff_roles:
role = member.guild.get_role(int(role_id))
if role and role in member.roles:
return True
# Check category-specific staff roles first
staff_roles = self.category.staff_roles if self.category and self.category.staff_roles else self.config.staff_roles
for role_id in staff_roles:
try:
role = member.guild.get_role(int(role_id))
if role and role in member.roles:
return True
except (ValueError, TypeError, AttributeError):
continue
return False
@ -788,6 +912,7 @@ class CloseConfirmationView(discord.ui.View):
self.storage = storage
self.config = config
self.ticket = ticket
self.category = config.get_category(ticket.category_id)
@discord.ui.button(label="✅ Confirmer", style=discord.ButtonStyle.green, custom_id="close_confirm")
async def confirm_callback(self, interaction: discord.Interaction, button: discord.ui.Button):
@ -825,13 +950,7 @@ class CloseConfirmationView(discord.ui.View):
await interaction.delete_original_response()
def _is_staff(self, interaction: discord.Interaction) -> bool:
guild = interaction.guild
user = interaction.user
for role_id in self.config.staff_roles:
role = guild.get_role(int(role_id))
if role and role in user.roles:
return True
return user.guild_permissions.administrator
return is_staff(interaction, self.config, self.category)
class CloseDelayView(discord.ui.View):
@ -934,13 +1053,24 @@ class CloseDelayView(discord.ui.View):
if self.close_reason:
dm_embed.add_field(name="Raison de fermeture", value=self.close_reason, inline=False)
# Create a special view with a "Noter le staff" button
view = FeedbackView(self.bot, self.storage, self.ticket)
# Add a button to directly open the feedback form
note_staff_btn = discord.ui.Button(
label="⭐ Noter le staff",
style=discord.ButtonStyle.primary,
custom_id="note_staff_direct"
)
note_staff_btn.callback = self._note_staff_callback
view.add_item(note_staff_btn)
await user.send(embed=dm_embed, view=view)
except Exception as e:
print(f"Failed to send feedback DM: {e}")
# Send transcript to log channel
if self.config.log_channel_id and transcript_file and os.path.exists(transcript_file):
if self.config.log_channel_id:
try:
log_channel = interaction.guild.get_channel(self.config.log_channel_id)
if log_channel:
@ -961,8 +1091,12 @@ class CloseDelayView(discord.ui.View):
if self.close_reason:
log_embed.add_field(name="Raison", value=self.close_reason, inline=False)
file = discord.File(transcript_file, filename=os.path.basename(transcript_file))
await log_channel.send(embed=log_embed, file=file)
if transcript_file and os.path.exists(transcript_file):
file = discord.File(transcript_file, filename=os.path.basename(transcript_file))
await log_channel.send(embed=log_embed, file=file)
else:
log_embed.add_field(name="⚠️ Transcript", value="Le transcript n'a pas pu être généré (l'utilisateur a peut-être quitté le serveur).", inline=False)
await log_channel.send(embed=log_embed)
except Exception as e:
print(f"Error sending transcript to log channel: {e}")
@ -977,6 +1111,11 @@ class CloseDelayView(discord.ui.View):
color=discord.Color.red()
)
await interaction.channel.send(embed=embed)
async def _note_staff_callback(self, interaction: discord.Interaction):
"""Handle direct staff rating button"""
# This would open a direct feedback form
await interaction.response.send_message("Vous pouvez maintenant noter le staff directement !", ephemeral=True)
async def _cancel_callback(self, interaction: discord.Interaction):
"""Annule la fermeture du ticket pendant le délai"""
@ -992,20 +1131,32 @@ class CloseDelayView(discord.ui.View):
await interaction.channel.send(embed=embed, delete_after=5)
def _is_staff(self, interaction: discord.Interaction) -> bool:
guild = interaction.guild
user = interaction.user
for role_id in self.config.staff_roles:
role = guild.get_role(int(role_id))
if role and role in user.roles:
return True
return user.guild_permissions.administrator
return is_staff(interaction, self.config, self.category)
def _is_staff_member(self, member) -> bool:
guild = member.guild
for role_id in self.config.staff_roles:
role = guild.get_role(int(role_id))
if role and role in member.roles:
return True
"""Check if a specific member is staff, considering category roles"""
if not member:
return False
# Handle cases where member is a User (left the guild)
if not isinstance(member, discord.Member):
return member.id in self.config.staff_users
if member.guild_permissions.administrator:
return True
if member.id in self.config.staff_users:
return True
# Check category-specific staff roles first
staff_roles = self.category.staff_roles if self.category and self.category.staff_roles else self.config.staff_roles
for role_id in staff_roles:
try:
role = member.guild.get_role(int(role_id))
if role and role in member.roles:
return True
except (ValueError, TypeError, AttributeError):
continue
return False
@ -1125,7 +1276,7 @@ class CloseTicketModal(discord.ui.Modal):
duration = self.ticket.duration_minutes
# Send transcript to log channel if configured
if self.config.log_channel_id and transcript_file and os.path.exists(transcript_file):
if self.config.log_channel_id:
try:
log_channel = interaction.guild.get_channel(self.config.log_channel_id)
if log_channel:
@ -1149,8 +1300,12 @@ class CloseTicketModal(discord.ui.Modal):
log_embed.add_field(name="Raison de fermeture", value=self.reason.value, inline=False)
# Send transcript file
file = discord.File(transcript_file, filename=os.path.basename(transcript_file))
await log_channel.send(embed=log_embed, file=file)
if transcript_file and os.path.exists(transcript_file):
file = discord.File(transcript_file, filename=os.path.basename(transcript_file))
await log_channel.send(embed=log_embed, file=file)
else:
log_embed.add_field(name="⚠️ Transcript", value="Le transcript n'a pas pu être généré (l'utilisateur a peut-être quitté le serveur).", inline=False)
await log_channel.send(embed=log_embed)
except Exception as e:
print(f"Error sending transcript to log channel: {e}")
@ -1163,13 +1318,32 @@ class CloseTicketModal(discord.ui.Modal):
await interaction.followup.send("Erreur lors de la suppression du canal, mais le ticket a été fermé.", ephemeral=True)
def _is_staff(self, interaction: discord.Interaction) -> bool:
guild = interaction.guild
user = interaction.user
for role_id in self.config.staff_roles:
role = guild.get_role(int(role_id))
if role and role in user.roles:
return True
return user.guild_permissions.administrator
return is_staff(interaction, self.config, self.category)
def _is_staff_member(self, member) -> bool:
"""Check if a specific member is staff, considering category roles"""
if not member:
return False
# Handle cases where member is a User (left the guild)
if not isinstance(member, discord.Member):
return member.id in self.config.staff_users
if member.guild_permissions.administrator:
return True
if member.id in self.config.staff_users:
return True
staff_roles = self.category.staff_roles if self.category and self.category.staff_roles else self.config.staff_roles
for role_id in staff_roles:
try:
role = member.guild.get_role(int(role_id))
if role and role in member.roles:
return True
except (ValueError, TypeError, AttributeError):
continue
return False
class TicketSetupView(discord.ui.View):
@ -2039,7 +2213,8 @@ class CategoryActionView(discord.ui.View):
description=f"**ID:** `{self.category.id}`\n"
f"**Emoji:** {self.category.emoji}\n"
f"**Description:** {self.category.description or 'Aucune'}\n"
f"**Couleur:** `{self.category.color}`",
f"**Couleur:** `{self.category.color}`\n"
f"**Staff Spécifique:** {', '.join([f'<@&{r}>' for r in self.category.staff_roles]) if self.category.staff_roles else 'Global (Tous les staffs)'}",
color=discord.Color.blue()
)
return embed
@ -2049,6 +2224,18 @@ class CategoryActionView(discord.ui.View):
modal = EditCategoryModal(self.bot, self.storage, self.config, self.category, self)
await interaction.response.send_modal(modal)
@discord.ui.button(label="🛡️ Permissions", style=discord.ButtonStyle.success)
async def permissions_callback(self, interaction: discord.Interaction, button: discord.ui.Button):
view = CategoryPermissionsView(self.bot, self.storage, self.config, self.category, self)
embed = discord.Embed(
title=f"Permissions: {self.category.name}",
description="Choisissez les rôles qui auront accès à cette catégorie de tickets.\n\n"
"⚠️ **Si vous sélectionnez des rôles ici, seuls ces rôles (et les admins) auront accès aux tickets de cette catégorie.** "
"Les rôles staff globaux n'y auront plus accès.",
color=discord.Color.green()
)
await interaction.response.edit_message(embed=embed, view=view)
@discord.ui.button(label="🗑️ Supprimer", style=discord.ButtonStyle.danger)
async def delete_callback(self, interaction: discord.Interaction, button: discord.ui.Button):
view = DeleteCategoryConfirmationView(self.bot, self.storage, self.config, self.category, self.parent_view)
@ -2065,6 +2252,30 @@ class CategoryActionView(discord.ui.View):
self.parent_view._add_category_select()
await interaction.response.edit_message(embed=self.parent_view._get_embed(), view=self.parent_view)
class CategoryPermissionsView(discord.ui.View):
"""Vue pour configurer les rôles staff d'une catégorie"""
def __init__(self, bot, storage, config, category, parent_view):
super().__init__(timeout=None)
self.bot = bot
self.storage = storage
self.config = config
self.category = category
self.parent_view = parent_view
@discord.ui.select(cls=discord.ui.RoleSelect, placeholder="Sélectionner les rôles staff (Gestion)", min_values=0, max_values=10)
async def role_select_callback(self, interaction: discord.Interaction, select: discord.ui.RoleSelect):
role_ids = [str(role.id) for role in select.values]
self.category.staff_roles = role_ids
self.storage.save_config(interaction.guild_id, self.config)
# Refresh embed
await interaction.response.edit_message(embed=self.parent_view._get_embed(), view=self.parent_view)
@discord.ui.button(label="⬅️ Annuler", style=discord.ButtonStyle.secondary)
async def back_callback(self, interaction: discord.Interaction, button: discord.ui.Button):
await interaction.response.edit_message(embed=self.parent_view._get_embed(), view=self.parent_view)
class DeleteCategoryConfirmationView(discord.ui.View):
def __init__(self, bot, storage, config, category, parent_view):

View file

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

View file

@ -2,13 +2,14 @@ import discord
from .data.models import TicketData
class FeedbackView(discord.ui.View):
def __init__(self, bot, storage, ticket: TicketData):
def __init__(self, bot, storage, ticket: TicketData, staff_rating_only: bool = False):
super().__init__(timeout=86400) # 24h timeout
self.bot = bot
self.storage = storage
self.ticket = ticket
self.rating = 0
self.comment = ""
self.staff_rating_only = staff_rating_only
# Add Select Menu for Rating
self.add_item(RatingSelect())
@ -32,7 +33,11 @@ class FeedbackView(discord.ui.View):
for child in self.children:
child.disabled = True
await interaction.response.edit_message(content="✅ Merci pour votre retour !", view=self, embed=None)
# If this is a staff rating only view, we don't send the normal message
if not self.staff_rating_only:
await interaction.response.edit_message(content="✅ Merci pour votre retour !", view=self, embed=None)
else:
await interaction.response.edit_message(content="✅ Note envoyée !", view=self, embed=None)
# Log feedback if configured
try:

View file

@ -9,6 +9,11 @@ from collections import defaultdict
from typing import List, Dict, Any, Optional
import discord
import matplotlib.pyplot as plt
import plotly.graph_objects as go
import plotly.express as px
from plotly.subplots import make_subplots
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from .data.storage import get_storage
@ -102,213 +107,74 @@ class StaffAnalytics:
return result
def create_staff_rating_chart(self, staff_list: List[Dict[str, Any]]) -> io.BytesIO:
"""Create a comprehensive staff rating chart"""
"""Create a comprehensive staff rating chart using matplotlib"""
# Filter staff with ratings
rated_staff = [s for s in staff_list if s['total_ratings'] > 0]
if not rated_staff:
# Return empty chart
img = Image.new('RGB', (800, 400), '#2F3136')
fig, ax = plt.subplots(figsize=(10, 6), facecolor='#2F3136')
ax.set_facecolor('#2F3136')
ax.text(0.5, 0.5, 'Aucune évaluation disponible', ha='center', va='center',
transform=ax.transAxes, color='white')
ax.set_title('Évaluations des Membres du Staff', color='white')
buf = io.BytesIO()
img.save(buf, format='PNG')
plt.savefig(buf, format='png', bbox_inches='tight', facecolor='#2F3136')
buf.seek(0)
plt.close()
return buf
# Image dimensions
width, height = 1200, 700
img = Image.new('RGB', (width, height), '#36393F') # Discord dark theme
draw = ImageDraw.Draw(img)
# Try to load fonts with better Unicode support
try:
# Try different font paths for better Unicode support
font_paths = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/System/Library/Fonts/Arial.ttf",
"arial.ttf"
]
font_title = None
font_label = None
font_value = None
for font_path in font_paths:
try:
font_title = ImageFont.truetype(font_path, 24)
font_label = ImageFont.truetype(font_path, 16)
font_value = ImageFont.truetype(font_path, 14)
break
except:
continue
if not font_title:
raise Exception("No suitable font found")
except:
# Fallback to default with better handling
font_title = ImageFont.load_default()
font_label = ImageFont.load_default()
font_value = ImageFont.load_default()
# Title with better styling
title = "Évaluations des Membres du Staff"
# Add background for title
title_bbox = draw.textbbox((0, 0), title, font=font_title)
title_width = title_bbox[2] - title_bbox[0]
title_x = (width - title_width) // 2
draw.rectangle([title_x - 20, 15, title_x + title_width + 20, 55], fill='#5865F2', outline='#FFFFFF', width=2)
draw.text((width//2, 35), title, fill='white', font=font_title, anchor='mm')
# Chart dimensions with better spacing
chart_left = 200
chart_right = width - 100
chart_top = 100
chart_bottom = height - 200
chart_width = chart_right - chart_left
chart_height = chart_bottom - chart_top
# Prepare data - limit to top 8 for better readability
top_staff = sorted(rated_staff, key=lambda x: x['average_rating'], reverse=True)[:8]
names = []
for s in top_staff:
name = s['name']
# Handle Unicode properly
try:
# Ensure proper encoding
name = name.encode('utf-8').decode('utf-8')
except:
name = str(name)
# Truncate long names
if len(name) > 12:
name = name[:12] + '...'
names.append(name)
# Sort by average rating
rated_staff.sort(key=lambda x: x['average_rating'], reverse=True)
# Prepare data - limit to top 10 for better readability
top_staff = rated_staff[:10]
names = [s['name'][:15] + '...' if len(s['name']) > 15 else s['name'] for s in top_staff]
ratings = [s['average_rating'] for s in top_staff]
counts = [s['total_ratings'] for s in top_staff]
if not ratings:
buf = io.BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
return buf
max_rating = 5
bar_width = min(80, chart_width // len(ratings)) if ratings else 1
spacing = 20
# Draw background grid
for i in range(6):
y = chart_bottom - (i * chart_height // 5)
draw.line([chart_left, y, chart_right, y], fill='#72767D', width=1)
# Rating labels
draw.text((chart_left - 40, y), f"{i}", fill='#FFFFFF', font=font_value, anchor='mm')
# Draw bars with gradient effect
for i, (name, rating, count) in enumerate(zip(names, ratings, counts)):
bar_height = int((rating / max_rating) * chart_height)
x1 = chart_left + i * (bar_width + spacing) + spacing//2
y1 = chart_bottom - bar_height
x2 = x1 + bar_width
y2 = chart_bottom
# Bar color based on rating with better colors
if rating >= 4.5:
color = '#57F287' # Green
shadow_color = '#4CAF50'
elif rating >= 3.5:
color = '#FEE75C' # Yellow
shadow_color = '#FFC107'
elif rating >= 2.5:
color = '#FAA61A' # Orange
shadow_color = '#FF9800'
else:
color = '#ED4245' # Red
shadow_color = '#F44336'
# Draw shadow for 3D effect
draw.rectangle([x1+2, y1+2, x2+2, y2], fill=shadow_color, outline=shadow_color)
# Main bar
draw.rectangle([x1, y1, x2, y2], fill=color, outline='#FFFFFF', width=2)
# Rating value on top with background
rating_text = f"{rating:.1f}"
text_bbox = draw.textbbox((0, 0), rating_text, font=font_value)
text_width = text_bbox[2] - text_bbox[0]
text_x = x1 + bar_width//2
draw.rectangle([text_x - text_width//2 - 5, y1 - 25, text_x + text_width//2 + 5, y1 - 5], fill='#36393F', outline='#FFFFFF', width=1)
draw.text((text_x, y1 - 15), rating_text, fill='white', font=font_value, anchor='mm')
# Count below with background
count_text = f"({count})"
count_bbox = draw.textbbox((0, 0), count_text, font=font_value)
count_width = count_bbox[2] - count_bbox[0]
draw.rectangle([text_x - count_width//2 - 5, y2 + 5, text_x + count_width//2 + 5, y2 + 25], fill='#36393F', outline='#FFFFFF', width=1)
draw.text((text_x, y2 + 15), count_text, fill='white', font=font_value, anchor='mm')
# Draw staff names with better positioning
for i, name in enumerate(names):
x = chart_left + i * (bar_width + spacing) + bar_width//2 + spacing//2
# Handle long names by splitting
lines = []
if len(name) > 8:
# Split at spaces if possible
words = name.split()
current_line = ""
for word in words:
if len(current_line + word) < 8:
current_line += word + " "
else:
lines.append(current_line.strip())
current_line = word + " "
if current_line:
lines.append(current_line.strip())
if not lines:
lines = [name[:8] + '...']
else:
lines = [name]
y_offset = chart_bottom + 40
for line in lines:
# Background for name
name_bbox = draw.textbbox((0, 0), line, font=font_label)
name_width = name_bbox[2] - name_bbox[0]
draw.rectangle([x - name_width//2 - 5, y_offset - 5, x + name_width//2 + 5, y_offset + 15], fill='#36393F', outline='#FFFFFF', width=1)
draw.text((x, y_offset + 5), line, fill='white', font=font_label, anchor='mm')
y_offset += 20
# Y-axis label
draw.text((chart_left - 120, height//2), 'Note Moyenne', fill='white', font=font_label, anchor='mm')
# Enhanced legend with better positioning
legend_x = chart_right - 250
legend_y = chart_top + 50
# Legend background
draw.rectangle([legend_x - 20, legend_y - 10, legend_x + 230, legend_y + 120], fill='#36393F', outline='#FFFFFF', width=2)
draw.text((legend_x, legend_y), "Légende des Notes:", fill='white', font=font_label)
legend_items = [
("🟢 Excellent", "4.5+", '#57F287'),
("🟡 Bon", "3.5-4.4", '#FEE75C'),
("🟠 Moyen", "2.5-3.4", '#FAA61A'),
("🔴 À améliorer", "<2.5", '#ED4245')
# Create figure
fig, ax = plt.subplots(figsize=(12, 8), facecolor='#2F3136')
fig.patch.set_facecolor('#2F3136')
ax.set_facecolor('#2F3136')
# Create bars with color coding
colors = ['#57F287' if r >= 4.5 else '#FEE75C' if r >= 3.5 else '#FAA61A' if r >= 2.5 else '#ED4245' for r in ratings]
bars = ax.bar(range(len(ratings)), ratings, color=colors)
# Customize
ax.set_xticks(range(len(names)))
ax.set_xticklabels(names, rotation=45, ha='right', color='white')
ax.set_ylabel('Note moyenne', color='white')
ax.set_title('Évaluations des Membres du Staff', color='white', fontsize=16)
ax.tick_params(colors='white')
ax.grid(True, alpha=0.3, color='gray')
# Add value labels on bars (avoiding Unicode stars to prevent warnings)
for i, (bar, rating, count) in enumerate(zip(bars, ratings, counts)):
ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.05,
f'{rating:.1f}\n({count})', ha='center', va='bottom', color='white', fontsize=8)
# Set y-axis limits
ax.set_ylim(0, 5.2)
# Add legend
legend_elements = [
plt.Rectangle((0,0),1,1, color='#57F287', label='Excellent (4.5+)'),
plt.Rectangle((0,0),1,1, color='#FEE75C', label='Bon (3.5-4.4)'),
plt.Rectangle((0,0),1,1, color='#FAA61A', label='Moyen (2.5-3.4)'),
plt.Rectangle((0,0),1,1, color='#ED4245', label='À améliorer (<2.5)')
]
for i, (emoji_text, range_text, color) in enumerate(legend_items):
y_pos = legend_y + 25 + i * 20
draw.text((legend_x, y_pos), f"{emoji_text} {range_text}", fill=color, font=font_value)
# Add some stats at the bottom
stats_text = f"Total évalué: {len(rated_staff)} membres • Moyenne générale: {sum(ratings)/len(ratings):.1f}"
draw.text((width//2, height - 30), stats_text, fill='#B9BBBE', font=font_value, anchor='mm')
ax.legend(handles=legend_elements, loc='upper right', facecolor='#2F3136', edgecolor='white', labelcolor='white')
# Adjust layout
plt.tight_layout()
# Save to buffer
buf = io.BytesIO()
img.save(buf, format='PNG')
plt.savefig(buf, format='png', bbox_inches='tight', facecolor='#2F3136')
buf.seek(0)
plt.close()
return buf
def create_individual_staff_chart(self, staff_data: Dict[str, Any]) -> io.BytesIO:

View file

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