Première version non terminer du nouveau système de ticket § ajout du système de boutons persistant (non tester)

This commit is contained in:
Mathis 2026-01-04 00:01:15 +01:00
parent 10173132a2
commit 93e2f9bb44
15 changed files with 3788 additions and 209 deletions

View file

@ -0,0 +1,29 @@
"""
Ticket Data Module
==================
Data management for the ticket system.
"""
from .models import (
TicketStatus,
Priority,
TicketCategory,
TicketMessage,
TicketData,
TicketStats,
GuildTicketConfig
)
from .storage import TicketStorage, get_storage, reset_storage
__all__ = [
"TicketStatus",
"Priority",
"TicketCategory",
"TicketMessage",
"TicketData",
"TicketStats",
"GuildTicketConfig",
"TicketStorage",
"get_storage",
"reset_storage",
]

View file

@ -0,0 +1,353 @@
"""
Ticket Data Models
==================
Data models for the modular ticket system.
"""
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional, List, Dict, Any
from enum import Enum
class TicketStatus(Enum):
"""Ticket status enumeration"""
OPEN = "open"
CLAIMED = "claimed"
CLOSED = "closed"
ARCHIVED = "archived"
class Priority(Enum):
"""Ticket priority levels"""
LOW = "low"
NORMAL = "normal"
HIGH = "high"
URGENT = "urgent"
class TicketCategory:
"""
Represents a ticket category with customizable settings.
Attributes:
id: Unique identifier for the category
name: Display name
description: Description shown in panel
emoji: Emoji icon for the category
color: Embed color (hex)
staff_roles: List of role IDs who can access this category
welcome_message: Custom welcome message for this category
require_reason: Whether to require a reason when creating
max_tickets_per_user: Maximum tickets per user in this category
auto_close_days: Days before auto-closing inactive tickets
priority_enabled: Whether priority selection is enabled
allow_claims: Whether staff can claim tickets
survey_enabled: Whether satisfaction survey is enabled
"""
def __init__(
self,
id: str,
name: str,
description: str = "",
emoji: str = "🎫",
color: str = "#3498db",
staff_roles: List[str] = None,
welcome_message: str = None,
require_reason: bool = False,
max_tickets_per_user: int = 5,
auto_close_days: int = 7,
priority_enabled: bool = True,
allow_claims: bool = True,
survey_enabled: bool = True
):
self.id = id
self.name = name
self.description = description
self.emoji = emoji
self.color = color
self.staff_roles = staff_roles or []
self.welcome_message = welcome_message or "Merci d'avoir créé un ticket. Notre équipe va vous répondre sous peu."
self.require_reason = require_reason
self.max_tickets_per_user = max_tickets_per_user
self.auto_close_days = auto_close_days
self.priority_enabled = priority_enabled
self.allow_claims = allow_claims
self.survey_enabled = survey_enabled
def to_dict(self) -> Dict[str, Any]:
"""Convert category to dictionary for storage"""
return {
"id": self.id,
"name": self.name,
"description": self.description,
"emoji": self.emoji,
"color": self.color,
"staff_roles": self.staff_roles,
"welcome_message": self.welcome_message,
"require_reason": self.require_reason,
"max_tickets_per_user": self.max_tickets_per_user,
"auto_close_days": self.auto_close_days,
"priority_enabled": self.priority_enabled,
"allow_claims": self.allow_claims,
"survey_enabled": self.survey_enabled
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'TicketCategory':
"""Create category from dictionary"""
return cls(
id=data.get("id", ""),
name=data.get("name", ""),
description=data.get("description", ""),
emoji=data.get("emoji", "🎫"),
color=data.get("color", "#3498db"),
staff_roles=data.get("staff_roles", []),
welcome_message=data.get("welcome_message"),
require_reason=data.get("require_reason", False),
max_tickets_per_user=data.get("max_tickets_per_user", 5),
auto_close_days=data.get("auto_close_days", 7),
priority_enabled=data.get("priority_enabled", True),
allow_claims=data.get("allow_claims", True),
survey_enabled=data.get("survey_enabled", True)
)
@dataclass
class TicketMessage:
"""Represents a message in a ticket"""
id: str
author_id: int
author_name: str
content: str
timestamp: datetime
attachments: List[str] = field(default_factory=list)
is_staff: bool = False
def to_dict(self) -> Dict[str, Any]:
return {
"id": self.id,
"author_id": self.author_id,
"author_name": self.author_name,
"content": self.content,
"timestamp": self.timestamp.isoformat(),
"attachments": self.attachments,
"is_staff": self.is_staff
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'TicketMessage':
return cls(
id=data.get("id", ""),
author_id=data.get("author_id", 0),
author_name=data.get("author_name", ""),
content=data.get("content", ""),
timestamp=datetime.fromisoformat(data.get("timestamp", datetime.now().isoformat())),
attachments=data.get("attachments", []),
is_staff=data.get("is_staff", False)
)
@dataclass
class TicketData:
"""
Main ticket data structure.
Attributes:
channel_id: Discord channel ID
guild_id: Discord guild ID
user_id: User who created the ticket
category_id: Ticket category
status: Current ticket status
priority: Ticket priority level
created_at: Creation timestamp
closed_at: Closing timestamp
claimed_by: Staff member who claimed the ticket
messages: List of messages in the ticket
reason: Reason for creating the ticket
transcript_path: Path to generated transcript
rating: Satisfaction rating (1-5)
feedback: User feedback text
"""
channel_id: int
guild_id: int
user_id: int
category_id: str
status: TicketStatus = TicketStatus.OPEN
priority: Priority = Priority.NORMAL
created_at: datetime = field(default_factory=datetime.now)
closed_at: Optional[datetime] = None
claimed_by: Optional[int] = None
messages: List[TicketMessage] = field(default_factory=list)
reason: str = ""
transcript_path: Optional[str] = None
rating: Optional[int] = None
feedback: Optional[str] = None
@property
def is_open(self) -> bool:
return self.status in [TicketStatus.OPEN, TicketStatus.CLAIMED]
@property
def duration_minutes(self) -> Optional[float]:
"""Get ticket duration in minutes"""
end = self.closed_at or datetime.now()
return (end - self.created_at).total_seconds() / 60
def to_dict(self) -> Dict[str, Any]:
return {
"channel_id": self.channel_id,
"guild_id": self.guild_id,
"user_id": self.user_id,
"category_id": self.category_id,
"status": self.status.value,
"priority": self.priority.value,
"created_at": self.created_at.isoformat(),
"closed_at": self.closed_at.isoformat() if self.closed_at else None,
"claimed_by": self.claimed_by,
"messages": [m.to_dict() for m in self.messages],
"reason": self.reason,
"transcript_path": self.transcript_path,
"rating": self.rating,
"feedback": self.feedback
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'TicketData':
messages = [TicketMessage.from_dict(m) for m in data.get("messages", [])]
return cls(
channel_id=data.get("channel_id", 0),
guild_id=data.get("guild_id", 0),
user_id=data.get("user_id", 0),
category_id=data.get("category_id", ""),
status=TicketStatus(data.get("status", "open")),
priority=Priority(data.get("priority", "normal")),
created_at=datetime.fromisoformat(data.get("created_at", datetime.now().isoformat())),
closed_at=datetime.fromisoformat(data["closed_at"]) if data.get("closed_at") else None,
claimed_by=data.get("claimed_by"),
messages=messages,
reason=data.get("reason", ""),
transcript_path=data.get("transcript_path"),
rating=data.get("rating"),
feedback=data.get("feedback")
)
@dataclass
class TicketStats:
"""Statistics for a ticket system"""
total_tickets: int = 0
open_tickets: int = 0
closed_tickets: int = 0
claimed_tickets: int = 0
average_duration_minutes: float = 0
average_rating: float = 0
tickets_by_category: Dict[str, int] = field(default_factory=dict)
tickets_by_priority: Dict[str, int] = field(default_factory=dict)
tickets_by_day: Dict[str, int] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
return {
"total_tickets": self.total_tickets,
"open_tickets": self.open_tickets,
"closed_tickets": self.closed_tickets,
"claimed_tickets": self.claimed_tickets,
"average_duration_minutes": self.average_duration_minutes,
"average_rating": self.average_rating,
"tickets_by_category": self.tickets_by_category,
"tickets_by_priority": self.tickets_by_priority,
"tickets_by_day": self.tickets_by_day
}
@dataclass
class GuildTicketConfig:
"""
Guild-wide ticket configuration.
Attributes:
guild_id: Discord guild ID
enabled: Whether the ticket system is enabled
categories: List of ticket categories
default_category: Default category ID
log_channel_id: Channel for ticket logs
archive_channel_id: Channel for archived transcripts
staff_roles: Global staff roles
max_tickets_per_user: Global max tickets per user
auto_close_enabled: Whether auto-close is enabled
auto_close_days: Days before auto-close
transcript_enabled: Whether transcripts are generated
claim_enabled: Whether claiming is enabled by default
survey_enabled: Whether surveys are enabled by default
panel_channel_id: Channel for the ticket panel
welcome_enabled: Whether welcome messages are shown
priority_enabled: Whether priority selection is enabled
"""
guild_id: int
enabled: bool = True
categories: List[TicketCategory] = field(default_factory=list)
default_category: Optional[str] = None
log_channel_id: Optional[int] = None
archive_channel_id: Optional[int] = None
staff_roles: List[str] = field(default_factory=list)
max_tickets_per_user: int = 5
auto_close_enabled: bool = False
auto_close_days: int = 7
transcript_enabled: bool = True
claim_enabled: bool = True
survey_enabled: bool = True
panel_channel_id: Optional[int] = None
welcome_enabled: bool = True
priority_enabled: bool = True
def to_dict(self) -> Dict[str, Any]:
return {
"guild_id": self.guild_id,
"enabled": self.enabled,
"categories": [c.to_dict() for c in self.categories],
"default_category": self.default_category,
"log_channel_id": self.log_channel_id,
"archive_channel_id": self.archive_channel_id,
"staff_roles": self.staff_roles,
"max_tickets_per_user": self.max_tickets_per_user,
"auto_close_enabled": self.auto_close_enabled,
"auto_close_days": self.auto_close_days,
"transcript_enabled": self.transcript_enabled,
"claim_enabled": self.claim_enabled,
"survey_enabled": self.survey_enabled,
"panel_channel_id": self.panel_channel_id,
"welcome_enabled": self.welcome_enabled,
"priority_enabled": self.priority_enabled
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'GuildTicketConfig':
categories = [TicketCategory.from_dict(c) for c in data.get("categories", [])]
return cls(
guild_id=data.get("guild_id", 0),
enabled=data.get("enabled", True),
categories=categories,
default_category=data.get("default_category"),
log_channel_id=data.get("log_channel_id"),
archive_channel_id=data.get("archive_channel_id"),
staff_roles=data.get("staff_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),
transcript_enabled=data.get("transcript_enabled", True),
claim_enabled=data.get("claim_enabled", True),
survey_enabled=data.get("survey_enabled", True),
panel_channel_id=data.get("panel_channel_id"),
welcome_enabled=data.get("welcome_enabled", True),
priority_enabled=data.get("priority_enabled", True)
)
def get_category(self, category_id: str) -> Optional[TicketCategory]:
"""Get a category by ID"""
for cat in self.categories:
if cat.id == category_id:
return cat
return None

View file

@ -0,0 +1,323 @@
"""
Ticket Storage Utility
======================
Modular storage system for ticket data with caching support.
"""
import json
import os
from datetime import datetime
from typing import Dict, Optional, Any, List
from threading import Lock
from .models import TicketData, GuildTicketConfig, TicketStatus
class TicketStorage:
"""
Thread-safe storage manager for ticket data.
Features:
- Automatic file creation
- In-memory caching with write-through
- Backup creation before writes
- Data validation on load
"""
def __init__(self, base_path: str = "data/tickets"):
self.base_path = base_path
self.config_path = os.path.join(base_path, "config.json")
self.tickets_path = os.path.join(base_path, "tickets")
self.backup_path = os.path.join(base_path, "backups")
self.cache: Dict[str, Any] = {}
self.cache_lock = Lock()
self._ensure_directories()
def _ensure_directories(self) -> None:
"""Create necessary directories"""
for path in [self.base_path, self.tickets_path, self.backup_path]:
os.makedirs(path, exist_ok=True)
def _create_backup(self, file_path: str) -> str:
"""Create a timestamped backup of a file"""
if not os.path.exists(file_path):
return ""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = os.path.basename(file_path)
backup_file = os.path.join(self.backup_path, f"{timestamp}_{filename}")
try:
with open(file_path, 'r', encoding='utf-8') as src:
with open(backup_file, 'w', encoding='utf-8') as dst:
dst.write(src.read())
return backup_file
except Exception:
return ""
def _get_cache_key(self, guild_id: int, key_type: str) -> str:
"""Generate a cache key"""
return f"{guild_id}_{key_type}"
# --- Config Methods ---
def load_config(self, guild_id: int) -> GuildTicketConfig:
"""Load guild ticket configuration"""
cache_key = self._get_cache_key(guild_id, "config")
with self.cache_lock:
if cache_key in self.cache:
return self.cache[cache_key]
try:
if os.path.exists(self.config_path):
with open(self.config_path, 'r', encoding='utf-8') as f:
all_configs = json.load(f)
config_data = all_configs.get(str(guild_id), {})
config = GuildTicketConfig.from_dict(config_data)
config.guild_id = guild_id
else:
config = GuildTicketConfig(guild_id=guild_id)
except (json.JSONDecodeError, KeyError) as e:
print(f"[TicketStorage] Error loading config for guild {guild_id}: {e}")
config = GuildTicketConfig(guild_id=guild_id)
with self.cache_lock:
self.cache[cache_key] = config
return config
def save_config(self, guild_id: int, config: GuildTicketConfig) -> bool:
"""Save guild ticket configuration"""
cache_key = self._get_cache_key(guild_id, "config")
try:
# Create backup
self._create_backup(self.config_path)
# Load all configs
all_configs = {}
if os.path.exists(self.config_path):
with open(self.config_path, 'r', encoding='utf-8') as f:
all_configs = json.load(f)
# Update config
all_configs[str(guild_id)] = config.to_dict()
# Save
with open(self.config_path, 'w', encoding='utf-8') as f:
json.dump(all_configs, f, indent=4, ensure_ascii=False)
# Update cache
with self.cache_lock:
self.cache[cache_key] = config
return True
except Exception as e:
print(f"[TicketStorage] Error saving config for guild {guild_id}: {e}")
return False
# --- Ticket Methods ---
def get_ticket_path(self, channel_id: int) -> str:
"""Get the file path for a ticket"""
return os.path.join(self.tickets_path, f"{channel_id}.json")
def load_ticket(self, channel_id: int) -> Optional[TicketData]:
"""Load a ticket by channel ID"""
cache_key = self._get_cache_key(channel_id, "ticket")
with self.cache_lock:
if cache_key in self.cache:
return self.cache[cache_key]
path = self.get_ticket_path(channel_id)
try:
if os.path.exists(path):
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
ticket = TicketData.from_dict(data)
else:
return None
except (json.JSONDecodeError, KeyError) as e:
print(f"[TicketStorage] Error loading ticket {channel_id}: {e}")
return None
with self.cache_lock:
self.cache[cache_key] = ticket
return ticket
def save_ticket(self, ticket: TicketData) -> bool:
"""Save a ticket"""
cache_key = self._get_cache_key(ticket.channel_id, "ticket")
try:
# Create backup
self._create_backup(self.get_ticket_path(ticket.channel_id))
path = self.get_ticket_path(ticket.channel_id)
with open(path, 'w', encoding='utf-8') as f:
json.dump(ticket.to_dict(), f, indent=4, ensure_ascii=False)
with self.cache_lock:
self.cache[cache_key] = ticket
return True
except Exception as e:
print(f"[TicketStorage] Error saving ticket {ticket.channel_id}: {e}")
return False
def delete_ticket(self, channel_id: int) -> bool:
"""Delete a ticket file"""
cache_key = self._get_cache_key(channel_id, "ticket")
path = self.get_ticket_path(channel_id)
try:
if os.path.exists(path):
# Create backup before delete
self._create_backup(path)
os.remove(path)
with self.cache_lock:
self.cache.pop(cache_key, None)
return True
except Exception as e:
print(f"[TicketStorage] Error deleting ticket {channel_id}: {e}")
return False
def get_guild_tickets(self, guild_id: int, status: Optional[TicketStatus] = None) -> List[TicketData]:
"""Get all tickets for a guild, optionally filtered by status"""
tickets = []
if not os.path.exists(self.tickets_path):
return tickets
for filename in os.listdir(self.tickets_path):
if not filename.endswith('.json'):
continue
try:
channel_id = int(filename.replace('.json', ''))
ticket = self.load_ticket(channel_id)
if ticket and ticket.guild_id == guild_id:
if status is None or ticket.status == status:
tickets.append(ticket)
except (ValueError, TypeError):
continue
return tickets
def get_user_tickets(self, guild_id: int, user_id: int, open_only: bool = True) -> List[TicketData]:
"""Get all tickets for a user"""
tickets = self.get_guild_tickets(guild_id)
user_tickets = [t for t in tickets if t.user_id == user_id]
if open_only:
user_tickets = [t for t in user_tickets if t.is_open]
return user_tickets
def count_user_tickets(self, guild_id: int, user_id: int, category_id: Optional[str] = None) -> int:
"""Count tickets for a user, optionally in a specific category"""
tickets = self.get_user_tickets(guild_id, user_id, open_only=True)
if category_id:
tickets = [t for t in tickets if t.category_id == category_id]
return len(tickets)
# --- Bulk Operations ---
def get_all_guild_ids(self) -> List[int]:
"""Get all guild IDs with ticket configurations"""
if not os.path.exists(self.config_path):
return []
try:
with open(self.config_path, 'r', encoding='utf-8') as f:
configs = json.load(f)
return [int(gid) for gid in configs.keys()]
except (json.JSONDecodeError, ValueError):
return []
def cleanup_cache(self, max_size: int = 1000) -> int:
"""Clear cache if it exceeds max size"""
with self.cache_lock:
if len(self.cache) <= max_size:
return 0
# Clear all non-config keys (keep recent)
removed = 0
keys_to_remove = [k for k in self.cache.keys() if not k.endswith("_config")]
for key in keys_to_remove[:len(keys_to_remove) - max_size]:
del self.cache[key]
removed += 1
return removed
def invalidate_cache(self, guild_id: Optional[int] = None) -> None:
"""Invalidate cache for a guild or all"""
with self.cache_lock:
if guild_id is None:
self.cache.clear()
else:
keys_to_remove = [k for k in self.cache.keys() if k.startswith(str(guild_id))]
for key in keys_to_remove:
del self.cache[key]
# --- Statistics ---
def get_storage_stats(self) -> Dict[str, Any]:
"""Get storage statistics"""
stats = {
"total_tickets": 0,
"total_backups": 0,
"config_size_bytes": 0,
"cache_size": 0,
"tickets_by_status": {}
}
# Count tickets
if os.path.exists(self.tickets_path):
for filename in os.listdir(self.tickets_path):
if filename.endswith('.json'):
stats["total_tickets"] += 1
# Count backups
if os.path.exists(self.backup_path):
stats["total_backups"] = len(os.listdir(self.backup_path))
# Config size
if os.path.exists(self.config_path):
stats["config_size_bytes"] = os.path.getsize(self.config_path)
# Cache size
with self.cache_lock:
stats["cache_size"] = len(self.cache)
return stats
# Singleton instance
_storage_instance: Optional[TicketStorage] = None
def get_storage() -> TicketStorage:
"""Get the singleton storage instance"""
global _storage_instance
if _storage_instance is None:
_storage_instance = TicketStorage()
return _storage_instance
def reset_storage() -> None:
"""Reset the storage instance (for testing)"""
global _storage_instance
_storage_instance = None