522 lines
18 KiB
Python
522 lines
18 KiB
Python
"""
|
||
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
|
||
)
|
||
|