Kuby/commandes/ticket/staff_analytics.py

334 lines
13 KiB
Python

"""
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
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
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)
# Invalidate cache to ensure we get the latest ticket data with new ratings
self.storage.invalidate_cache(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 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 - 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]
# 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)')
]
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()
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:
"""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)