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