""" User Profile Manager for MarkiTect. This module provides comprehensive CRUD operations for user profiles including database integration, validation, and profile inheritance functionality. """ import sqlite3 import json import os from datetime import datetime from typing import Dict, Any, Optional, List, Union from pathlib import Path from jsonschema import ValidationError from .schema import ProfileSchema, ProfileData from ..database import DatabaseManager class ProfileNotFoundError(Exception): """Raised when a profile is not found.""" pass class ProfileValidationError(Exception): """Raised when profile data fails validation.""" pass class ProfileManager: """ Comprehensive user profile management system. Handles CRUD operations, validation, and database integration for user profiles with support for multiple named profiles and template integration. """ def __init__(self, db_path: Optional[str] = None): """ Initialize profile manager. Args: db_path: Path to SQLite database file. If None, uses default from config. """ if db_path: self.db_path = db_path else: # Use default database path from MarkiTect from ..config_manager import ConfigurationManager config_manager = ConfigurationManager() config = config_manager.get_current_config() self.db_path = config.get('database_path', 'markitect.db') self._ensure_database_initialized() def _ensure_database_initialized(self) -> None: """Ensure database and user_profiles table exist.""" # Initialize main database if needed if not os.path.exists(self.db_path): db_manager = DatabaseManager(self.db_path) db_manager.initialize_database() # Create user_profiles table self._create_profiles_table() def _create_profiles_table(self) -> None: """Create user_profiles table if it doesn't exist.""" conn = sqlite3.connect(self.db_path) cursor = conn.cursor() try: cursor.execute(''' CREATE TABLE IF NOT EXISTS user_profiles ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, description TEXT, data TEXT NOT NULL, -- JSON data is_active BOOLEAN DEFAULT TRUE, is_default BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ''') # Create index for faster lookups cursor.execute(''' CREATE INDEX IF NOT EXISTS idx_user_profiles_name ON user_profiles(name) ''') cursor.execute(''' CREATE INDEX IF NOT EXISTS idx_user_profiles_active ON user_profiles(is_active) ''') conn.commit() except sqlite3.Error as e: conn.rollback() raise RuntimeError(f"Failed to create profiles table: {e}") finally: conn.close() def create_profile(self, name: str, data: Union[Dict[str, Any], ProfileData], description: Optional[str] = None, set_as_default: bool = False) -> int: """ Create a new user profile. Args: name: Unique profile name data: Profile data (dict or ProfileData instance) description: Optional profile description set_as_default: Whether to set as default profile Returns: ID of created profile Raises: ProfileValidationError: If data is invalid ValueError: If profile name already exists """ # Convert ProfileData to dict if needed if isinstance(data, ProfileData): profile_data = data.to_dict() else: profile_data = data.copy() # Add timestamps now = datetime.now().isoformat() profile_data.setdefault('created_at', now) profile_data['updated_at'] = now # Validate data try: ProfileSchema.validate(profile_data) except ValidationError as e: raise ProfileValidationError(f"Profile data validation failed: {e.message}") conn = sqlite3.connect(self.db_path) cursor = conn.cursor() try: # Check if profile name already exists cursor.execute('SELECT id FROM user_profiles WHERE name = ?', (name,)) if cursor.fetchone(): raise ValueError(f"Profile '{name}' already exists") # If setting as default, unset any existing default if set_as_default: cursor.execute(''' UPDATE user_profiles SET is_default = FALSE WHERE is_default = TRUE ''') # Insert new profile cursor.execute(''' INSERT INTO user_profiles (name, description, data, is_default, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?) ''', ( name, description, json.dumps(profile_data, indent=2), set_as_default, now, now )) conn.commit() profile_id = cursor.lastrowid return profile_id except sqlite3.IntegrityError as e: conn.rollback() if "UNIQUE constraint failed" in str(e): raise ValueError(f"Profile '{name}' already exists") raise RuntimeError(f"Database error: {e}") except sqlite3.Error as e: conn.rollback() raise RuntimeError(f"Failed to create profile: {e}") finally: conn.close() def get_profile(self, name: str) -> ProfileData: """ Get profile by name. Args: name: Profile name Returns: ProfileData instance Raises: ProfileNotFoundError: If profile doesn't exist """ conn = sqlite3.connect(self.db_path) cursor = conn.cursor() try: cursor.execute(''' SELECT data FROM user_profiles WHERE name = ? AND is_active = TRUE ''', (name,)) row = cursor.fetchone() if not row: raise ProfileNotFoundError(f"Profile '{name}' not found") profile_data = json.loads(row[0]) return ProfileData.from_dict(profile_data) except sqlite3.Error as e: raise RuntimeError(f"Failed to get profile: {e}") finally: conn.close() def get_profile_info(self, name: str) -> Dict[str, Any]: """ Get profile metadata without full data. Args: name: Profile name Returns: Dictionary with profile metadata Raises: ProfileNotFoundError: If profile doesn't exist """ conn = sqlite3.connect(self.db_path) cursor = conn.cursor() try: cursor.execute(''' SELECT id, name, description, is_active, is_default, created_at, updated_at FROM user_profiles WHERE name = ? AND is_active = TRUE ''', (name,)) row = cursor.fetchone() if not row: raise ProfileNotFoundError(f"Profile '{name}' not found") return { 'id': row[0], 'name': row[1], 'description': row[2], 'is_active': bool(row[3]), 'is_default': bool(row[4]), 'created_at': row[5], 'updated_at': row[6] } except sqlite3.Error as e: raise RuntimeError(f"Failed to get profile info: {e}") finally: conn.close() def update_profile(self, name: str, data: Union[Dict[str, Any], ProfileData], description: Optional[str] = None) -> bool: """ Update existing profile. Args: name: Profile name data: Updated profile data description: Updated description (optional) Returns: True if updated successfully Raises: ProfileNotFoundError: If profile doesn't exist ProfileValidationError: If data is invalid """ # Convert ProfileData to dict if needed if isinstance(data, ProfileData): profile_data = data.to_dict() else: profile_data = data.copy() # Update timestamp profile_data['updated_at'] = datetime.now().isoformat() # Validate data try: ProfileSchema.validate(profile_data) except ValidationError as e: raise ProfileValidationError(f"Profile data validation failed: {e.message}") conn = sqlite3.connect(self.db_path) cursor = conn.cursor() try: # Check if profile exists cursor.execute('SELECT id FROM user_profiles WHERE name = ? AND is_active = TRUE', (name,)) if not cursor.fetchone(): raise ProfileNotFoundError(f"Profile '{name}' not found") # Update profile update_fields = ['data = ?', 'updated_at = ?'] update_values = [json.dumps(profile_data, indent=2), profile_data['updated_at']] if description is not None: update_fields.append('description = ?') update_values.append(description) update_values.append(name) # For WHERE clause cursor.execute(f''' UPDATE user_profiles SET {', '.join(update_fields)} WHERE name = ? AND is_active = TRUE ''', update_values) conn.commit() return cursor.rowcount > 0 except sqlite3.Error as e: conn.rollback() raise RuntimeError(f"Failed to update profile: {e}") finally: conn.close() def delete_profile(self, name: str, hard_delete: bool = False) -> bool: """ Delete a profile. Args: name: Profile name hard_delete: If True, permanently delete. If False, mark as inactive. Returns: True if deleted successfully Raises: ProfileNotFoundError: If profile doesn't exist """ conn = sqlite3.connect(self.db_path) cursor = conn.cursor() try: # Check if profile exists cursor.execute('SELECT id FROM user_profiles WHERE name = ?', (name,)) if not cursor.fetchone(): raise ProfileNotFoundError(f"Profile '{name}' not found") if hard_delete: # Permanently delete cursor.execute('DELETE FROM user_profiles WHERE name = ?', (name,)) else: # Mark as inactive cursor.execute(''' UPDATE user_profiles SET is_active = FALSE, updated_at = ? WHERE name = ? ''', (datetime.now().isoformat(), name)) conn.commit() return cursor.rowcount > 0 except sqlite3.Error as e: conn.rollback() raise RuntimeError(f"Failed to delete profile: {e}") finally: conn.close() def list_profiles(self, include_inactive: bool = False) -> List[Dict[str, Any]]: """ List all profiles. Args: include_inactive: Whether to include inactive profiles Returns: List of profile metadata dictionaries """ conn = sqlite3.connect(self.db_path) cursor = conn.cursor() try: if include_inactive: cursor.execute(''' SELECT id, name, description, is_active, is_default, created_at, updated_at FROM user_profiles ORDER BY is_default DESC, name ASC ''') else: cursor.execute(''' SELECT id, name, description, is_active, is_default, created_at, updated_at FROM user_profiles WHERE is_active = TRUE ORDER BY is_default DESC, name ASC ''') rows = cursor.fetchall() return [ { 'id': row[0], 'name': row[1], 'description': row[2], 'is_active': bool(row[3]), 'is_default': bool(row[4]), 'created_at': row[5], 'updated_at': row[6] } for row in rows ] except sqlite3.Error as e: raise RuntimeError(f"Failed to list profiles: {e}") finally: conn.close() def set_default_profile(self, name: str) -> bool: """ Set a profile as the default profile. Args: name: Profile name Returns: True if set successfully Raises: ProfileNotFoundError: If profile doesn't exist """ conn = sqlite3.connect(self.db_path) cursor = conn.cursor() try: # Check if profile exists cursor.execute('SELECT id FROM user_profiles WHERE name = ? AND is_active = TRUE', (name,)) if not cursor.fetchone(): raise ProfileNotFoundError(f"Profile '{name}' not found") # Unset all defaults cursor.execute('UPDATE user_profiles SET is_default = FALSE') # Set new default cursor.execute(''' UPDATE user_profiles SET is_default = TRUE, updated_at = ? WHERE name = ? AND is_active = TRUE ''', (datetime.now().isoformat(), name)) conn.commit() return cursor.rowcount > 0 except sqlite3.Error as e: conn.rollback() raise RuntimeError(f"Failed to set default profile: {e}") finally: conn.close() def get_default_profile(self) -> Optional[ProfileData]: """ Get the default profile. Returns: Default ProfileData or None if no default set """ conn = sqlite3.connect(self.db_path) cursor = conn.cursor() try: cursor.execute(''' SELECT name, data FROM user_profiles WHERE is_default = TRUE AND is_active = TRUE LIMIT 1 ''') row = cursor.fetchone() if not row: return None profile_data = json.loads(row[1]) return ProfileData.from_dict(profile_data) except sqlite3.Error as e: raise RuntimeError(f"Failed to get default profile: {e}") finally: conn.close() def export_profile(self, name: str, format: str = 'json') -> str: """ Export profile to string format. Args: name: Profile name format: Export format ('json' or 'yaml') Returns: Exported profile string Raises: ProfileNotFoundError: If profile doesn't exist ValueError: If format is unsupported """ profile_data = self.get_profile(name) profile_info = self.get_profile_info(name) export_data = { 'profile_info': profile_info, 'profile_data': profile_data.to_dict() } if format.lower() == 'json': return json.dumps(export_data, indent=2, ensure_ascii=False) elif format.lower() == 'yaml': try: import yaml return yaml.dump(export_data, default_flow_style=False, allow_unicode=True) except ImportError: raise ValueError("YAML format requires PyYAML package") else: raise ValueError(f"Unsupported export format: {format}") def import_profile(self, name: str, data: str, format: str = 'json', overwrite: bool = False) -> int: """ Import profile from string format. Args: name: New profile name (overrides name in data if different) data: Profile data string format: Import format ('json' or 'yaml') overwrite: Whether to overwrite existing profile Returns: ID of imported profile Raises: ValueError: If format is unsupported or profile exists without overwrite ProfileValidationError: If data is invalid """ if format.lower() == 'json': import_data = json.loads(data) elif format.lower() == 'yaml': try: import yaml import_data = yaml.safe_load(data) except ImportError: raise ValueError("YAML format requires PyYAML package") else: raise ValueError(f"Unsupported import format: {format}") # Extract profile data if 'profile_data' in import_data: profile_data = import_data['profile_data'] else: profile_data = import_data description = None if 'profile_info' in import_data and 'description' in import_data['profile_info']: description = import_data['profile_info']['description'] # Check if profile exists try: existing_profile = self.get_profile_info(name) if not overwrite: raise ValueError(f"Profile '{name}' already exists. Use overwrite=True to replace.") # Update existing profile self.update_profile(name, profile_data, description) return existing_profile['id'] except ProfileNotFoundError: # Create new profile return self.create_profile(name, profile_data, description) def merge_profiles(self, base_profile: str, override_profile: str) -> ProfileData: """ Merge two profiles with override taking precedence. Args: base_profile: Base profile name override_profile: Override profile name Returns: Merged ProfileData Raises: ProfileNotFoundError: If either profile doesn't exist """ base_data = self.get_profile(base_profile).to_dict() override_data = self.get_profile(override_profile).to_dict() def deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]: """Deep merge two dictionaries.""" result = base.copy() for key, value in override.items(): if key in result and isinstance(result[key], dict) and isinstance(value, dict): result[key] = deep_merge(result[key], value) elif value is not None: # Only override with non-None values result[key] = value return result merged_data = deep_merge(base_data, override_data) return ProfileData.from_dict(merged_data) def get_template_variables(self, profile_name: Optional[str] = None) -> Dict[str, Any]: """ Get flattened template variables from a profile. Args: profile_name: Profile name (uses default if None) Returns: Flattened dictionary suitable for template filling Raises: ProfileNotFoundError: If profile doesn't exist """ if profile_name: profile_data = self.get_profile(profile_name) else: profile_data = self.get_default_profile() if not profile_data: return {} # Flatten nested structure for template variables variables = {} profile_dict = profile_data.to_dict() def flatten_dict(d: Dict[str, Any], prefix: str = '') -> None: """Recursively flatten dictionary.""" for key, value in d.items(): if value is None: continue new_key = f"{prefix}.{key}" if prefix else key if isinstance(value, dict): flatten_dict(value, new_key) elif isinstance(value, (str, int, float, bool)): variables[new_key] = value elif isinstance(value, list): # Convert lists to comma-separated strings variables[new_key] = ', '.join(str(item) for item in value) flatten_dict(profile_dict) # Add some computed convenience variables if 'first_name' in variables and 'last_name' in variables: variables['full_name'] = f"{variables['first_name']} {variables['last_name']}" return variables