Rework complet du système de ticket § focntinnnement partiel
This commit is contained in:
parent
93e2f9bb44
commit
dfec6b2418
75 changed files with 6424 additions and 1893 deletions
466
commandes/ticket/staff_analytics.py
Normal file
466
commandes/ticket/staff_analytics.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue