Kuby/commandes/ticket/staff_analytics.py

335 lines
13 KiB
Python
Raw Permalink Normal View History

"""
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
2026-05-06 17:07:09 +02:00
import disnake
2026-05-01 16:16:33 +02:00
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)
2026-01-14 21:32:20 +01:00
# 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:
2026-05-01 16:16:33 +02:00
"""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
2026-05-01 16:16:33 +02:00
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()
2026-05-01 16:16:33 +02:00
plt.savefig(buf, format='png', bbox_inches='tight', facecolor='#2F3136')
buf.seek(0)
2026-05-01 16:16:33 +02:00
plt.close()
return buf
2026-05-01 16:16:33 +02:00
# 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]
2026-05-01 16:16:33 +02:00
# 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)')
]
2026-05-01 16:16:33 +02:00
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()
2026-05-01 16:16:33 +02:00
plt.savefig(buf, format='png', bbox_inches='tight', facecolor='#2F3136')
buf.seek(0)
2026-05-01 16:16:33 +02:00
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
2026-05-06 17:07:09 +02:00
class StaffStatsView(disnake.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(
2026-05-06 17:07:09 +02:00
disnake.SelectOption(
label=label,
value=str(staff['user_id']),
description=description[:50]
)
)
if options:
2026-05-06 17:07:09 +02:00
select = disnake.ui.Select(
placeholder="Sélectionnez un membre du staff...",
options=options
)
select.callback = self._staff_selected
self.add_item(select)
2026-05-06 17:07:09 +02:00
async def _staff_selected(self, interaction: disnake.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)
2026-05-06 17:07:09 +02:00
embed = disnake.Embed(
title=f"📊 Statistiques de {staff_data['name']}",
description="Performances détaillées du membre du staff",
2026-05-06 17:07:09 +02:00
color=disnake.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
)
2026-05-06 17:07:09 +02:00
await interaction.followup.send(embed=embed, file=disnake.File(chart, 'staff_stats.png'), ephemeral=True)