""" 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__))))) import discord from discord.ext import commands from discord import app_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() def _is_staff(self, interaction: discord.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 @app_commands.command(name="ticket_analytics", description="Affiche les statistiques détaillées des tickets (Staff uniquement)") @app_commands.describe(period="Période d'analyse (7d, 30d, 90d, all)") async def ticket_analytics(self, interaction: discord.Interaction, period: str = "30d"): """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 embed = discord.Embed( title="📊 Analytics des Tickets", description=f"Statistiques pour la période: **{period}**", color=discord.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**", file=discord.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**", file=discord.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**", file=discord.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']: feedback_embed = discord.Embed( title="💬 Commentaires Clients", description=f"Derniers commentaires ({min(5, len(stats['feedbacks']))} affichés)", color=discord.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))