""" 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) # 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""" # 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)