Kuby/commandes/ticket/data/storage.py

323 lines
11 KiB
Python

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