Rework complet du système de ticket § focntinnnement partiel
This commit is contained in:
parent
93e2f9bb44
commit
dfec6b2418
75 changed files with 6424 additions and 1893 deletions
20
commandes/ticket/FIX_PLAN.md
Normal file
20
commandes/ticket/FIX_PLAN.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Fix Plan: Missing _config_roles_callback method
|
||||
|
||||
## Problem
|
||||
The `TicketSetupView` class has a button that references `_config_roles_callback`, but this method doesn't exist. This causes an `AttributeError` when the `/ticketconfig` command is used.
|
||||
|
||||
## Solution
|
||||
1. Add `_config_roles_callback` method to `TicketSetupView` class
|
||||
2. Create `ConfigRolesSetupView` class to handle config roles selection
|
||||
|
||||
## Files to modify
|
||||
- `commandes/ticket/__init__.py`
|
||||
|
||||
## Changes
|
||||
1. Add `_config_roles_callback` method in `TicketSetupView` (after `_categories_callback`)
|
||||
2. Add `ConfigRolesSetupView` class (after `StaffRolesSetupView`)
|
||||
|
||||
## Status
|
||||
- [x] Add `_config_roles_callback` method
|
||||
- [x] Add `ConfigRolesSetupView` class
|
||||
|
||||
|
|
@ -1,130 +1,54 @@
|
|||
# 🎫 Ticket System - TODO & Documentation
|
||||
# Ticket System Fixes - Implementation Status
|
||||
|
||||
## Vue d'ensemble
|
||||
Système de tickets modulaire et complet pour Discord bot.
|
||||
## ✅ Completed Fixes
|
||||
|
||||
## Structure du projet
|
||||
```
|
||||
commandes/ticket/
|
||||
├── __init__.py # Commande principale (le cog)
|
||||
├── ticket.py # Wrapper de compatibilité (ancien fichier)
|
||||
├── views/ # Interfaces utilisateurs
|
||||
│ ├── creation.py # Création de tickets
|
||||
│ └── admin.py # Panneau d'admin
|
||||
├── data/ # Données et stockage
|
||||
│ ├── models.py # Modèles de données
|
||||
│ └── storage.py # Gestion du stockage JSON
|
||||
├── utils/ # Utilitaires
|
||||
│ ├── transcript.py # Génération de transcriptions
|
||||
│ └── formatters.py # Formatage des embeds
|
||||
└── TODO.md # Documentation
|
||||
```
|
||||
### 1. UI Synchronization Issue
|
||||
- **Problem**: TicketManagementView._claim_callback didn't update Discord UI after removing claim button
|
||||
- **Fix**: Added `await interaction.response.edit_message(view=self)` after view modification
|
||||
- **Status**: ✅ FIXED
|
||||
|
||||
## ⚠️ Vues Persistantes - Important
|
||||
### 2. Resource Leak and API Overload
|
||||
- **Problem**: @tasks.loop(seconds=30) on _register_views caused rate limits and memory consumption
|
||||
- **Fix**: Moved view registration to cog_load() method, removed looped task
|
||||
- **Status**: ✅ FIXED
|
||||
|
||||
Pour que les boutons fonctionnent après un redémarrage du bot, toutes les vues doivent:
|
||||
1. Utiliser `timeout=None` dans le constructeur
|
||||
2. Être enregistrées avec `bot.add_view(view)` dans `cog_load()`
|
||||
### 3. Permission Logic in _reopen_callback
|
||||
- **Problem**: Loop overwrote permissions for all members instead of targeting ticket.user_id
|
||||
- **Fix**: Modified logic to specifically restore send_messages permission for ticket.user_id only
|
||||
- **Status**: ✅ FIXED
|
||||
|
||||
### Vues persistantes configurées:
|
||||
- ✅ `TicketCreationView` - timeout=None
|
||||
- ✅ `TicketManagementView` - timeout=None
|
||||
- ✅ `SurveyView` - timeout=None
|
||||
- ✅ `AdminPanelView` - timeout=None
|
||||
- ✅ `ConfigPanelView` - timeout=None
|
||||
- ✅ `BulkCloseView` - timeout=None
|
||||
- ✅ `RolesManagementView` - timeout=None
|
||||
### 4. Missing Error Handling in Modals
|
||||
- **Problem**: TicketPriorityModal and CloseTicketModal lacked on_error methods
|
||||
- **Fix**: Added try/except blocks in on_submit and implemented on_error methods
|
||||
- **Status**: ✅ FIXED
|
||||
|
||||
### Enregistrement des vues
|
||||
Les vues sont enregistrées dans `cog_load()` et aussi périodiquement toutes les 30 secondes:
|
||||
### 5. Input Validation in StaffRolesModal
|
||||
- **Problem**: Regex (\d{17,20}) was too permissive, didn't validate role existence
|
||||
- **Fix**: Added validation to check if extracted role IDs correspond to existing guild roles
|
||||
- **Status**: ✅ FIXED
|
||||
|
||||
```python
|
||||
async def cog_load(self):
|
||||
await self._register_all_views()
|
||||
### 6. Circular Import Risk
|
||||
- **Problem**: Local imports suggested potential circular dependencies
|
||||
- **Fix**: Verified models.py doesn't import from __init__.py - no circular import exists
|
||||
- **Status**: ✅ VERIFIED (No fix needed)
|
||||
|
||||
async def _register_all_views(self):
|
||||
for guild_id in self.storage.get_all_guild_ids():
|
||||
config = self.storage.load_config(guild_id)
|
||||
if config and config.enabled:
|
||||
self.bot.add_view(TicketCreationView(self.bot, config))
|
||||
self.bot.add_view(TicketManagementView(...))
|
||||
# etc.
|
||||
```
|
||||
## 🧪 Testing Recommendations
|
||||
|
||||
## Commandes
|
||||
1. **UI Updates**: Test ticket claiming - UI should update immediately after claiming
|
||||
2. **Performance**: Monitor API usage - should be significantly reduced
|
||||
3. **Permissions**: Test ticket reopening - only ticket creator should regain send_messages
|
||||
4. **Error Handling**: Trigger errors in modals to verify graceful failure
|
||||
5. **Role Validation**: Try invalid role IDs in staff configuration
|
||||
6. **Import Safety**: Verify no import errors on bot startup
|
||||
|
||||
| Commande | Description |
|
||||
|----------|-------------|
|
||||
| `/ticket setup` | Configure le système (admin) |
|
||||
| `/ticket panel [channel]` | Crée le panneau de création |
|
||||
| `/ticket info [channel]` | Infos sur un ticket |
|
||||
| `/ticket list [user]` | Liste des tickets |
|
||||
| `/ticket stats` | Statistiques |
|
||||
| `/ticket admin` | Panneau d'admin |
|
||||
| `/ticket transcript [channel]` | Génère une transcription |
|
||||
| `/ticket category add\|remove\|list` | Gère les catégories |
|
||||
| `/ticket close [channel] [reason]` | Ferme un ticket |
|
||||
| `/ticket reopen [channel]` | Rouvre un ticket |
|
||||
|
||||
## Configuration
|
||||
|
||||
### Modèles de données
|
||||
- **TicketData** - Données d'un ticket
|
||||
- **TicketCategory** - Catégorie de ticket
|
||||
- **GuildTicketConfig** - Configuration du serveur
|
||||
- **TicketStatus** - Statuts: OPEN, CLAIMED, CLOSED, ARCHIVED
|
||||
- **Priority** - Priorités: LOW, NORMAL, HIGH, URGENT
|
||||
|
||||
### Stockage
|
||||
Les données sont stockées dans `data/tickets/`:
|
||||
- `config_guild_id.json` - Configuration du serveur
|
||||
- `tickets.json` - Tous les tickets
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
### Création de tickets
|
||||
1. L'utilisateur clique sur une catégorie
|
||||
2. Sélectionne la priorité (si activé)
|
||||
3. Entre la raison (si requis)
|
||||
4. Le salon est créé automatiquement
|
||||
|
||||
### Gestion des tickets
|
||||
- **Fermer** - Ferme avec raison optionnelle
|
||||
- **Réclamer** - Reserve le ticket à un staff
|
||||
- **Priorité** - Change la priorité
|
||||
- **Transcript** - Génère une transcription
|
||||
|
||||
### Système de survey
|
||||
Enquête de satisfaction 1-5 étoiles après fermeture
|
||||
|
||||
## Problèmes connus et solutions
|
||||
|
||||
### Boutons ne fonctionnent pas après redémarrage
|
||||
**Cause:** Views non persistantes (timeout défini) ou non enregistrées
|
||||
**Solution:**
|
||||
1. Vérifier que `timeout=None` est défini
|
||||
2. Vérifier que `bot.add_view()` est appelé dans `cog_load()`
|
||||
3. Redémarrer le bot après avoir envoyé le panneau
|
||||
|
||||
### Transcript non généré
|
||||
**Cause:** Historique trop long ou permissions manquantes
|
||||
**Solution:** Le transcript limite à 1000 messages
|
||||
|
||||
## Dépannage
|
||||
|
||||
```bash
|
||||
# Tester si les vues sont enregistrées
|
||||
# Les boutons doivent répondre même après un redémarrage
|
||||
|
||||
# Vérifier les logs pour les erreurs
|
||||
tail -f logs/kuby.log
|
||||
```
|
||||
|
||||
## À faire (Backlog)
|
||||
|
||||
- [ ] Ajouter support pour transcripts PDF
|
||||
- [ ] Implémenter système de tags pour tickets
|
||||
- [ ] Ajouter intégration avec webhooks
|
||||
- [ ] Support multi-langue
|
||||
- [ ] Interface web pour administration
|
||||
## 📋 Files Modified
|
||||
- `commandes/ticket/__init__.py`: All fixes applied
|
||||
- `commandes/ticket/data/models.py`: Verified for circular imports (none found)
|
||||
|
||||
## 🎯 Impact
|
||||
- Improved user experience with immediate UI updates
|
||||
- Reduced API load and memory usage
|
||||
- Enhanced security with proper permission handling
|
||||
- Better error resilience
|
||||
- More robust input validation
|
||||
- Maintained clean architecture
|
||||
|
|
|
|||
0
commandes/ticket/TODO_new.md
Normal file
0
commandes/ticket/TODO_new.md
Normal file
File diff suppressed because it is too large
Load diff
497
commandes/ticket/analytics.py
Normal file
497
commandes/ticket/analytics.py
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
"""
|
||||
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))
|
||||
|
|
@ -273,7 +273,8 @@ class GuildTicketConfig:
|
|||
default_category: Default category ID
|
||||
log_channel_id: Channel for ticket logs
|
||||
archive_channel_id: Channel for archived transcripts
|
||||
staff_roles: Global staff roles
|
||||
staff_roles: Global staff roles (can manage tickets, access analytics, etc.)
|
||||
config_roles: Roles that can access the configuration panel (/ticketconfig)
|
||||
max_tickets_per_user: Global max tickets per user
|
||||
auto_close_enabled: Whether auto-close is enabled
|
||||
auto_close_days: Days before auto-close
|
||||
|
|
@ -292,6 +293,7 @@ class GuildTicketConfig:
|
|||
log_channel_id: Optional[int] = None
|
||||
archive_channel_id: Optional[int] = None
|
||||
staff_roles: List[str] = field(default_factory=list)
|
||||
config_roles: List[str] = field(default_factory=list)
|
||||
max_tickets_per_user: int = 5
|
||||
auto_close_enabled: bool = False
|
||||
auto_close_days: int = 7
|
||||
|
|
@ -311,6 +313,7 @@ class GuildTicketConfig:
|
|||
"log_channel_id": self.log_channel_id,
|
||||
"archive_channel_id": self.archive_channel_id,
|
||||
"staff_roles": self.staff_roles,
|
||||
"config_roles": self.config_roles,
|
||||
"max_tickets_per_user": self.max_tickets_per_user,
|
||||
"auto_close_enabled": self.auto_close_enabled,
|
||||
"auto_close_days": self.auto_close_days,
|
||||
|
|
@ -333,6 +336,7 @@ class GuildTicketConfig:
|
|||
log_channel_id=data.get("log_channel_id"),
|
||||
archive_channel_id=data.get("archive_channel_id"),
|
||||
staff_roles=data.get("staff_roles", []),
|
||||
config_roles=data.get("config_roles", []),
|
||||
max_tickets_per_user=data.get("max_tickets_per_user", 5),
|
||||
auto_close_enabled=data.get("auto_close_enabled", False),
|
||||
auto_close_days=data.get("auto_close_days", 7),
|
||||
|
|
|
|||
466
commandes/ticket/staff_analytics.py
Normal file
466
commandes/ticket/staff_analytics.py
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
"""
|
||||
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)
|
||||
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)
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
"""
|
||||
Ticket Views Module
|
||||
===================
|
||||
UI views for the ticket system.
|
||||
"""
|
||||
from .creation import (
|
||||
TicketCreationView,
|
||||
TicketPriorityModal,
|
||||
TicketManagementView,
|
||||
TicketCloseModal,
|
||||
TicketPriorityChangeModal,
|
||||
SurveyView,
|
||||
)
|
||||
from .admin import (
|
||||
AdminPanelView,
|
||||
BulkCloseView,
|
||||
ConfigPanelView,
|
||||
AddCategoryModal,
|
||||
RolesManagementView,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"TicketCreationView",
|
||||
"TicketPriorityModal",
|
||||
"TicketManagementView",
|
||||
"TicketCloseModal",
|
||||
"TicketPriorityChangeModal",
|
||||
"SurveyView",
|
||||
"AdminPanelView",
|
||||
"BulkCloseView",
|
||||
"ConfigPanelView",
|
||||
"AddCategoryModal",
|
||||
"RolesManagementView",
|
||||
]
|
||||
|
||||
|
|
@ -1,522 +0,0 @@
|
|||
"""
|
||||
Ticket Admin Views
|
||||
==================
|
||||
Admin panel views for bulk management and configuration.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
import discord
|
||||
from discord.ui import View, Button, Select, Modal, TextInput
|
||||
from discord import Interaction, ButtonStyle, Color
|
||||
|
||||
from ..data.models import (
|
||||
TicketData,
|
||||
TicketCategory,
|
||||
TicketStatus,
|
||||
TicketStats,
|
||||
GuildTicketConfig
|
||||
)
|
||||
from ..data.storage import get_storage
|
||||
from ..utils.formatters import TicketEmbedFormatter
|
||||
from ..utils.transcript import get_transcript_generator
|
||||
|
||||
|
||||
class AdminPanelView(View):
|
||||
"""
|
||||
Main admin panel for ticket management.
|
||||
"""
|
||||
|
||||
def __init__(self, bot, config: GuildTicketConfig):
|
||||
super().__init__(timeout=None)
|
||||
self.bot = bot
|
||||
self.config = config
|
||||
|
||||
# Stats button
|
||||
stats_btn = Button(
|
||||
style=ButtonStyle.blurple,
|
||||
label="📊 Statistiques",
|
||||
custom_id="admin_stats"
|
||||
)
|
||||
stats_btn.callback = self._stats_callback
|
||||
self.add_item(stats_btn)
|
||||
|
||||
# Bulk close button
|
||||
bulk_close_btn = Button(
|
||||
style=ButtonStyle.red,
|
||||
label="🔴 Fermeture Massive",
|
||||
custom_id="admin_bulk_close"
|
||||
)
|
||||
bulk_close_btn.callback = self._bulk_close_callback
|
||||
self.add_item(bulk_close_btn)
|
||||
|
||||
# Config button
|
||||
config_btn = Button(
|
||||
style=ButtonStyle.secondary,
|
||||
label="⚙️ Configuration",
|
||||
custom_id="admin_config"
|
||||
)
|
||||
config_btn.callback = self._config_callback
|
||||
self.add_item(config_btn)
|
||||
|
||||
# Clean old tickets button
|
||||
cleanup_btn = Button(
|
||||
style=ButtonStyle.secondary,
|
||||
label="🧹 Nettoyer",
|
||||
custom_id="admin_cleanup"
|
||||
)
|
||||
cleanup_btn.callback = self._cleanup_callback
|
||||
self.add_item(cleanup_btn)
|
||||
|
||||
async def _stats_callback(self, interaction: Interaction):
|
||||
"""Show statistics"""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
storage = get_storage()
|
||||
stats = self._calculate_stats(interaction.guild_id)
|
||||
|
||||
embed = TicketEmbedFormatter.stats_embed(stats, interaction.guild.name)
|
||||
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
async def _bulk_close_callback(self, interaction: Interaction):
|
||||
"""Show bulk close options"""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
# Get open tickets
|
||||
storage = get_storage()
|
||||
tickets = storage.get_guild_tickets(interaction.guild_id, TicketStatus.OPEN)
|
||||
|
||||
if not tickets:
|
||||
await interaction.followup.send("Aucun ticket ouvert à fermer.", ephemeral=True)
|
||||
return
|
||||
|
||||
# Show selection view
|
||||
view = BulkCloseView(self.bot, self.config, tickets)
|
||||
embed = discord.Embed(
|
||||
title="🔴 Fermeture Massive",
|
||||
description=f"Nombre de tickets ouverts: **{len(tickets)}**\n\n"
|
||||
"Sélectionnez les tickets à fermer:",
|
||||
color=Color.red()
|
||||
)
|
||||
|
||||
await interaction.followup.send(embed=embed, view=view, ephemeral=True)
|
||||
|
||||
async def _config_callback(self, interaction: Interaction):
|
||||
"""Show configuration options"""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
view = ConfigPanelView(self.bot, self.config)
|
||||
|
||||
embed = discord.Embed(
|
||||
title="⚙️ Configuration des Tickets",
|
||||
color=Color.blue()
|
||||
)
|
||||
embed.add_field(name="Tickets activés", value="✅" if self.config.enabled else "❌", inline=True)
|
||||
embed.add_field(name="Catégories", value=str(len(self.config.categories)), inline=True)
|
||||
embed.add_field(name="Rôles Staff", value=str(len(self.config.staff_roles)), inline=True)
|
||||
embed.add_field(name="Canal Logs", value=f"<#{self.config.log_channel_id}>" if self.config.log_channel_id else "Non configuré", inline=True)
|
||||
embed.add_field(name="Limite par utilisateur", value=str(self.config.max_tickets_per_user), inline=True)
|
||||
embed.add_field(name="Auto-close", value="✅" if self.config.auto_close_enabled else "❌", inline=True)
|
||||
|
||||
await interaction.followup.send(embed=embed, view=view, ephemeral=True)
|
||||
|
||||
async def _cleanup_callback(self, interaction: Interaction):
|
||||
"""Clean up old closed tickets"""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
storage = get_storage()
|
||||
removed = await self._cleanup_closed_tickets(interaction.guild_id)
|
||||
|
||||
await interaction.followup.send(
|
||||
f"✅ {removed} tickets fermés ont été supprimés.",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
def _calculate_stats(self, guild_id: int) -> TicketStats:
|
||||
"""Calculate ticket statistics"""
|
||||
storage = get_storage()
|
||||
tickets = storage.get_guild_tickets(guild_id)
|
||||
|
||||
stats = TicketStats()
|
||||
stats.total_tickets = len(tickets)
|
||||
|
||||
total_duration = 0
|
||||
closed_count = 0
|
||||
rating_sum = 0
|
||||
rating_count = 0
|
||||
|
||||
for ticket in tickets:
|
||||
# By status
|
||||
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
|
||||
closed_count += 1
|
||||
|
||||
# By category
|
||||
stats.tickets_by_category[ticket.category_id] = \
|
||||
stats.tickets_by_category.get(ticket.category_id, 0) + 1
|
||||
|
||||
# By priority
|
||||
stats.tickets_by_priority[ticket.priority.value] = \
|
||||
stats.tickets_by_priority.get(ticket.priority.value, 0) + 1
|
||||
|
||||
# By day
|
||||
day = ticket.created_at.strftime("%Y-%m-%d")
|
||||
stats.tickets_by_day[day] = stats.tickets_by_day.get(day, 0) + 1
|
||||
|
||||
# Duration
|
||||
if ticket.duration_minutes:
|
||||
total_duration += ticket.duration_minutes
|
||||
|
||||
# Rating
|
||||
if ticket.rating:
|
||||
rating_sum += ticket.rating
|
||||
rating_count += 1
|
||||
|
||||
# Averages
|
||||
if closed_count > 0:
|
||||
stats.average_duration_minutes = total_duration / closed_count
|
||||
|
||||
if rating_count > 0:
|
||||
stats.average_rating = rating_sum / rating_count
|
||||
|
||||
return stats
|
||||
|
||||
async def _cleanup_closed_tickets(self, guild_id: int, days: int = 30) -> int:
|
||||
"""Remove closed tickets older than X days"""
|
||||
storage = get_storage()
|
||||
tickets = storage.get_guild_tickets(guild_id, TicketStatus.CLOSED)
|
||||
|
||||
cutoff = datetime.now().timestamp() - (days * 86400)
|
||||
removed = 0
|
||||
|
||||
for ticket in tickets:
|
||||
if ticket.closed_at and ticket.closed_at.timestamp() < cutoff:
|
||||
# Delete channel if it exists
|
||||
channel = self.bot.get_channel(ticket.channel_id)
|
||||
if channel:
|
||||
try:
|
||||
await channel.delete()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Delete ticket data
|
||||
storage.delete_ticket(ticket.channel_id)
|
||||
removed += 1
|
||||
|
||||
return removed
|
||||
|
||||
|
||||
class BulkCloseView(View):
|
||||
"""View for selecting tickets to bulk close"""
|
||||
|
||||
def __init__(self, bot, config: GuildTicketConfig, tickets: List[TicketData]):
|
||||
super().__init__(timeout=None) # Persistent view
|
||||
self.bot = bot
|
||||
self.config = config
|
||||
self.tickets = tickets
|
||||
self.selected_tickets = set()
|
||||
|
||||
# Create select with tickets (max 25 per select)
|
||||
options = []
|
||||
for ticket in tickets[:25]:
|
||||
channel = self.bot.get_channel(ticket.channel_id)
|
||||
channel_name = channel.name if channel else f"ticket_{ticket.channel_id}"
|
||||
options.append(
|
||||
discord.SelectOption(
|
||||
label=channel_name[:100],
|
||||
value=str(ticket.channel_id),
|
||||
description=f"Priorité: {ticket.priority.value}"
|
||||
)
|
||||
)
|
||||
|
||||
if options:
|
||||
select = Select(
|
||||
placeholder="Sélectionnez les tickets à fermer...",
|
||||
options=options,
|
||||
min_values=1,
|
||||
max_values=len(options)
|
||||
)
|
||||
select.callback = self._select_callback
|
||||
self.add_item(select)
|
||||
|
||||
# Confirm button
|
||||
confirm_btn = Button(
|
||||
style=ButtonStyle.red,
|
||||
label="Confirmer la fermeture",
|
||||
custom_id="bulk_confirm"
|
||||
)
|
||||
confirm_btn.callback = self._confirm_callback
|
||||
self.add_item(confirm_btn)
|
||||
|
||||
async def _select_callback(self, interaction: Interaction):
|
||||
"""Handle ticket selection"""
|
||||
self.selected_tickets = set(int(v) for v in interaction.data["values"])
|
||||
await interaction.response.send_message(
|
||||
f"*{len(self.selected_tickets)} ticket(s) sélectionné(s)*",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
async def _confirm_callback(self, interaction: Interaction):
|
||||
"""Confirm bulk close"""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
if not self.selected_tickets:
|
||||
await interaction.followup.send("Aucun ticket sélectionné.", ephemeral=True)
|
||||
return
|
||||
|
||||
storage = get_storage()
|
||||
closed_count = 0
|
||||
|
||||
for channel_id in self.selected_tickets:
|
||||
ticket = storage.load_ticket(channel_id)
|
||||
if ticket:
|
||||
ticket.status = TicketStatus.CLOSED
|
||||
ticket.closed_at = datetime.now()
|
||||
storage.save_ticket(ticket)
|
||||
|
||||
# Update channel
|
||||
channel = interaction.guild.get_channel(channel_id)
|
||||
if channel:
|
||||
# Update permissions
|
||||
overwrites = channel.overwrites.copy()
|
||||
for target, perms in overwrites.items():
|
||||
if isinstance(target, discord.Member) and target.id != interaction.guild.me.id:
|
||||
overwrites[target] = discord.PermissionOverwrite(
|
||||
read_messages=True,
|
||||
send_messages=False
|
||||
)
|
||||
|
||||
await channel.edit(
|
||||
name=f"closed-{channel.name}",
|
||||
overwrites=overwrites
|
||||
)
|
||||
|
||||
await channel.send(f"Fermé par bulk close (Admin: {interaction.user.mention})")
|
||||
|
||||
closed_count += 1
|
||||
|
||||
await interaction.followup.send(
|
||||
f"✅ {closed_count} ticket(s) fermé(s).",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
|
||||
class ConfigPanelView(View):
|
||||
"""Configuration panel view"""
|
||||
|
||||
def __init__(self, bot, config: GuildTicketConfig):
|
||||
super().__init__(timeout=None)
|
||||
self.bot = bot
|
||||
self.config = config
|
||||
|
||||
# Toggle enabled
|
||||
toggle_btn = Button(
|
||||
style=ButtonStyle.green if config.enabled else ButtonStyle.red,
|
||||
label="Activer/Désactiver",
|
||||
custom_id="config_toggle"
|
||||
)
|
||||
toggle_btn.callback = self._toggle_callback
|
||||
self.add_item(toggle_btn)
|
||||
|
||||
# Add category
|
||||
add_cat_btn = Button(
|
||||
style=ButtonStyle.blurple,
|
||||
label="➕ Ajouter Catégorie",
|
||||
custom_id="config_add_category"
|
||||
)
|
||||
add_cat_btn.callback = self._add_category_callback
|
||||
self.add_item(add_cat_btn)
|
||||
|
||||
# Edit roles
|
||||
roles_btn = Button(
|
||||
style=ButtonStyle.secondary,
|
||||
label="👥 Gérer Rôles Staff",
|
||||
custom_id="config_roles"
|
||||
)
|
||||
roles_btn.callback = self._roles_callback
|
||||
self.add_item(roles_btn)
|
||||
|
||||
# Toggle auto-close
|
||||
auto_close_btn = Button(
|
||||
style=ButtonStyle.green if config.auto_close_enabled else ButtonStyle.red,
|
||||
label="Auto-close",
|
||||
custom_id="config_auto_close"
|
||||
)
|
||||
auto_close_btn.callback = self._auto_close_callback
|
||||
self.add_item(auto_close_btn)
|
||||
|
||||
async def _toggle_callback(self, interaction: Interaction):
|
||||
"""Toggle ticket system"""
|
||||
self.config.enabled = not self.config.enabled
|
||||
|
||||
storage = get_storage()
|
||||
storage.save_config(interaction.guild_id, self.config)
|
||||
|
||||
status = "activé" if self.config.enabled else "désactivé"
|
||||
await interaction.response.send_message(
|
||||
f"Le système de tickets est maintenant **{status}**.",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
async def _add_category_callback(self, interaction: Interaction):
|
||||
"""Show add category modal"""
|
||||
modal = AddCategoryModal(self.bot, self.config)
|
||||
await interaction.response.send_modal(modal)
|
||||
|
||||
async def _roles_callback(self, interaction: Interaction):
|
||||
"""Show role management"""
|
||||
view = RolesManagementView(self.bot, self.config)
|
||||
|
||||
embed = discord.Embed(
|
||||
title="👥 Gestion des Rôles Staff",
|
||||
description="Sélectionnez les rôles qui auront accès aux tickets:",
|
||||
color=Color.blue()
|
||||
)
|
||||
|
||||
# Show current roles
|
||||
role_mentions = []
|
||||
for role_id in self.config.staff_roles:
|
||||
role = interaction.guild.get_role(int(role_id))
|
||||
if role:
|
||||
role_mentions.append(role.mention)
|
||||
|
||||
embed.add_field(
|
||||
name="Rôles actuels",
|
||||
value=", ".join(role_mentions) if role_mentions else "Aucun",
|
||||
inline=False
|
||||
)
|
||||
|
||||
await interaction.response.send_message(embed=embed, view=view, ephemeral=True)
|
||||
|
||||
async def _auto_close_callback(self, interaction: Interaction):
|
||||
"""Toggle auto-close"""
|
||||
self.config.auto_close_enabled = not self.config.auto_close_enabled
|
||||
|
||||
storage = get_storage()
|
||||
storage.save_config(interaction.guild_id, self.config)
|
||||
|
||||
status = "activé" if self.config.auto_close_enabled else "désactivé"
|
||||
await interaction.response.send_message(
|
||||
f"L'auto-close est maintenant **{status}**.",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
|
||||
class AddCategoryModal(Modal):
|
||||
"""Modal for adding a new category"""
|
||||
|
||||
def __init__(self, bot, config: GuildTicketConfig):
|
||||
super().__init__(title="➕ Nouvelle Catégorie")
|
||||
self.bot = bot
|
||||
self.config = config
|
||||
|
||||
self.name = TextInput(
|
||||
label="Nom",
|
||||
placeholder="Ex: Support",
|
||||
required=True
|
||||
)
|
||||
self.add_item(self.name)
|
||||
|
||||
self.description = TextInput(
|
||||
label="Description",
|
||||
placeholder="Description de la catégorie...",
|
||||
required=False,
|
||||
style=discord.TextStyle.paragraph
|
||||
)
|
||||
self.add_item(self.description)
|
||||
|
||||
self.emoji = TextInput(
|
||||
label="Emoji",
|
||||
placeholder="🎫",
|
||||
required=False
|
||||
)
|
||||
self.add_item(self.emoji)
|
||||
|
||||
self.welcome_msg = TextInput(
|
||||
label="Message de bienvenue",
|
||||
placeholder="Message affiché quand un ticket est créé...",
|
||||
required=False,
|
||||
style=discord.TextStyle.paragraph
|
||||
)
|
||||
self.add_item(self.welcome_msg)
|
||||
|
||||
async def on_submit(self, interaction: Interaction):
|
||||
# Generate ID
|
||||
import uuid
|
||||
cat_id = str(uuid.uuid4())[:8]
|
||||
|
||||
category = TicketCategory(
|
||||
id=cat_id,
|
||||
name=self.name.value,
|
||||
description=self.description.value or "",
|
||||
emoji=self.emoji.value or "🎫",
|
||||
welcome_message=self.welcome_msg.value or "Merci d'avoir créé un ticket."
|
||||
)
|
||||
|
||||
self.config.categories.append(category)
|
||||
|
||||
if not self.config.default_category:
|
||||
self.config.default_category = cat_id
|
||||
|
||||
storage = get_storage()
|
||||
storage.save_config(interaction.guild_id, self.config)
|
||||
|
||||
await interaction.response.send_message(
|
||||
f"Catégorie **{self.name.value}** créée avec succès!",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
|
||||
class RolesManagementView(View):
|
||||
"""View for managing staff roles"""
|
||||
|
||||
def __init__(self, bot, config: GuildTicketConfig):
|
||||
super().__init__(timeout=None)
|
||||
self.bot = bot
|
||||
self.config = config
|
||||
|
||||
# Create select with guild roles
|
||||
guild = bot.guilds[0] if bot.guilds else None
|
||||
options = []
|
||||
|
||||
if guild:
|
||||
for role in guild.roles:
|
||||
if role.id != guild.id: # Skip @everyone
|
||||
selected = str(role.id) in config.staff_roles
|
||||
options.append(
|
||||
discord.SelectOption(
|
||||
label=role.name,
|
||||
value=str(role.id),
|
||||
default=selected
|
||||
)
|
||||
)
|
||||
|
||||
if options:
|
||||
select = Select(
|
||||
placeholder="Sélectionnez les rôles staff...",
|
||||
options=options[:25]
|
||||
)
|
||||
select.callback = self._select_callback
|
||||
self.add_item(select)
|
||||
|
||||
async def _select_callback(self, interaction: Interaction):
|
||||
"""Handle role selection"""
|
||||
selected_ids = set(interaction.data["values"])
|
||||
|
||||
# Update config
|
||||
self.config.staff_roles = list(selected_ids)
|
||||
|
||||
storage = get_storage()
|
||||
storage.save_config(interaction.guild_id, self.config)
|
||||
|
||||
await interaction.response.send_message(
|
||||
f"*{len(selected_ids)} rôle(s) mis à jour.*",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
|
|
@ -1,692 +0,0 @@
|
|||
"""
|
||||
Ticket Creation Views
|
||||
=====================
|
||||
UI views for creating tickets with categories, priority, and reason.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
import discord
|
||||
from discord.ui import View, Button, Select, Modal, TextInput
|
||||
from discord import Interaction, ButtonStyle, Color
|
||||
|
||||
from ..data.models import (
|
||||
TicketData,
|
||||
TicketCategory,
|
||||
TicketStatus,
|
||||
Priority,
|
||||
GuildTicketConfig
|
||||
)
|
||||
from ..data.storage import get_storage
|
||||
from ..utils.formatters import TicketEmbedFormatter, TicketMessageFormatter
|
||||
from ..utils.transcript import get_transcript_generator
|
||||
|
||||
|
||||
class TicketCreationView(View):
|
||||
"""
|
||||
Main view for ticket creation panel.
|
||||
Shows category selection buttons.
|
||||
"""
|
||||
|
||||
def __init__(self, bot, config: GuildTicketConfig):
|
||||
super().__init__(timeout=None)
|
||||
self.bot = bot
|
||||
self.config = config
|
||||
|
||||
# Add category buttons
|
||||
for i, category in enumerate(config.categories[:5]): # Max 5 categories
|
||||
btn = Button(
|
||||
style=ButtonStyle.blurple,
|
||||
label=category.name,
|
||||
emoji=category.emoji,
|
||||
custom_id=f"ticket_category_{category.id}"
|
||||
)
|
||||
btn.callback = self._create_category_callback(category.id)
|
||||
self.add_item(btn)
|
||||
|
||||
# Add help button
|
||||
help_btn = Button(
|
||||
style=ButtonStyle.secondary,
|
||||
label="Aide",
|
||||
emoji="❓",
|
||||
custom_id="ticket_help"
|
||||
)
|
||||
help_btn.callback = self._help_callback
|
||||
self.add_item(help_btn)
|
||||
|
||||
def _create_category_callback(self, category_id: str):
|
||||
"""Create callback for category button"""
|
||||
async def callback(interaction: Interaction):
|
||||
await self.on_category_select(interaction, category_id)
|
||||
return callback
|
||||
|
||||
async def on_category_select(self, interaction: Interaction, category_id: str):
|
||||
"""Handle category selection"""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
category = self.config.get_category(category_id)
|
||||
if not category:
|
||||
await interaction.followup.send("Catégorie non trouvée.", ephemeral=True)
|
||||
return
|
||||
|
||||
storage = get_storage()
|
||||
|
||||
# Check if user already has a ticket
|
||||
user_tickets = storage.get_user_tickets(
|
||||
interaction.guild_id,
|
||||
interaction.user.id,
|
||||
open_only=True
|
||||
)
|
||||
|
||||
# Check limits
|
||||
user_count = len([t for t in user_tickets if t.category_id == category_id])
|
||||
if user_count >= category.max_tickets_per_user:
|
||||
await interaction.followup.send(
|
||||
TicketMessageFormatter.ticket_limit_reached(category.max_tickets_per_user),
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
# Check global limit
|
||||
if len(user_tickets) >= self.config.max_tickets_per_user:
|
||||
await interaction.followup.send(
|
||||
TicketMessageFormatter.ticket_limit_reached(self.config.max_tickets_per_user),
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
# Show priority selection modal
|
||||
modal = TicketPriorityModal(
|
||||
bot=self.bot,
|
||||
config=self.config,
|
||||
category=category
|
||||
)
|
||||
await interaction.response.send_modal(modal)
|
||||
|
||||
async def _help_callback(self, interaction: Interaction):
|
||||
"""Handle help button click"""
|
||||
embed = discord.Embed(
|
||||
title="❓ Aide - Création de Ticket",
|
||||
description="Sélectionnez une catégorie ci-dessus correspondant à votre demande:\n\n"
|
||||
"• **Support** - Questions techniques\n"
|
||||
"• **Bug Report** - Signaler un problème\n"
|
||||
"• **Partenariat** - Propositions de partenariat\n"
|
||||
"• **Autre** - Autres demandes",
|
||||
color=Color.blue()
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
|
||||
|
||||
class TicketPriorityModal(Modal):
|
||||
"""
|
||||
Modal for selecting priority and reason when creating a ticket.
|
||||
"""
|
||||
|
||||
def __init__(self, bot, config: GuildTicketConfig, category: TicketCategory):
|
||||
super().__init__(title=f"🎫 {category.name} - Nouveau Ticket")
|
||||
self.bot = bot
|
||||
self.config = config
|
||||
self.category = category
|
||||
|
||||
# Priority selection
|
||||
priorities = ["Basse", "Normale", "Haute", "Urgente"]
|
||||
priority_placeholder = "Sélectionnez la priorité"
|
||||
|
||||
if not category.priority_enabled:
|
||||
priorities = ["Normale"]
|
||||
priority_placeholder = "Priorité par défaut"
|
||||
|
||||
self.priority_select = Select(
|
||||
placeholder=priority_placeholder,
|
||||
options=[
|
||||
discord.SelectOption(label=p, value=p.lower(), emoji=emoji)
|
||||
for p, emoji in [
|
||||
("Basse", "🟢"),
|
||||
("Normale", "🔵"),
|
||||
("Haute", "🟠"),
|
||||
("Urgente", "🔴")
|
||||
] if p in priorities or p.lower() in ["normale"]
|
||||
],
|
||||
custom_id="priority_select"
|
||||
)
|
||||
self.add_item(self.priority_select)
|
||||
|
||||
# Reason input
|
||||
reason_placeholder = "Décrivez votre demande..."
|
||||
if category.require_reason:
|
||||
reason_placeholder = "Requis - Décrivez votre demande..."
|
||||
|
||||
self.reason = TextInput(
|
||||
label="Motif de la demande",
|
||||
placeholder=reason_placeholder,
|
||||
required=category.require_reason,
|
||||
style=discord.TextStyle.paragraph,
|
||||
custom_id="reason_input"
|
||||
)
|
||||
self.add_item(self.reason)
|
||||
|
||||
async def on_submit(self, interaction: Interaction):
|
||||
"""Handle modal submission"""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
# Map priority
|
||||
priority_map = {
|
||||
"basse": Priority.LOW,
|
||||
"normale": Priority.NORMAL,
|
||||
"haute": Priority.HIGH,
|
||||
"urgente": Priority.URGENT
|
||||
}
|
||||
priority = priority_map.get(self.priority_select.values[0], Priority.NORMAL)
|
||||
|
||||
await self.create_ticket(
|
||||
interaction=interaction,
|
||||
priority=priority,
|
||||
reason=self.reason.value
|
||||
)
|
||||
|
||||
async def create_ticket(
|
||||
self,
|
||||
interaction: Interaction,
|
||||
priority: Priority,
|
||||
reason: str
|
||||
):
|
||||
"""Create the actual ticket"""
|
||||
guild = interaction.guild
|
||||
user = interaction.user
|
||||
|
||||
# Get category for config
|
||||
category = self.category
|
||||
|
||||
# Get category channel
|
||||
category_channel = guild.get_channel(int(category.id)) if category.id.isdigit() else None
|
||||
|
||||
# Build permissions
|
||||
overwrites = {
|
||||
guild.default_role: discord.PermissionOverwrite(read_messages=False),
|
||||
user: discord.PermissionOverwrite(read_messages=True, send_messages=True, attach_files=True),
|
||||
guild.me: discord.PermissionOverwrite(read_messages=True, send_messages=True)
|
||||
}
|
||||
|
||||
# Add staff roles
|
||||
for role_id in category.staff_roles:
|
||||
role = guild.get_role(int(role_id))
|
||||
if role:
|
||||
overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=True)
|
||||
|
||||
# Also add global staff roles
|
||||
for role_id in self.config.staff_roles:
|
||||
role = guild.get_role(int(role_id))
|
||||
if role and role not in overwrites:
|
||||
overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=True)
|
||||
|
||||
# Create channel name
|
||||
timestamp = datetime.now().strftime("%m%d%H%M")
|
||||
channel_name = f"ticket-{user.id}-{timestamp}"
|
||||
|
||||
# Create ticket channel
|
||||
try:
|
||||
ticket_channel = await guild.create_text_channel(
|
||||
name=channel_name,
|
||||
category=guild.get_channel(int(self.config.default_category)) if self.config.default_category else None,
|
||||
overwrites=overwrites,
|
||||
topic=f"Ticket de {user} ({user.id}) | Catégorie: {category.name}"
|
||||
)
|
||||
except Exception as e:
|
||||
await interaction.followup.send(f"❌ Erreur lors de la création du ticket: {e}", ephemeral=True)
|
||||
return
|
||||
|
||||
# Create ticket data
|
||||
storage = get_storage()
|
||||
ticket_data = TicketData(
|
||||
channel_id=ticket_channel.id,
|
||||
guild_id=guild.id,
|
||||
user_id=user.id,
|
||||
category_id=category.id,
|
||||
priority=priority,
|
||||
status=TicketStatus.OPEN,
|
||||
reason=reason
|
||||
)
|
||||
storage.save_ticket(ticket_data)
|
||||
|
||||
# Send welcome message
|
||||
welcome_content = TicketMessageFormatter.welcome_message(user, category)
|
||||
welcome_embed = TicketEmbedFormatter.ticket_created(ticket_data, user, category, guild.name)
|
||||
|
||||
# Send initial message with management view
|
||||
view = TicketManagementView(self.bot, storage, self.config)
|
||||
await ticket_channel.send(
|
||||
content=welcome_content,
|
||||
embed=welcome_embed,
|
||||
view=view
|
||||
)
|
||||
|
||||
# Send confirmation to user
|
||||
await interaction.followup.send(
|
||||
f"✅ Votre ticket a été créé: {ticket_channel.mention}",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
# Log to log channel
|
||||
if self.config.log_channel_id:
|
||||
log_channel = guild.get_channel(self.config.log_channel_id)
|
||||
if log_channel:
|
||||
log_embed = discord.Embed(
|
||||
title="🎫 Ticket Créé",
|
||||
color=Color.green(),
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
log_embed.add_field(name="👤 Utilisateur", value=user.mention, inline=True)
|
||||
log_embed.add_field(name="📁 Catégorie", value=category.name, inline=True)
|
||||
log_embed.add_field(name="⚡ Priorité", value=TicketEmbedFormatter.format_priority(priority), inline=True)
|
||||
log_embed.add_field(name="📝 Salon", value=ticket_channel.mention, inline=True)
|
||||
if reason:
|
||||
log_embed.add_field(name="📄 Raison", value=reason[:100], inline=False)
|
||||
|
||||
await log_channel.send(embed=log_embed)
|
||||
|
||||
|
||||
class TicketManagementView(View):
|
||||
"""
|
||||
View shown inside tickets for management actions.
|
||||
"""
|
||||
|
||||
def __init__(self, bot, storage, config: GuildTicketConfig):
|
||||
super().__init__(timeout=None)
|
||||
self.bot = bot
|
||||
self.storage = storage
|
||||
self.config = config
|
||||
|
||||
# Close button
|
||||
close_btn = Button(
|
||||
style=ButtonStyle.red,
|
||||
label="Fermer",
|
||||
emoji="🔒",
|
||||
custom_id="ticket_close"
|
||||
)
|
||||
close_btn.callback = self._close_callback
|
||||
self.add_item(close_btn)
|
||||
|
||||
# Claim button (if enabled)
|
||||
if config.claim_enabled:
|
||||
claim_btn = Button(
|
||||
style=ButtonStyle.gold,
|
||||
label="Réclamer",
|
||||
emoji="🙋",
|
||||
custom_id="ticket_claim"
|
||||
)
|
||||
claim_btn.callback = self._claim_callback
|
||||
self.add_item(claim_btn)
|
||||
|
||||
# Priority button
|
||||
priority_btn = Button(
|
||||
style=ButtonStyle.secondary,
|
||||
label="Priorité",
|
||||
emoji="⚡",
|
||||
custom_id="ticket_priority"
|
||||
)
|
||||
priority_btn.callback = self._priority_callback
|
||||
self.add_item(priority_btn)
|
||||
|
||||
# Transcript button
|
||||
transcript_btn = Button(
|
||||
style=ButtonStyle.secondary,
|
||||
label="Transcript",
|
||||
emoji="📄",
|
||||
custom_id="ticket_transcript"
|
||||
)
|
||||
transcript_btn.callback = self._transcript_callback
|
||||
self.add_item(transcript_btn)
|
||||
|
||||
async def _close_callback(self, interaction: Interaction):
|
||||
"""Handle close button click"""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
storage = get_storage()
|
||||
ticket = storage.load_ticket(interaction.channel.id)
|
||||
|
||||
if not ticket:
|
||||
await interaction.followup.send("Ticket non trouvé.", ephemeral=True)
|
||||
return
|
||||
|
||||
# Check if user can close (owner or staff)
|
||||
can_close = (
|
||||
interaction.user.id == ticket.user_id or
|
||||
self._is_staff(interaction)
|
||||
)
|
||||
|
||||
if not can_close:
|
||||
await interaction.followup.send(
|
||||
"Vous ne pouvez pas fermer ce ticket.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
# Show close modal
|
||||
modal = TicketCloseModal(self.bot, storage, self.config, ticket)
|
||||
await interaction.response.send_modal(modal)
|
||||
|
||||
async def _claim_callback(self, interaction: Interaction):
|
||||
"""Handle claim button click"""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
if not self._is_staff(interaction):
|
||||
await interaction.followup.send(
|
||||
"Vous n'êtes pas staff.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
storage = get_storage()
|
||||
ticket = storage.load_ticket(interaction.channel.id)
|
||||
|
||||
if not ticket:
|
||||
await interaction.followup.send("Ticket non trouvé.", ephemeral=True)
|
||||
return
|
||||
|
||||
if ticket.status == TicketStatus.CLAIMED and ticket.claimed_by == interaction.user.id:
|
||||
# Unclaim
|
||||
ticket.claimed_by = None
|
||||
ticket.status = TicketStatus.OPEN
|
||||
storage.save_ticket(ticket)
|
||||
|
||||
await interaction.followup.send("Ticket non réclamé.", ephemeral=True)
|
||||
await interaction.channel.send(
|
||||
embed=TicketEmbedFormatter.ticket_unclaimed(ticket, interaction.user)
|
||||
)
|
||||
elif ticket.status == TicketStatus.CLAIMED:
|
||||
await interaction.followup.send(
|
||||
"Ce ticket est déjà réclamé par quelqu'un d'autre.",
|
||||
ephemeral=True
|
||||
)
|
||||
else:
|
||||
# Claim
|
||||
ticket.claimed_by = interaction.user.id
|
||||
ticket.status = TicketStatus.CLAIMED
|
||||
storage.save_ticket(ticket)
|
||||
|
||||
await interaction.followup.send("Ticket réclamé!", ephemeral=True)
|
||||
await interaction.channel.send(
|
||||
embed=TicketEmbedFormatter.ticket_claimed(ticket, interaction.user)
|
||||
)
|
||||
|
||||
async def _priority_callback(self, interaction: Interaction):
|
||||
"""Handle priority button click"""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
if not self._is_staff(interaction):
|
||||
await interaction.followup.send(
|
||||
"Vous n'êtes pas staff.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
modal = TicketPriorityChangeModal(self.bot, get_storage(), self.config)
|
||||
await interaction.response.send_modal(modal)
|
||||
|
||||
async def _transcript_callback(self, interaction: Interaction):
|
||||
"""Handle transcript button click"""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
if not self._is_staff(interaction):
|
||||
await interaction.followup.send(
|
||||
"Vous n'êtes pas staff.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
storage = get_storage()
|
||||
ticket = storage.load_ticket(interaction.channel.id)
|
||||
|
||||
if not ticket:
|
||||
await interaction.followup.send("Ticket non trouvé.", ephemeral=True)
|
||||
return
|
||||
|
||||
# Generate transcript
|
||||
transcript_gen = get_transcript_generator()
|
||||
|
||||
# Collect messages
|
||||
messages = []
|
||||
async for msg in interaction.channel.history(limit=1000, oldest_first=True):
|
||||
if msg.author.bot:
|
||||
continue
|
||||
messages.append({
|
||||
"id": str(msg.id),
|
||||
"author_id": msg.author.id,
|
||||
"author_name": str(msg.author),
|
||||
"content": msg.content,
|
||||
"timestamp": msg.created_at.isoformat(),
|
||||
"attachments": [a.url for a in msg.attachments],
|
||||
"is_staff": self._is_staff_member(msg.author)
|
||||
})
|
||||
|
||||
# Save transcript
|
||||
filepath = transcript_gen.save_transcript(ticket, messages, format="html")
|
||||
|
||||
if filepath:
|
||||
await interaction.followup.send(
|
||||
f"✅ Transcript sauvegardé: `{filepath}`",
|
||||
ephemeral=True
|
||||
)
|
||||
else:
|
||||
await interaction.followup.send(
|
||||
"❌ Erreur lors de la génération du transcript.",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
def _is_staff(self, interaction: Interaction) -> bool:
|
||||
"""Check if user is staff"""
|
||||
guild = interaction.guild
|
||||
user = interaction.user
|
||||
|
||||
for role_id in self.config.staff_roles:
|
||||
role = guild.get_role(int(role_id))
|
||||
if role and role in user.roles:
|
||||
return True
|
||||
|
||||
# Also check category-specific roles
|
||||
storage = get_storage()
|
||||
ticket = storage.load_ticket(interaction.channel.id)
|
||||
if ticket:
|
||||
category = self.config.get_category(ticket.category_id)
|
||||
if category:
|
||||
for role_id in category.staff_roles:
|
||||
role = guild.get_role(int(role_id))
|
||||
if role and role in user.roles:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _is_staff_member(self, member) -> bool:
|
||||
"""Check if member is staff"""
|
||||
guild = member.guild
|
||||
for role_id in self.config.staff_roles:
|
||||
role = guild.get_role(int(role_id))
|
||||
if role and role in member.roles:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class TicketCloseModal(Modal):
|
||||
"""Modal for closing a ticket with reason"""
|
||||
|
||||
def __init__(self, bot, storage, config: GuildTicketConfig, ticket: TicketData):
|
||||
super().__init__(title="🔒 Fermer le Ticket")
|
||||
self.bot = bot
|
||||
self.storage = storage
|
||||
self.config = config
|
||||
self.ticket = ticket
|
||||
|
||||
self.reason = TextInput(
|
||||
label="Raison de la fermeture",
|
||||
placeholder="Pourquoi fermez-vous ce ticket?",
|
||||
required=False,
|
||||
style=discord.TextStyle.paragraph,
|
||||
custom_id="close_reason"
|
||||
)
|
||||
self.add_item(self.reason)
|
||||
|
||||
async def on_submit(self, interaction: Interaction):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
# Update ticket
|
||||
self.ticket.status = TicketStatus.CLOSED
|
||||
self.ticket.closed_at = datetime.now()
|
||||
self.storage.save_ticket(self.ticket)
|
||||
|
||||
# Calculate duration
|
||||
duration_minutes = self.ticket.duration_minutes
|
||||
|
||||
# Send close embed
|
||||
close_embed = TicketEmbedFormatter.ticket_closed(
|
||||
self.ticket,
|
||||
interaction.user,
|
||||
duration_minutes,
|
||||
self.reason.value
|
||||
)
|
||||
|
||||
# Update channel permissions
|
||||
overwrites = interaction.channel.overwrites.copy()
|
||||
for target, perms in overwrites.items():
|
||||
if isinstance(target, discord.Member) and target.id != interaction.guild.me.id:
|
||||
overwrites[target] = discord.PermissionOverwrite(
|
||||
read_messages=True,
|
||||
send_messages=False
|
||||
)
|
||||
|
||||
await interaction.channel.edit(
|
||||
name=f"closed-{interaction.channel.name}",
|
||||
overwrites=overwrites
|
||||
)
|
||||
|
||||
# Send close message
|
||||
await interaction.channel.send(
|
||||
content=TicketMessageFormatter.ticket_closed_notification(
|
||||
interaction.user,
|
||||
self.reason.value
|
||||
),
|
||||
embed=close_embed
|
||||
)
|
||||
|
||||
# Show survey if enabled
|
||||
if self.config.survey_enabled:
|
||||
survey_view = SurveyView(self.bot, self.storage, self.config, self.ticket)
|
||||
survey_embed = TicketEmbedFormatter.survey_embed()
|
||||
await interaction.channel.send(embed=survey_embed, view=survey_view)
|
||||
|
||||
# Log
|
||||
if self.config.log_channel_id:
|
||||
log_channel = interaction.guild.get_channel(self.config.log_channel_id)
|
||||
if log_channel:
|
||||
log_embed = discord.Embed(
|
||||
title="🔴 Ticket Fermé",
|
||||
color=Color.red(),
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
log_embed.add_field(name="👤 Fermé par", value=interaction.user.mention, inline=True)
|
||||
log_embed.add_field(name="📝 Salon", value=interaction.channel.mention, inline=True)
|
||||
log_embed.add_field(name="⏱️ Durée", value=TicketEmbedFormatter._format_duration(duration_minutes), inline=True)
|
||||
|
||||
await log_channel.send(embed=log_embed)
|
||||
|
||||
await interaction.followup.send("Ticket fermé!", ephemeral=True)
|
||||
|
||||
|
||||
class TicketPriorityChangeModal(Modal):
|
||||
"""Modal for changing ticket priority"""
|
||||
|
||||
def __init__(self, bot, storage, config: GuildTicketConfig):
|
||||
super().__init__(title="⚡ Changer la Priorité")
|
||||
self.bot = bot
|
||||
self.storage = storage
|
||||
self.config = config
|
||||
|
||||
self.priority = Select(
|
||||
placeholder="Sélectionnez la nouvelle priorité",
|
||||
options=[
|
||||
discord.SelectOption(label="Basse", value="low", emoji="🟢"),
|
||||
discord.SelectOption(label="Normale", value="normal", emoji="🔵"),
|
||||
discord.SelectOption(label="Haute", value="high", emoji="🟠"),
|
||||
discord.SelectOption(label="Urgente", value="urgent", emoji="🔴"),
|
||||
],
|
||||
custom_id="priority_change"
|
||||
)
|
||||
self.add_item(self.priority)
|
||||
|
||||
async def on_submit(self, interaction: Interaction):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
ticket = self.storage.load_ticket(interaction.channel.id)
|
||||
if not ticket:
|
||||
await interaction.followup.send("Ticket non trouvé.", ephemeral=True)
|
||||
return
|
||||
|
||||
priority_map = {
|
||||
"low": Priority.LOW,
|
||||
"normal": Priority.NORMAL,
|
||||
"high": Priority.HIGH,
|
||||
"urgent": Priority.URGENT
|
||||
}
|
||||
|
||||
old_priority = ticket.priority
|
||||
ticket.priority = priority_map[self.priority.values[0]]
|
||||
self.storage.save_ticket(ticket)
|
||||
|
||||
embed = discord.Embed(
|
||||
title="⚡ Priorité Changée",
|
||||
description=f"La priorité a été changée de **{old_priority.value}** à **{ticket.priority.value}**",
|
||||
color=TicketEmbedFormatter.get_priority_color(ticket.priority)
|
||||
)
|
||||
|
||||
await interaction.channel.send(embed=embed)
|
||||
await interaction.followup.send("Priorité mise à jour!", ephemeral=True)
|
||||
|
||||
|
||||
class SurveyView(View):
|
||||
"""View for satisfaction survey"""
|
||||
|
||||
def __init__(self, bot, storage, config: GuildTicketConfig, ticket: TicketData):
|
||||
super().__init__(timeout=None) # Persistent view
|
||||
self.bot = bot
|
||||
self.storage = storage
|
||||
self.config = config
|
||||
self.ticket = ticket
|
||||
|
||||
for i in range(1, 6):
|
||||
btn = Button(
|
||||
style=ButtonStyle.secondary,
|
||||
label=f"{i} ⭐",
|
||||
emoji="⭐" if i == 5 else None,
|
||||
custom_id=f"survey_{i}"
|
||||
)
|
||||
btn.callback = self._create_rating_callback(i)
|
||||
self.add_item(btn)
|
||||
|
||||
def _create_rating_callback(self, rating: int):
|
||||
async def callback(interaction: Interaction):
|
||||
await self.on_rating(interaction, rating)
|
||||
return callback
|
||||
|
||||
async def on_rating(self, interaction: Interaction, rating: int):
|
||||
"""Handle rating selection"""
|
||||
# Only allow ticket creator to rate
|
||||
if interaction.user.id != self.ticket.user_id:
|
||||
await interaction.response.send_message(
|
||||
"Seul le créateur du ticket peut voter.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
# Update ticket
|
||||
self.ticket.rating = rating
|
||||
self.storage.save_ticket(self.ticket)
|
||||
|
||||
# Show thanks
|
||||
embed = TicketEmbedFormatter.survey_thanks_embed(rating)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
|
||||
# Disable buttons
|
||||
for item in self.children:
|
||||
item.disabled = True
|
||||
|
||||
await interaction.message.edit(view=self)
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue