Rework complet du système de ticket § focntinnnement partiel

This commit is contained in:
Mathis 2026-01-10 15:30:51 +01:00
parent 93e2f9bb44
commit dfec6b2418
75 changed files with 6424 additions and 1893 deletions

View file

@ -0,0 +1,466 @@
"""
Staff Analytics Module
======================
Advanced analytics for individual staff performance and ratings.
"""
import io
from datetime import datetime, timedelta
from collections import defaultdict
from typing import List, Dict, Any, Optional
import discord
from PIL import Image, ImageDraw, ImageFont
from .data.storage import get_storage
from .data.models import TicketData, TicketStatus
class StaffAnalytics:
"""Handles staff-specific analytics and visualizations"""
def __init__(self, bot):
self.bot = bot
self.storage = get_storage()
async def get_staff_list(self, guild_id: int) -> List[Dict[str, Any]]:
"""Get list of staff members with their stats"""
config = self.storage.load_config(guild_id)
tickets = self.storage.get_guild_tickets(guild_id)
staff_stats = defaultdict(lambda: {
'user_id': 0,
'name': 'Unknown',
'tickets_claimed': 0,
'tickets_closed': 0,
'average_rating': 0,
'total_ratings': 0,
'response_times': [],
'ratings': [],
'categories': defaultdict(int)
})
for ticket in tickets:
if ticket.claimed_by:
staff_id = ticket.claimed_by
staff_stats[staff_id]['user_id'] = staff_id
staff_stats[staff_id]['tickets_claimed'] += 1
if ticket.status == TicketStatus.CLOSED:
staff_stats[staff_id]['tickets_closed'] += 1
if ticket.rating:
staff_stats[staff_id]['ratings'].append(ticket.rating)
staff_stats[staff_id]['total_ratings'] += 1
# Calculate response time
if ticket.created_at:
claimed_time = None
for msg in ticket.messages:
if msg.author_id == staff_id and msg.is_staff:
claimed_time = msg.timestamp
break
if claimed_time:
response_time = (claimed_time - ticket.created_at).total_seconds() / 60
staff_stats[staff_id]['response_times'].append(response_time)
# Category stats
staff_stats[staff_id]['categories'][ticket.category_id] += 1
# Calculate averages and get names
result = []
for staff_id, stats in staff_stats.items():
if stats['ratings']:
stats['average_rating'] = sum(stats['ratings']) / len(stats['ratings'])
if stats['response_times']:
stats['average_response_time'] = sum(stats['response_times']) / len(stats['response_times'])
else:
stats['average_response_time'] = 0
# Get user name
try:
user = self.bot.get_user(staff_id)
if user:
stats['name'] = str(user)
else:
# Try to get from guild
guild = self.bot.get_guild(guild_id)
if guild:
member = guild.get_member(staff_id)
if member:
stats['name'] = str(member)
except:
pass
result.append(stats)
# Sort by tickets claimed
result.sort(key=lambda x: x['tickets_claimed'], reverse=True)
return result
def create_staff_rating_chart(self, staff_list: List[Dict[str, Any]]) -> io.BytesIO:
"""Create a comprehensive staff rating chart"""
# 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')
buf = io.BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
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)
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')
]
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')
buf = io.BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
return buf
def create_individual_staff_chart(self, staff_data: Dict[str, Any]) -> io.BytesIO:
"""Create detailed chart for individual staff member"""
width, height = 800, 500
img = Image.new('RGB', (width, height), '#2F3136')
draw = ImageDraw.Draw(img)
try:
font_title = ImageFont.truetype("arial.ttf", 18)
font_label = ImageFont.truetype("arial.ttf", 14)
font_value = ImageFont.truetype("arial.ttf", 12)
except:
font_title = ImageFont.load_default()
font_label = ImageFont.load_default()
font_value = ImageFont.load_default()
# Title
title = f"Statistiques de {staff_data['name']}"
draw.text((width//2, 30), title, fill='white', font=font_title, anchor='mm')
# Basic stats
stats_y = 80
draw.text((50, stats_y), f"Tickets réclamés: {staff_data['tickets_claimed']}", fill='white', font=font_label)
draw.text((50, stats_y + 25), f"Tickets fermés: {staff_data['tickets_closed']}", fill='white', font=font_label)
if staff_data['total_ratings'] > 0:
avg_rating = staff_data['average_rating']
color = '#57F287' if avg_rating >= 4 else '#FEE75C' if avg_rating >= 3 else '#ED4245'
draw.text((50, stats_y + 50), f"Note moyenne: {avg_rating:.1f}⭐ ({staff_data['total_ratings']} évaluations)", fill=color, font=font_label)
if staff_data['response_times']:
avg_response = staff_data['average_response_time']
draw.text((50, stats_y + 75), f"Temps de réponse moyen: {avg_response:.1f} min", fill='white', font=font_label)
# Rating distribution if available
if staff_data['ratings']:
chart_left = 400
chart_top = 150
chart_width = 350
chart_height = 200
# Count ratings
rating_counts = defaultdict(int)
for rating in staff_data['ratings']:
rating_counts[rating] += 1
max_count = max(rating_counts.values()) if rating_counts else 1
# Draw mini bars
bar_width = chart_width // 5
for i in range(1, 6):
count = rating_counts[i]
if max_count > 0:
bar_height = int((count / max_count) * chart_height)
else:
bar_height = 0
x1 = chart_left + (i-1) * bar_width + 10
y1 = chart_top + chart_height - bar_height
x2 = x1 + bar_width - 20
y2 = chart_top + chart_height
draw.rectangle([x1, y1, x2, y2], fill='#5865F2', outline='#FFFFFF', width=1)
draw.text((x1 + (x2-x1)//2, y1 - 15), str(count), fill='white', font=font_value, anchor='mm')
# Labels
for i in range(1, 6):
x = chart_left + (i-1) * bar_width + bar_width//2
draw.text((x, chart_top + chart_height + 20), f"{i}", fill='white', font=font_label, anchor='mm')
draw.text((chart_left + chart_width//2, chart_top - 20), "Distribution des Notes", fill='white', font=font_label, anchor='mm')
buf = io.BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
return buf
class StaffStatsView(discord.ui.View):
"""View for selecting individual staff member stats"""
def __init__(self, bot, staff_list: List[Dict[str, Any]], analytics: StaffAnalytics):
super().__init__(timeout=300)
self.bot = bot
self.staff_list = staff_list
self.analytics = analytics
self._build_select()
def _build_select(self):
"""Build staff selection dropdown"""
options = []
for staff in self.staff_list[:25]: # Discord limit
label = f"{staff['name'][:20]} ({staff['tickets_claimed']} tickets)"
description = f"Note: {staff['average_rating']:.1f}" if staff['total_ratings'] > 0 else "Aucune évaluation"
options.append(
discord.SelectOption(
label=label,
value=str(staff['user_id']),
description=description[:50]
)
)
if options:
select = discord.ui.Select(
placeholder="Sélectionnez un membre du staff...",
options=options
)
select.callback = self._staff_selected
self.add_item(select)
async def _staff_selected(self, interaction: discord.Interaction):
"""Handle staff selection"""
staff_id = int(interaction.data["values"][0])
staff_data = next((s for s in self.staff_list if s['user_id'] == staff_id), None)
if not staff_data:
await interaction.response.send_message("Membre du staff introuvable.", ephemeral=True)
return
await interaction.response.defer(ephemeral=True)
# Create individual chart
chart = self.analytics.create_individual_staff_chart(staff_data)
embed = discord.Embed(
title=f"📊 Statistiques de {staff_data['name']}",
description="Performances détaillées du membre du staff",
color=discord.Color.blue(),
timestamp=datetime.now()
)
embed.add_field(
name="📈 Activité",
value=f"**Tickets réclamés:** {staff_data['tickets_claimed']}\n"
f"**Tickets fermés:** {staff_data['tickets_closed']}\n"
f"**Taux de résolution:** {staff_data['tickets_closed']/staff_data['tickets_claimed']*100:.1f}%" if staff_data['tickets_claimed'] > 0 else "N/A",
inline=True
)
if staff_data['total_ratings'] > 0:
embed.add_field(
name="⭐ Évaluations",
value=f"**Note moyenne:** {staff_data['average_rating']:.1f}/5\n"
f"**Total d'évaluations:** {staff_data['total_ratings']}",
inline=True
)
if staff_data['response_times']:
embed.add_field(
name="⏱️ Performance",
value=f"**Temps de réponse moyen:** {staff_data['average_response_time']:.1f} min",
inline=True
)
await interaction.followup.send(embed=embed, file=discord.File(chart, 'staff_stats.png'), ephemeral=True)