Kuby/commandes/ticket/analytics.py

496 lines
19 KiB
Python
Raw Normal View History

"""
Staff Analytics Command for Ticket System
==========================================
A modular command for staff to view ticket analytics with beautiful graphics.
Shows response times, ratings, comments, and other key metrics.
"""
import os
import sys
import io
from datetime import datetime, timedelta
from collections import defaultdict
from typing import List, Dict, Any, Optional
from PIL import Image, ImageDraw, ImageFont
# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
2026-05-06 17:07:09 +02:00
import disnake
from disnake.ext import commands
from .data.storage import get_storage
from .data.models import TicketData, TicketStatus, GuildTicketConfig
class TicketAnalytics(commands.Cog):
"""Analytics command for staff to view ticket statistics and ratings"""
def __init__(self, bot):
self.bot = bot
self.storage = get_storage()
2026-05-06 17:07:09 +02:00
def _is_staff(self, interaction: disnake.Interaction) -> bool:
"""Check if user is staff"""
guild = interaction.guild
user = interaction.user
config = self.storage.load_config(guild.id)
for role_id in config.staff_roles:
role = guild.get_role(int(role_id))
if role and role in user.roles:
return True
return user.guild_permissions.administrator
def _calculate_comprehensive_stats(self, guild_id: int) -> Dict[str, Any]:
"""Calculate comprehensive statistics for analytics"""
tickets = self.storage.get_guild_tickets(guild_id)
stats = {
'total_tickets': len(tickets),
'open_tickets': 0,
'closed_tickets': 0,
'claimed_tickets': 0,
'ratings': [],
'response_times': [],
'feedbacks': [],
'tickets_by_day': defaultdict(int),
'tickets_by_category': defaultdict(int),
'tickets_by_priority': defaultdict(int),
'average_rating': 0,
'average_response_time': 0,
'total_response_time': 0,
'response_time_count': 0
}
for ticket in tickets:
# Basic counts
if ticket.status == TicketStatus.OPEN:
stats['open_tickets'] += 1
elif ticket.status == TicketStatus.CLAIMED:
stats['claimed_tickets'] += 1
stats['open_tickets'] += 1
elif ticket.status == TicketStatus.CLOSED:
stats['closed_tickets'] += 1
# Ratings and feedback
if ticket.rating:
stats['ratings'].append(ticket.rating)
if ticket.feedback:
stats['feedbacks'].append({
'rating': ticket.rating,
'feedback': ticket.feedback,
'user_id': ticket.user_id,
'ticket_id': ticket.channel_id
})
# Response time calculation
if ticket.claimed_by and ticket.created_at:
claimed_time = None
for msg in ticket.messages:
if msg.author_id == ticket.claimed_by and msg.is_staff:
claimed_time = msg.timestamp
break
if claimed_time:
response_time = (claimed_time - ticket.created_at).total_seconds() / 60 # minutes
stats['response_times'].append(response_time)
stats['total_response_time'] += response_time
stats['response_time_count'] += 1
# Daily stats
day_key = ticket.created_at.strftime('%Y-%m-%d')
stats['tickets_by_day'][day_key] += 1
# Category stats
stats['tickets_by_category'][ticket.category_id] += 1
# Priority stats
stats['tickets_by_priority'][ticket.priority.value] += 1
# Calculate averages
if stats['ratings']:
stats['average_rating'] = sum(stats['ratings']) / len(stats['ratings'])
if stats['response_time_count'] > 0:
stats['average_response_time'] = stats['total_response_time'] / stats['response_time_count']
return stats
def _create_rating_chart(self, ratings: List[int]) -> io.BytesIO:
"""Create a beautiful rating distribution chart using Pillow"""
# Image dimensions
width, height = 800, 500
img = Image.new('RGB', (width, height), '#2F3136')
draw = ImageDraw.Draw(img)
# Try to load a font, fallback to default if not available
try:
font_title = ImageFont.truetype("arial.ttf", 24)
font_label = ImageFont.truetype("arial.ttf", 16)
font_value = ImageFont.truetype("arial.ttf", 14)
except:
font_title = ImageFont.load_default()
font_label = ImageFont.load_default()
font_value = ImageFont.load_default()
# Count ratings
rating_counts = defaultdict(int)
for rating in ratings:
rating_counts[rating] += 1
values = [rating_counts[i] for i in range(1, 6)]
max_value = max(values) if values else 1
# Chart dimensions
chart_left = 100
chart_right = width - 50
chart_top = 80
chart_bottom = height - 100
chart_width = chart_right - chart_left
chart_height = chart_bottom - chart_top
# Draw title
title = "Distribution des Notes Clients"
draw.text((width//2, 30), title, fill='white', font=font_title, anchor='mm')
# Draw bars
bar_width = chart_width // 6
for i, value in enumerate(values):
if max_value > 0:
bar_height = int((value / max_value) * chart_height)
else:
bar_height = 0
x1 = chart_left + i * bar_width + 10
y1 = chart_bottom - bar_height
x2 = x1 + bar_width - 20
y2 = chart_bottom
# Draw bar
draw.rectangle([x1, y1, x2, y2], fill='#5865F2', outline='#FFFFFF', width=2)
# Draw value on top of bar
if value > 0:
draw.text((x1 + (x2-x1)//2, y1 - 10), str(value), fill='white', font=font_value, anchor='mm')
# Draw x-axis labels
labels = ['1 ⭐', '2 ⭐', '3 ⭐', '4 ⭐', '5 ⭐']
for i, label in enumerate(labels):
x = chart_left + i * bar_width + bar_width//2
draw.text((x, chart_bottom + 20), label, fill='white', font=font_label, anchor='mm')
# Draw y-axis label
draw.text((30, height//2), 'Nombre de Tickets', fill='white', font=font_label, anchor='mm')
# Draw grid lines
for i in range(0, 6):
y = chart_bottom - (i * chart_height // 5)
draw.line([chart_left, y, chart_right, y], fill='#FFFFFF', width=1)
# Save to buffer
buf = io.BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
return buf
def _create_response_time_chart(self, response_times: List[float]) -> io.BytesIO:
"""Create response time distribution chart using Pillow"""
# Image dimensions
width, height = 800, 500
img = Image.new('RGB', (width, height), '#2F3136')
draw = ImageDraw.Draw(img)
# Try to load a font, fallback to default if not available
try:
font_title = ImageFont.truetype("arial.ttf", 20)
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()
# Create histogram bins
bins = [0, 5, 15, 30, 60, 120, 240, float('inf')]
labels = ['< 5min', '5-15min', '15-30min', '30-60min', '1-2h', '2-4h', '> 4h']
hist_values = []
for i in range(len(bins) - 1):
count = sum(1 for rt in response_times if bins[i] <= rt < bins[i+1])
hist_values.append(count)
# Last bin for > 4h
hist_values.append(sum(1 for rt in response_times if rt >= bins[-2]))
max_value = max(hist_values) if hist_values else 1
# Chart dimensions
chart_left = 120
chart_right = width - 50
chart_top = 80
chart_bottom = height - 120
chart_width = chart_right - chart_left
chart_height = chart_bottom - chart_top
# Draw title
title = "Temps de Réponse Moyen"
draw.text((width//2, 30), title, fill='white', font=font_title, anchor='mm')
# Draw bars
bar_width = chart_width // len(labels)
for i, value in enumerate(hist_values):
if max_value > 0:
bar_height = int((value / max_value) * chart_height)
else:
bar_height = 0
x1 = chart_left + i * bar_width + 5
y1 = chart_bottom - bar_height
x2 = x1 + bar_width - 10
y2 = chart_bottom
# Draw bar
draw.rectangle([x1, y1, x2, y2], fill='#57F287', outline='#FFFFFF', width=2)
# Draw value on top of bar
if value > 0:
draw.text((x1 + (x2-x1)//2, y1 - 10), str(value), fill='white', font=font_value, anchor='mm')
# Draw x-axis labels
for i, label in enumerate(labels):
x = chart_left + i * bar_width + bar_width//2
# Rotate text for better fit
draw.text((x, chart_bottom + 30), label, fill='white', font=font_label, anchor='mm')
# Draw y-axis label
draw.text((40, height//2), 'Nombre de Tickets', fill='white', font=font_label, anchor='mm')
# Draw grid lines
for i in range(0, 6):
y = chart_bottom - (i * chart_height // 5)
draw.line([chart_left, y, chart_right, y], fill='#FFFFFF', width=1)
# Save to buffer
buf = io.BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
return buf
def _create_trend_chart(self, tickets_by_day: Dict[str, int]) -> io.BytesIO:
"""Create ticket trend chart over time using Pillow"""
# Image dimensions
width, height = 900, 500
img = Image.new('RGB', (width, height), '#2F3136')
draw = ImageDraw.Draw(img)
# Try to load a font, fallback to default if not available
try:
font_title = ImageFont.truetype("arial.ttf", 20)
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()
# Sort dates
sorted_dates = sorted(tickets_by_day.keys())
values = [tickets_by_day[date] for date in sorted_dates]
if not values:
# Return empty chart if no data
buf = io.BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
return buf
max_value = max(values) if values else 1
# Chart dimensions
chart_left = 100
chart_right = width - 50
chart_top = 80
chart_bottom = height - 100
chart_width = chart_right - chart_left
chart_height = chart_bottom - chart_top
# Draw title
title = "Évolution des Tickets sur les Derniers Jours"
draw.text((width//2, 30), title, fill='white', font=font_title, anchor='mm')
# Calculate points for line chart
points = []
for i, (date_str, value) in enumerate(zip(sorted_dates, values)):
x = chart_left + (i * chart_width) // max(1, len(values) - 1)
if max_value > 0:
y = chart_bottom - int((value / max_value) * chart_height)
else:
y = chart_bottom
points.append((x, y))
# Draw line
if len(points) > 1:
draw.line(points, fill='#FEE75C', width=3, joint='curve')
# Draw points and values
for i, ((x, y), value, date_str) in enumerate(zip(points, values, sorted_dates)):
# Draw point
draw.ellipse([x-4, y-4, x+4, y+4], fill='#FEE75C', outline='#FFFFFF', width=2)
# Draw value above point
draw.text((x, y-15), str(value), fill='white', font=font_value, anchor='mm')
# Draw date label (every few points to avoid crowding)
if i % max(1, len(points)//7) == 0 or i == len(points)-1:
date_obj = datetime.strptime(date_str, '%Y-%m-%d')
date_label = date_obj.strftime('%d/%m')
draw.text((x, chart_bottom + 20), date_label, fill='white', font=font_label, anchor='mm')
# Draw y-axis label
draw.text((40, height//2), 'Nombre de Tickets', fill='white', font=font_label, anchor='mm')
# Draw grid lines
for i in range(0, 6):
y = chart_bottom - (i * chart_height // 5)
draw.line([chart_left, y, chart_right, y], fill='#FFFFFF', width=1)
# Save to buffer
buf = io.BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
return buf
2026-05-06 17:07:09 +02:00
@commands.slash_command(name="ticket_analytics", description="Affiche les statistiques détaillées des tickets (Staff uniquement)")
async def ticket_analytics(self, interaction: disnake.Interaction, period: str = commands.Param(default="30d", choices=["7d", "30d", "90d", "all"], description="Période d'analyse")):
"""Display comprehensive ticket analytics for staff"""
await interaction.response.defer(ephemeral=True)
# Check permissions
if not self._is_staff(interaction):
await interaction.followup.send("❌ Cette commande est réservée au staff.", ephemeral=True)
return
# Parse period
days = 30
if period == "7d":
days = 7
elif period == "30d":
days = 30
elif period == "90d":
days = 90
elif period == "all":
days = None
# Calculate stats
stats = self._calculate_comprehensive_stats(interaction.guild_id)
# Filter by period if specified
if days:
cutoff_date = datetime.now() - timedelta(days=days)
tickets = self.storage.get_guild_tickets(interaction.guild_id)
filtered_tickets = [t for t in tickets if t.created_at >= cutoff_date]
# Recalculate stats for filtered period
stats = self._calculate_comprehensive_stats(interaction.guild_id)
# Filter the data
stats['tickets_by_day'] = {k: v for k, v in stats['tickets_by_day'].items()
if datetime.strptime(k, '%Y-%m-%d') >= cutoff_date}
stats['ratings'] = [r for r in stats['ratings'] if any(
t.created_at >= cutoff_date and t.rating == r for t in filtered_tickets)]
stats['response_times'] = [rt for rt in stats['response_times'] if any(
t.created_at >= cutoff_date for t in filtered_tickets if hasattr(t, 'response_time') and t.response_time == rt)]
# Create main embed
2026-05-06 17:07:09 +02:00
embed = disnake.Embed(
title="📊 Analytics des Tickets",
description=f"Statistiques pour la période: **{period}**",
2026-05-06 17:07:09 +02:00
color=disnake.Color.blue(),
timestamp=datetime.now()
)
# Basic stats
embed.add_field(
name="📈 Statistiques Générales",
value=f"**Total:** {stats['total_tickets']}\n"
f"**Ouverts:** {stats['open_tickets']}\n"
f"**Fermés:** {stats['closed_tickets']}\n"
f"**Réclamés:** {stats['claimed_tickets']}",
inline=True
)
# Performance metrics
avg_rating = f"{stats['average_rating']:.1f}" if stats['ratings'] else "N/A"
avg_response = f"{stats['average_response_time']:.1f}min" if stats['response_times'] else "N/A"
embed.add_field(
name="⚡ Performance",
value=f"**Note moyenne:** {avg_rating}\n"
f"**Temps réponse moyen:** {avg_response}\n"
f"**Feedbacks:** {len(stats['feedbacks'])}",
inline=True
)
# Send main embed
await interaction.followup.send(embed=embed, ephemeral=True)
# Create and send charts
try:
# Rating chart
if stats['ratings']:
rating_chart = self._create_rating_chart(stats['ratings'])
await interaction.followup.send(
"📊 **Distribution des Notes Clients**",
2026-05-06 17:07:09 +02:00
file=disnake.File(rating_chart, 'rating_distribution.png'),
ephemeral=True
)
# Response time chart
if stats['response_times']:
response_chart = self._create_response_time_chart(stats['response_times'])
await interaction.followup.send(
"⏱️ **Temps de Réponse**",
2026-05-06 17:07:09 +02:00
file=disnake.File(response_chart, 'response_times.png'),
ephemeral=True
)
# Trend chart
if stats['tickets_by_day']:
trend_chart = self._create_trend_chart(stats['tickets_by_day'])
await interaction.followup.send(
"📈 **Évolution des Tickets**",
2026-05-06 17:07:09 +02:00
file=disnake.File(trend_chart, 'ticket_trends.png'),
ephemeral=True
)
except Exception as e:
await interaction.followup.send(f"❌ Erreur lors de la génération des graphiques: {e}", ephemeral=True)
# Send feedback summary if available
if stats['feedbacks']:
2026-05-06 17:07:09 +02:00
feedback_embed = disnake.Embed(
title="💬 Commentaires Clients",
description=f"Derniers commentaires ({min(5, len(stats['feedbacks']))} affichés)",
2026-05-06 17:07:09 +02:00
color=disnake.Color.green()
)
# Sort by rating (lowest first to show areas for improvement)
sorted_feedbacks = sorted(stats['feedbacks'], key=lambda x: x['rating'] or 0)
for i, fb in enumerate(sorted_feedbacks[:5]):
rating_text = f"{fb['rating']}" if fb['rating'] else "N/A"
feedback_text = fb['feedback'][:200] + "..." if len(fb['feedback']) > 200 else fb['feedback']
feedback_embed.add_field(
name=f"Ticket #{fb['ticket_id']} - {rating_text}",
value=feedback_text,
inline=False
)
await interaction.followup.send(embed=feedback_embed, ephemeral=True)
async def setup(bot):
"""Setup function for the analytics cog"""
await bot.add_cog(TicketAnalytics(bot))