diff --git a/cost_notes/issue_112_cost_2025-10-04.md b/cost_notes/issue_112_cost_2025-10-04.md new file mode 100644 index 00000000..f114af5c --- /dev/null +++ b/cost_notes/issue_112_cost_2025-10-04.md @@ -0,0 +1,73 @@ +--- +note_type: "issue_cost_tracking" +issue_id: 112 +issue_title: "Period Management Framework" +session_date: "2025-10-04" +claude_model: "claude-sonnet-4" +total_cost_eur: 0.1794 +total_cost_usd: 0.195 +total_tokens: 33000 +generated_at: "2025-10-04T01:44:22.504281" +--- + +# Issue #112 Implementation Cost +**Issue**: Period Management Framework +**Date**: 2025-10-04 +**Claude Model**: claude-sonnet-4 + +## Cost Summary +- **Total Cost**: €0.1794 ($0.1950 USD) +- **Token Usage**: 33,000 tokens +- **Input Tokens**: 25,000 tokens @ $3.00/M +- **Output Tokens**: 8,000 tokens @ $15.00/M + +## Cost Breakdown + +| Component | Tokens | Rate ($/M) | Cost (USD) | Cost (EUR) | +|-----------|--------|------------|------------|------------| +| Input | 25,000 | $3.00 | $0.0750 | €0.0690 | +| Output | 8,000 | $15.00 | $0.1200 | €0.1104 | +| **Total** | 33,000 | - | $0.1950 | €0.1794 | + +## Implementation Summary +Implemented comprehensive Period Management Framework with complete lifecycle operations, status management, overlap validation, cost calculations, CLI integration (7 commands), and comprehensive test coverage (49 tests). Delivered PeriodManager class, CLI commands, and full database integration. + +## Cost Allocation +This cost has been allocated to the 'AI & ML Services' category as a one-time expense for issue #112 implementation. + +## Notes +- Currency conversion rate: 1 USD = 0.920 EUR +- Pricing based on claude-sonnet-4 rates as of 2025-10-04 +- Token counts and costs are estimates based on session usage + + \ No newline at end of file diff --git a/markitect/cli.py b/markitect/cli.py index 63b04e41..62a95e79 100644 --- a/markitect/cli.py +++ b/markitect/cli.py @@ -38,6 +38,13 @@ try: except ImportError: COST_TRACKING_AVAILABLE = False +# Import profile management commands +try: + from .profile.commands import profile_commands + PROFILE_MANAGEMENT_AVAILABLE = True +except ImportError: + PROFILE_MANAGEMENT_AVAILABLE = False + def get_database_path(config): """Get database path from config.""" @@ -6556,6 +6563,10 @@ def categories(config): if COST_TRACKING_AVAILABLE: cli.add_command(cost_commands) +# Register profile management commands +if PROFILE_MANAGEMENT_AVAILABLE: + cli.add_command(profile_commands) + # Register paradigms commands cli.add_command(paradigms) diff --git a/markitect/profile/__init__.py b/markitect/profile/__init__.py new file mode 100644 index 00000000..2d4ef250 --- /dev/null +++ b/markitect/profile/__init__.py @@ -0,0 +1,24 @@ +""" +User Profile Management System for MarkiTect. + +This package provides comprehensive user profile management including: +- CRUD operations for user profiles +- Multiple profile support (personal, work, etc.) +- JSON schema validation +- Database integration with persistent storage +- Profile inheritance and template support +- Data export/import functionality +""" + +from .manager import ProfileManager, ProfileNotFoundError, ProfileValidationError +from .schema import ProfileSchema, ProfileData +from .commands import profile_commands + +__all__ = [ + 'ProfileManager', + 'ProfileSchema', + 'ProfileData', + 'ProfileNotFoundError', + 'ProfileValidationError', + 'profile_commands' +] \ No newline at end of file diff --git a/markitect/profile/commands.py b/markitect/profile/commands.py new file mode 100644 index 00000000..f3417d4a --- /dev/null +++ b/markitect/profile/commands.py @@ -0,0 +1,569 @@ +""" +CLI commands for user profile management. + +This module provides command-line interface for user profile operations +including creation, modification, listing, and template integration. +""" + +import click +import sys +import json +from typing import Optional, Dict, Any +from pathlib import Path + +from .manager import ProfileManager, ProfileNotFoundError, ProfileValidationError +from .schema import ProfileSchema, ProfileData + + +@click.group(name='profile') +def profile_commands(): + """User profile management commands.""" + pass + + +@profile_commands.command('create') +@click.argument('name') +@click.option('--description', help='Profile description') +@click.option('--first-name', help='First name') +@click.option('--last-name', help='Last name') +@click.option('--email', help='Email address') +@click.option('--phone', help='Phone number') +@click.option('--organization', help='Organization name') +@click.option('--position', help='Job position') +@click.option('--city', help='City') +@click.option('--country', help='Country') +@click.option('--interactive', '-i', is_flag=True, help='Interactive profile creation') +@click.option('--set-default', is_flag=True, help='Set as default profile') +@click.option('--database', 'db_path', help='Database path (defaults to config)') +def create_profile(name: str, description: Optional[str], first_name: Optional[str], + last_name: Optional[str], email: Optional[str], phone: Optional[str], + organization: Optional[str], position: Optional[str], city: Optional[str], + country: Optional[str], interactive: bool, set_default: bool, + db_path: Optional[str]): + """Create a new user profile.""" + try: + profile_manager = ProfileManager(db_path) + + if interactive: + # Interactive profile creation + profile_data = _interactive_profile_creation() + if description is None: + description = click.prompt('Profile description', default='', show_default=False) or None + else: + # Command-line profile creation + profile_data = ProfileData( + first_name=first_name, + last_name=last_name, + contact=ProfileSchema.create_empty_profile().contact, + address=ProfileSchema.create_empty_profile().address, + organization=ProfileSchema.create_empty_profile().organization + ) + + # Set contact info + if email: + profile_data.contact.email = email + if phone: + profile_data.contact.phone = phone + + # Set organization info + if organization: + profile_data.organization.name = organization + if position: + profile_data.organization.position = position + + # Set address info + if city: + profile_data.address.city = city + if country: + profile_data.address.country = country + + # Compute full name if first and last are provided + if first_name and last_name: + profile_data.full_name = f"{first_name} {last_name}" + + # Create profile + profile_id = profile_manager.create_profile( + name=name, + data=profile_data, + description=description, + set_as_default=set_default + ) + + click.echo(f"✅ Created profile '{name}' (ID: {profile_id})") + if set_default: + click.echo(f"🎯 Set '{name}' as default profile") + + except ValueError as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + except ProfileValidationError as e: + click.echo(f"Validation Error: {e}", err=True) + sys.exit(1) + except Exception as e: + click.echo(f"Error creating profile: {e}", err=True) + sys.exit(1) + + +@profile_commands.command('show') +@click.argument('name') +@click.option('--format', 'output_format', type=click.Choice(['json', 'yaml', 'table']), + default='table', help='Output format') +@click.option('--database', 'db_path', help='Database path (defaults to config)') +def show_profile(name: str, output_format: str, db_path: Optional[str]): + """Show profile details.""" + try: + profile_manager = ProfileManager(db_path) + profile_data = profile_manager.get_profile(name) + profile_info = profile_manager.get_profile_info(name) + + if output_format == 'json': + output = { + 'profile_info': profile_info, + 'profile_data': profile_data.to_dict() + } + click.echo(json.dumps(output, indent=2, ensure_ascii=False)) + elif output_format == 'yaml': + try: + import yaml + output = { + 'profile_info': profile_info, + 'profile_data': profile_data.to_dict() + } + click.echo(yaml.dump(output, default_flow_style=False, allow_unicode=True)) + except ImportError: + click.echo("Error: PyYAML package required for YAML output", err=True) + sys.exit(1) + else: + # Table format + _display_profile_table(profile_info, profile_data) + + except ProfileNotFoundError as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + except Exception as e: + click.echo(f"Error showing profile: {e}", err=True) + sys.exit(1) + + +@profile_commands.command('list') +@click.option('--include-inactive', is_flag=True, help='Include inactive profiles') +@click.option('--format', 'output_format', type=click.Choice(['table', 'json']), + default='table', help='Output format') +@click.option('--database', 'db_path', help='Database path (defaults to config)') +def list_profiles(include_inactive: bool, output_format: str, db_path: Optional[str]): + """List all profiles.""" + try: + profile_manager = ProfileManager(db_path) + profiles = profile_manager.list_profiles(include_inactive=include_inactive) + + if not profiles: + click.echo("No profiles found.") + return + + if output_format == 'json': + click.echo(json.dumps(profiles, indent=2, ensure_ascii=False)) + else: + # Table format + click.echo("👤 User Profiles") + click.echo("=" * 80) + click.echo(f"{'Name':<20} {'Description':<30} {'Default':<8} {'Status':<8} {'Updated':<12}") + click.echo("-" * 80) + + for profile in profiles: + status = "Active" if profile['is_active'] else "Inactive" + default = "Yes" if profile['is_default'] else "No" + description = profile['description'] or "(No description)" + updated = profile['updated_at'][:10] if profile['updated_at'] else "N/A" + + click.echo(f"{profile['name']:<20} {description[:29]:<30} {default:<8} {status:<8} {updated:<12}") + + click.echo(f"\nTotal: {len(profiles)} profiles") + if not include_inactive: + inactive_count = len([p for p in profiles if not p['is_active']]) + if inactive_count > 0: + click.echo(f"💡 Use --include-inactive to show {inactive_count} inactive profiles") + + except Exception as e: + click.echo(f"Error listing profiles: {e}", err=True) + sys.exit(1) + + +@profile_commands.command('update') +@click.argument('name') +@click.option('--description', help='Update profile description') +@click.option('--first-name', help='Update first name') +@click.option('--last-name', help='Update last name') +@click.option('--email', help='Update email address') +@click.option('--phone', help='Update phone number') +@click.option('--organization', help='Update organization name') +@click.option('--position', help='Update job position') +@click.option('--interactive', '-i', is_flag=True, help='Interactive profile update') +@click.option('--database', 'db_path', help='Database path (defaults to config)') +def update_profile(name: str, description: Optional[str], first_name: Optional[str], + last_name: Optional[str], email: Optional[str], phone: Optional[str], + organization: Optional[str], position: Optional[str], interactive: bool, + db_path: Optional[str]): + """Update an existing profile.""" + try: + profile_manager = ProfileManager(db_path) + + if interactive: + # Interactive update + current_profile = profile_manager.get_profile(name) + profile_data = _interactive_profile_update(current_profile) + if description is None: + current_info = profile_manager.get_profile_info(name) + description = click.prompt('Profile description', + default=current_info['description'] or '', + show_default=True) or None + else: + # Get current profile and update specific fields + current_profile = profile_manager.get_profile(name) + + # Update fields if provided + if first_name: + current_profile.first_name = first_name + if last_name: + current_profile.last_name = last_name + if email: + current_profile.contact.email = email + if phone: + current_profile.contact.phone = phone + if organization: + current_profile.organization.name = organization + if position: + current_profile.organization.position = position + + # Recompute full name if first or last name changed + if first_name or last_name: + if current_profile.first_name and current_profile.last_name: + current_profile.full_name = f"{current_profile.first_name} {current_profile.last_name}" + + profile_data = current_profile + + # Update profile + success = profile_manager.update_profile(name, profile_data, description) + + if success: + click.echo(f"✅ Updated profile '{name}'") + else: + click.echo(f"❌ Failed to update profile '{name}'") + sys.exit(1) + + except ProfileNotFoundError as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + except ProfileValidationError as e: + click.echo(f"Validation Error: {e}", err=True) + sys.exit(1) + except Exception as e: + click.echo(f"Error updating profile: {e}", err=True) + sys.exit(1) + + +@profile_commands.command('delete') +@click.argument('name') +@click.option('--hard', is_flag=True, help='Permanently delete (default is soft delete)') +@click.option('--yes', is_flag=True, help='Skip confirmation prompt') +@click.option('--database', 'db_path', help='Database path (defaults to config)') +def delete_profile(name: str, hard: bool, yes: bool, db_path: Optional[str]): + """Delete a profile.""" + try: + profile_manager = ProfileManager(db_path) + + # Confirm deletion unless --yes flag is used + if not yes: + action = "permanently delete" if hard else "deactivate" + if not click.confirm(f"Are you sure you want to {action} profile '{name}'?"): + click.echo("Deletion cancelled.") + return + + success = profile_manager.delete_profile(name, hard_delete=hard) + + if success: + action = "deleted permanently" if hard else "deactivated" + click.echo(f"✅ Profile '{name}' {action}") + else: + click.echo(f"❌ Failed to delete profile '{name}'") + sys.exit(1) + + except ProfileNotFoundError as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + except Exception as e: + click.echo(f"Error deleting profile: {e}", err=True) + sys.exit(1) + + +@profile_commands.command('set-default') +@click.argument('name') +@click.option('--database', 'db_path', help='Database path (defaults to config)') +def set_default_profile(name: str, db_path: Optional[str]): + """Set a profile as the default profile.""" + try: + profile_manager = ProfileManager(db_path) + success = profile_manager.set_default_profile(name) + + if success: + click.echo(f"✅ Set '{name}' as default profile") + else: + click.echo(f"❌ Failed to set '{name}' as default") + sys.exit(1) + + except ProfileNotFoundError as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + except Exception as e: + click.echo(f"Error setting default: {e}", err=True) + sys.exit(1) + + +@profile_commands.command('export') +@click.argument('name') +@click.option('--format', 'output_format', type=click.Choice(['json', 'yaml']), + default='json', help='Export format') +@click.option('--output', 'output_file', help='Output file path') +@click.option('--database', 'db_path', help='Database path (defaults to config)') +def export_profile(name: str, output_format: str, output_file: Optional[str], db_path: Optional[str]): + """Export profile to file or stdout.""" + try: + profile_manager = ProfileManager(db_path) + exported_data = profile_manager.export_profile(name, output_format) + + if output_file: + Path(output_file).write_text(exported_data, encoding='utf-8') + click.echo(f"✅ Exported profile '{name}' to {output_file}") + else: + click.echo(exported_data) + + except ProfileNotFoundError as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + except Exception as e: + click.echo(f"Error exporting profile: {e}", err=True) + sys.exit(1) + + +@profile_commands.command('import') +@click.argument('name') +@click.argument('input_file') +@click.option('--format', 'input_format', type=click.Choice(['json', 'yaml']), + default='json', help='Import format') +@click.option('--overwrite', is_flag=True, help='Overwrite existing profile') +@click.option('--database', 'db_path', help='Database path (defaults to config)') +def import_profile(name: str, input_file: str, input_format: str, overwrite: bool, db_path: Optional[str]): + """Import profile from file.""" + try: + profile_manager = ProfileManager(db_path) + + if not Path(input_file).exists(): + click.echo(f"Error: Input file '{input_file}' not found", err=True) + sys.exit(1) + + data = Path(input_file).read_text(encoding='utf-8') + profile_id = profile_manager.import_profile(name, data, input_format, overwrite) + + action = "Updated" if overwrite else "Created" + click.echo(f"✅ {action} profile '{name}' (ID: {profile_id})") + + except ValueError as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + except ProfileValidationError as e: + click.echo(f"Validation Error: {e}", err=True) + sys.exit(1) + except Exception as e: + click.echo(f"Error importing profile: {e}", err=True) + sys.exit(1) + + +@profile_commands.command('variables') +@click.option('--profile', help='Profile name (uses default if not specified)') +@click.option('--format', 'output_format', type=click.Choice(['table', 'json']), + default='table', help='Output format') +@click.option('--database', 'db_path', help='Database path (defaults to config)') +def show_template_variables(profile: Optional[str], output_format: str, db_path: Optional[str]): + """Show template variables from a profile.""" + try: + profile_manager = ProfileManager(db_path) + variables = profile_manager.get_template_variables(profile) + + if not variables: + if profile: + click.echo(f"No template variables found for profile '{profile}'") + else: + click.echo("No default profile set or profile is empty") + return + + profile_name = profile or "(default)" + + if output_format == 'json': + click.echo(json.dumps(variables, indent=2, ensure_ascii=False)) + else: + # Table format + click.echo(f"📋 Template Variables - {profile_name}") + click.echo("=" * 60) + click.echo(f"{'Variable':<25} {'Value':<35}") + click.echo("-" * 60) + + for key, value in sorted(variables.items()): + value_str = str(value)[:34] if value else "(empty)" + click.echo(f"{key:<25} {value_str:<35}") + + click.echo(f"\nTotal: {len(variables)} variables") + + except ProfileNotFoundError as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + except Exception as e: + click.echo(f"Error showing variables: {e}", err=True) + sys.exit(1) + + +def _interactive_profile_creation() -> ProfileData: + """Interactive profile creation helper.""" + click.echo("📝 Interactive Profile Creation") + click.echo("=" * 40) + + profile_data = ProfileSchema.create_empty_profile() + + # Basic information + profile_data.first_name = click.prompt("First name", default="", show_default=False) or None + profile_data.last_name = click.prompt("Last name", default="", show_default=False) or None + + if profile_data.first_name and profile_data.last_name: + default_full = f"{profile_data.first_name} {profile_data.last_name}" + profile_data.full_name = click.prompt("Full name", default=default_full) or None + + profile_data.preferred_name = click.prompt("Preferred name", default="", show_default=False) or None + profile_data.title = click.prompt("Title (Mr/Ms/Dr/etc)", default="", show_default=False) or None + + # Contact information + if click.confirm("Add contact information?", default=True): + profile_data.contact.email = click.prompt("Email", default="", show_default=False) or None + profile_data.contact.phone = click.prompt("Phone", default="", show_default=False) or None + profile_data.contact.website = click.prompt("Website", default="", show_default=False) or None + + # Organization information + if click.confirm("Add organization information?", default=True): + profile_data.organization.name = click.prompt("Organization", default="", show_default=False) or None + profile_data.organization.position = click.prompt("Position/Title", default="", show_default=False) or None + profile_data.profession = click.prompt("Profession", default="", show_default=False) or None + + # Address information + if click.confirm("Add address information?", default=False): + profile_data.address.city = click.prompt("City", default="", show_default=False) or None + profile_data.address.state = click.prompt("State/Province", default="", show_default=False) or None + profile_data.address.country = click.prompt("Country", default="", show_default=False) or None + + return profile_data + + +def _interactive_profile_update(current_profile: ProfileData) -> ProfileData: + """Interactive profile update helper.""" + click.echo("📝 Interactive Profile Update") + click.echo("=" * 40) + click.echo("Current values shown in brackets. Press Enter to keep unchanged.") + + # Basic information + current_profile.first_name = click.prompt( + "First name", default=current_profile.first_name or "", show_default=True + ) or None + + current_profile.last_name = click.prompt( + "Last name", default=current_profile.last_name or "", show_default=True + ) or None + + if current_profile.first_name and current_profile.last_name: + default_full = f"{current_profile.first_name} {current_profile.last_name}" + current_profile.full_name = click.prompt( + "Full name", default=current_profile.full_name or default_full, show_default=True + ) or None + + # Contact information + if click.confirm("Update contact information?", default=False): + current_profile.contact.email = click.prompt( + "Email", default=current_profile.contact.email or "", show_default=True + ) or None + + current_profile.contact.phone = click.prompt( + "Phone", default=current_profile.contact.phone or "", show_default=True + ) or None + + # Organization information + if click.confirm("Update organization information?", default=False): + current_profile.organization.name = click.prompt( + "Organization", default=current_profile.organization.name or "", show_default=True + ) or None + + current_profile.organization.position = click.prompt( + "Position/Title", default=current_profile.organization.position or "", show_default=True + ) or None + + return current_profile + + +def _display_profile_table(profile_info: Dict[str, Any], profile_data: ProfileData) -> None: + """Display profile in table format.""" + click.echo(f"👤 Profile: {profile_info['name']}") + click.echo("=" * 50) + + if profile_info['description']: + click.echo(f"Description: {profile_info['description']}") + click.echo() + + # Basic Information + click.echo("📋 Basic Information") + click.echo("-" * 20) + if profile_data.full_name: + click.echo(f"Full Name: {profile_data.full_name}") + if profile_data.first_name: + click.echo(f"First Name: {profile_data.first_name}") + if profile_data.last_name: + click.echo(f"Last Name: {profile_data.last_name}") + if profile_data.preferred_name: + click.echo(f"Preferred Name: {profile_data.preferred_name}") + if profile_data.title: + click.echo(f"Title: {profile_data.title}") + + # Contact Information + if any([profile_data.contact.email, profile_data.contact.phone, profile_data.contact.website]): + click.echo(f"\n📞 Contact Information") + click.echo("-" * 20) + if profile_data.contact.email: + click.echo(f"Email: {profile_data.contact.email}") + if profile_data.contact.phone: + click.echo(f"Phone: {profile_data.contact.phone}") + if profile_data.contact.website: + click.echo(f"Website: {profile_data.contact.website}") + + # Organization Information + if any([profile_data.organization.name, profile_data.organization.position, profile_data.profession]): + click.echo(f"\n🏢 Organization") + click.echo("-" * 15) + if profile_data.organization.name: + click.echo(f"Organization: {profile_data.organization.name}") + if profile_data.organization.position: + click.echo(f"Position: {profile_data.organization.position}") + if profile_data.profession: + click.echo(f"Profession: {profile_data.profession}") + + # Address Information + if any([profile_data.address.city, profile_data.address.state, profile_data.address.country]): + click.echo(f"\n🌍 Address") + click.echo("-" * 10) + address_parts = [] + if profile_data.address.city: + address_parts.append(profile_data.address.city) + if profile_data.address.state: + address_parts.append(profile_data.address.state) + if profile_data.address.country: + address_parts.append(profile_data.address.country) + click.echo(f"Location: {', '.join(address_parts)}") + + # Metadata + click.echo(f"\n⏰ Metadata") + click.echo("-" * 10) + click.echo(f"Default Profile: {'Yes' if profile_info['is_default'] else 'No'}") + click.echo(f"Created: {profile_info['created_at'][:19] if profile_info['created_at'] else 'N/A'}") + click.echo(f"Updated: {profile_info['updated_at'][:19] if profile_info['updated_at'] else 'N/A'}") \ No newline at end of file diff --git a/markitect/profile/manager.py b/markitect/profile/manager.py new file mode 100644 index 00000000..049be28c --- /dev/null +++ b/markitect/profile/manager.py @@ -0,0 +1,663 @@ +""" +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 \ No newline at end of file diff --git a/markitect/profile/schema.py b/markitect/profile/schema.py new file mode 100644 index 00000000..8689d430 --- /dev/null +++ b/markitect/profile/schema.py @@ -0,0 +1,355 @@ +""" +Profile schema definition and validation for MarkiTect user profiles. + +This module defines the JSON schema structure for user profiles and provides +validation functionality to ensure profile data integrity. +""" + +import json +from datetime import datetime +from typing import Dict, Any, Optional, List +from dataclasses import dataclass, asdict, field +from jsonschema import validate, ValidationError + + +@dataclass +class ContactInfo: + """Contact information structure.""" + email: Optional[str] = None + phone: Optional[str] = None + website: Optional[str] = None + linkedin: Optional[str] = None + github: Optional[str] = None + twitter: Optional[str] = None + + +@dataclass +class Address: + """Address structure.""" + street: Optional[str] = None + city: Optional[str] = None + state: Optional[str] = None + postal_code: Optional[str] = None + country: Optional[str] = None + + +@dataclass +class Organization: + """Organization/company information.""" + name: Optional[str] = None + position: Optional[str] = None + department: Optional[str] = None + website: Optional[str] = None + address: Optional[Address] = field(default_factory=Address) + + +@dataclass +class ProfileData: + """ + Complete user profile data structure. + + This dataclass defines all the fields that can be stored in a user profile + for template auto-filling purposes. + """ + # Basic personal information + first_name: Optional[str] = None + last_name: Optional[str] = None + full_name: Optional[str] = None + preferred_name: Optional[str] = None + title: Optional[str] = None # Mr., Ms., Dr., etc. + + # Contact information + contact: ContactInfo = field(default_factory=ContactInfo) + + # Address information + address: Address = field(default_factory=Address) + + # Professional information + organization: Organization = field(default_factory=Organization) + profession: Optional[str] = None + bio: Optional[str] = None + + # Additional fields for template filling + custom_fields: Dict[str, Any] = field(default_factory=dict) + + # Metadata + created_at: Optional[str] = None + updated_at: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert profile data to dictionary.""" + return asdict(self) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'ProfileData': + """Create ProfileData from dictionary.""" + # Handle nested structures + if 'contact' in data and isinstance(data['contact'], dict): + data['contact'] = ContactInfo(**data['contact']) + + if 'address' in data and isinstance(data['address'], dict): + data['address'] = Address(**data['address']) + + if 'organization' in data and isinstance(data['organization'], dict): + org_data = data['organization'].copy() + if 'address' in org_data and isinstance(org_data['address'], dict): + org_data['address'] = Address(**org_data['address']) + data['organization'] = Organization(**org_data) + + return cls(**data) + + +class ProfileSchema: + """ + JSON Schema validation for user profiles. + + Provides schema definition and validation methods to ensure + profile data integrity and consistency. + """ + + # JSON Schema definition for profile validation + SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MarkiTect User Profile", + "type": "object", + "properties": { + "first_name": { + "type": ["string", "null"], + "maxLength": 100, + "description": "User's first name" + }, + "last_name": { + "type": ["string", "null"], + "maxLength": 100, + "description": "User's last name" + }, + "full_name": { + "type": ["string", "null"], + "maxLength": 200, + "description": "User's full name" + }, + "preferred_name": { + "type": ["string", "null"], + "maxLength": 100, + "description": "User's preferred name" + }, + "title": { + "type": ["string", "null"], + "maxLength": 20, + "description": "Title (Mr., Ms., Dr., etc.)" + }, + "contact": { + "type": "object", + "properties": { + "email": { + "type": ["string", "null"], + "format": "email", + "description": "Email address" + }, + "phone": { + "type": ["string", "null"], + "maxLength": 50, + "description": "Phone number" + }, + "website": { + "type": ["string", "null"], + "format": "uri", + "description": "Personal website URL" + }, + "linkedin": { + "type": ["string", "null"], + "description": "LinkedIn profile URL" + }, + "github": { + "type": ["string", "null"], + "description": "GitHub profile URL" + }, + "twitter": { + "type": ["string", "null"], + "description": "Twitter handle or URL" + } + }, + "additionalProperties": False + }, + "address": { + "type": "object", + "properties": { + "street": { + "type": ["string", "null"], + "maxLength": 200, + "description": "Street address" + }, + "city": { + "type": ["string", "null"], + "maxLength": 100, + "description": "City" + }, + "state": { + "type": ["string", "null"], + "maxLength": 100, + "description": "State or province" + }, + "postal_code": { + "type": ["string", "null"], + "maxLength": 20, + "description": "Postal or ZIP code" + }, + "country": { + "type": ["string", "null"], + "maxLength": 100, + "description": "Country" + } + }, + "additionalProperties": False + }, + "organization": { + "type": "object", + "properties": { + "name": { + "type": ["string", "null"], + "maxLength": 200, + "description": "Organization name" + }, + "position": { + "type": ["string", "null"], + "maxLength": 100, + "description": "Job position/title" + }, + "department": { + "type": ["string", "null"], + "maxLength": 100, + "description": "Department" + }, + "website": { + "type": ["string", "null"], + "format": "uri", + "description": "Organization website" + }, + "address": { + "$ref": "#/properties/address" + } + }, + "additionalProperties": False + }, + "profession": { + "type": ["string", "null"], + "maxLength": 100, + "description": "Professional role or occupation" + }, + "bio": { + "type": ["string", "null"], + "maxLength": 2000, + "description": "Professional biography" + }, + "custom_fields": { + "type": "object", + "description": "Additional custom fields for template filling", + "additionalProperties": True + }, + "created_at": { + "type": ["string", "null"], + "format": "date-time", + "description": "Profile creation timestamp" + }, + "updated_at": { + "type": ["string", "null"], + "format": "date-time", + "description": "Profile last update timestamp" + } + }, + "additionalProperties": False + } + + @classmethod + def validate(cls, data: Dict[str, Any]) -> None: + """ + Validate profile data against the schema. + + Args: + data: Profile data dictionary to validate + + Raises: + ValidationError: If data doesn't match schema + """ + validate(instance=data, schema=cls.SCHEMA) + + @classmethod + def is_valid(cls, data: Dict[str, Any]) -> bool: + """ + Check if profile data is valid. + + Args: + data: Profile data dictionary to check + + Returns: + True if valid, False otherwise + """ + try: + cls.validate(data) + return True + except ValidationError: + return False + + @classmethod + def get_field_description(cls, field_path: str) -> Optional[str]: + """ + Get description for a specific field. + + Args: + field_path: Dot-separated field path (e.g., 'contact.email') + + Returns: + Field description or None if not found + """ + parts = field_path.split('.') + current = cls.SCHEMA['properties'] + + try: + for part in parts: + if part in current: + current = current[part] + if 'properties' in current: + current = current['properties'] + else: + return current.get('description') + return current.get('description') + except (KeyError, TypeError): + return None + + @classmethod + def get_all_fields(cls) -> List[str]: + """ + Get list of all available field paths. + + Returns: + List of dot-separated field paths + """ + fields = [] + + def extract_fields(schema_dict: Dict[str, Any], prefix: str = '') -> None: + if 'properties' not in schema_dict: + return + + for field_name, field_def in schema_dict['properties'].items(): + field_path = f"{prefix}.{field_name}" if prefix else field_name + fields.append(field_path) + + if field_def.get('type') == 'object' and 'properties' in field_def: + extract_fields(field_def, field_path) + + extract_fields(cls.SCHEMA) + return sorted(fields) + + @classmethod + def create_empty_profile(self) -> ProfileData: + """ + Create an empty profile with default structure. + + Returns: + Empty ProfileData instance + """ + now = datetime.now().isoformat() + return ProfileData( + created_at=now, + updated_at=now + ) \ No newline at end of file diff --git a/tests/test_profile_cli_commands.py b/tests/test_profile_cli_commands.py new file mode 100644 index 00000000..98d5ca41 --- /dev/null +++ b/tests/test_profile_cli_commands.py @@ -0,0 +1,621 @@ +""" +Tests for MarkiTect user profile CLI commands. + +This module tests the command-line interface for user profile management +including creation, listing, updating, and template variable extraction. +""" + +import pytest +import tempfile +import os +import json +from click.testing import CliRunner +from pathlib import Path + +from markitect.profile.commands import profile_commands +from markitect.profile.manager import ProfileManager +from markitect.profile.schema import ProfileData, ContactInfo, Organization + + +class TestProfileCLICommands: + """Test suite for profile management CLI commands.""" + + @pytest.fixture + def temp_db(self): + """Create temporary database for testing.""" + fd, path = tempfile.mkstemp(suffix='.db') + os.close(fd) + yield path + os.unlink(path) + + @pytest.fixture + def setup_test_profile(self, temp_db): + """Setup test database with a sample profile.""" + profile_manager = ProfileManager(temp_db) + profile_data = ProfileData( + first_name="John", + last_name="Doe", + contact=ContactInfo(email="john@example.com", phone="555-0123"), + organization=Organization(name="Tech Corp", position="Developer") + ) + profile_id = profile_manager.create_profile("test_profile", profile_data, "Test profile") + return temp_db, profile_id + + @pytest.fixture + def runner(self): + """Create Click test runner.""" + return CliRunner() + + def test_profile_create_basic(self, runner, temp_db): + """Test basic profile creation.""" + result = runner.invoke(profile_commands, [ + 'create', 'basic_profile', + '--first-name', 'Alice', + '--last-name', 'Smith', + '--email', 'alice@example.com', + '--database', temp_db + ]) + + assert result.exit_code == 0 + assert "✅ Created profile 'basic_profile'" in result.output + + # Verify profile was created + profile_manager = ProfileManager(temp_db) + profile = profile_manager.get_profile('basic_profile') + assert profile.first_name == "Alice" + assert profile.contact.email == "alice@example.com" + + def test_profile_create_with_organization(self, runner, temp_db): + """Test profile creation with organization info.""" + result = runner.invoke(profile_commands, [ + 'create', 'work_profile', + '--first-name', 'Bob', + '--organization', 'ACME Corp', + '--position', 'Manager', + '--city', 'New York', + '--description', 'My work profile', + '--database', temp_db + ]) + + assert result.exit_code == 0 + assert "✅ Created profile 'work_profile'" in result.output + + # Verify organization details + profile_manager = ProfileManager(temp_db) + profile = profile_manager.get_profile('work_profile') + assert profile.organization.name == "ACME Corp" + assert profile.organization.position == "Manager" + assert profile.address.city == "New York" + + def test_profile_create_set_default(self, runner, temp_db): + """Test creating profile and setting as default.""" + result = runner.invoke(profile_commands, [ + 'create', 'default_profile', + '--first-name', 'Default', + '--set-default', + '--database', temp_db + ]) + + assert result.exit_code == 0 + assert "✅ Created profile 'default_profile'" in result.output + assert "🎯 Set 'default_profile' as default profile" in result.output + + # Verify it's set as default + profile_manager = ProfileManager(temp_db) + default_profile = profile_manager.get_default_profile() + assert default_profile.first_name == "Default" + + def test_profile_create_duplicate_name(self, runner, setup_test_profile): + """Test creating profile with duplicate name fails.""" + temp_db, _ = setup_test_profile + + result = runner.invoke(profile_commands, [ + 'create', 'test_profile', # Same name as existing + '--first-name', 'Duplicate', + '--database', temp_db + ]) + + assert result.exit_code == 1 + assert "already exists" in result.output + + def test_profile_show_table_format(self, runner, setup_test_profile): + """Test showing profile in table format.""" + temp_db, _ = setup_test_profile + + result = runner.invoke(profile_commands, [ + 'show', 'test_profile', + '--database', temp_db + ]) + + assert result.exit_code == 0 + assert "👤 Profile: test_profile" in result.output + assert "First Name: John" in result.output + assert "📞 Contact Information" in result.output + assert "Email: john@example.com" in result.output + assert "🏢 Organization" in result.output + assert "Organization: Tech Corp" in result.output + + def test_profile_show_json_format(self, runner, setup_test_profile): + """Test showing profile in JSON format.""" + temp_db, _ = setup_test_profile + + result = runner.invoke(profile_commands, [ + 'show', 'test_profile', + '--format', 'json', + '--database', temp_db + ]) + + assert result.exit_code == 0 + + # Parse JSON output + data = json.loads(result.output) + assert "profile_info" in data + assert "profile_data" in data + assert data["profile_info"]["name"] == "test_profile" + assert data["profile_data"]["first_name"] == "John" + + def test_profile_show_nonexistent(self, runner, temp_db): + """Test showing non-existent profile.""" + result = runner.invoke(profile_commands, [ + 'show', 'nonexistent', + '--database', temp_db + ]) + + assert result.exit_code == 1 + assert "not found" in result.output + + def test_profile_list_empty(self, runner, temp_db): + """Test listing profiles when none exist.""" + result = runner.invoke(profile_commands, [ + 'list', + '--database', temp_db + ]) + + assert result.exit_code == 0 + assert "No profiles found" in result.output + + def test_profile_list_with_profiles(self, runner, temp_db): + """Test listing profiles.""" + # Create multiple profiles + profile_manager = ProfileManager(temp_db) + profile_manager.create_profile("profile1", ProfileData(first_name="User1"), "First profile") + profile_manager.create_profile("profile2", ProfileData(first_name="User2"), set_as_default=True) + + result = runner.invoke(profile_commands, [ + 'list', + '--database', temp_db + ]) + + assert result.exit_code == 0 + assert "👤 User Profiles" in result.output + assert "profile1" in result.output + assert "profile2" in result.output + assert "Total: 2 profiles" in result.output + + # Check default indicator + lines = result.output.split('\n') + profile2_line = [line for line in lines if 'profile2' in line][0] + assert "Yes" in profile2_line # Default column should show "Yes" + + def test_profile_list_include_inactive(self, runner, temp_db): + """Test listing profiles including inactive ones.""" + profile_manager = ProfileManager(temp_db) + profile_manager.create_profile("active", ProfileData(first_name="Active")) + profile_manager.create_profile("inactive", ProfileData(first_name="Inactive")) + profile_manager.delete_profile("inactive", hard_delete=False) # Soft delete + + # List without inactive + result = runner.invoke(profile_commands, [ + 'list', + '--database', temp_db + ]) + + assert result.exit_code == 0 + assert "active" in result.output + assert "inactive" not in result.output + + # List with inactive + result = runner.invoke(profile_commands, [ + 'list', + '--include-inactive', + '--database', temp_db + ]) + + assert result.exit_code == 0 + assert "active" in result.output + assert "inactive" in result.output + + def test_profile_list_json_format(self, runner, setup_test_profile): + """Test listing profiles in JSON format.""" + temp_db, _ = setup_test_profile + + result = runner.invoke(profile_commands, [ + 'list', + '--format', 'json', + '--database', temp_db + ]) + + assert result.exit_code == 0 + + # Parse JSON output + data = json.loads(result.output) + assert isinstance(data, list) + assert len(data) == 1 + assert data[0]["name"] == "test_profile" + + def test_profile_update_basic_fields(self, runner, setup_test_profile): + """Test updating basic profile fields.""" + temp_db, _ = setup_test_profile + + result = runner.invoke(profile_commands, [ + 'update', 'test_profile', + '--first-name', 'Johnny', + '--email', 'johnny@example.com', + '--organization', 'New Corp', + '--database', temp_db + ]) + + assert result.exit_code == 0 + assert "✅ Updated profile 'test_profile'" in result.output + + # Verify updates + profile_manager = ProfileManager(temp_db) + profile = profile_manager.get_profile('test_profile') + assert profile.first_name == "Johnny" + assert profile.contact.email == "johnny@example.com" + assert profile.organization.name == "New Corp" + + def test_profile_update_nonexistent(self, runner, temp_db): + """Test updating non-existent profile.""" + result = runner.invoke(profile_commands, [ + 'update', 'nonexistent', + '--first-name', 'New', + '--database', temp_db + ]) + + assert result.exit_code == 1 + assert "not found" in result.output + + def test_profile_delete_soft(self, runner, setup_test_profile): + """Test soft delete (deactivate) profile.""" + temp_db, _ = setup_test_profile + + result = runner.invoke(profile_commands, [ + 'delete', 'test_profile', + '--yes', # Skip confirmation + '--database', temp_db + ]) + + assert result.exit_code == 0 + assert "✅ Profile 'test_profile' deactivated" in result.output + + # Verify profile is deactivated + profile_manager = ProfileManager(temp_db) + with pytest.raises(Exception): # ProfileNotFoundError + profile_manager.get_profile('test_profile') + + def test_profile_delete_hard(self, runner, setup_test_profile): + """Test hard delete (permanent) profile.""" + temp_db, _ = setup_test_profile + + result = runner.invoke(profile_commands, [ + 'delete', 'test_profile', + '--hard', + '--yes', + '--database', temp_db + ]) + + assert result.exit_code == 0 + assert "✅ Profile 'test_profile' deleted permanently" in result.output + + # Verify profile is completely gone + profile_manager = ProfileManager(temp_db) + all_profiles = profile_manager.list_profiles(include_inactive=True) + profile_names = [p["name"] for p in all_profiles] + assert "test_profile" not in profile_names + + def test_profile_delete_with_confirmation(self, runner, setup_test_profile): + """Test profile deletion with confirmation prompt.""" + temp_db, _ = setup_test_profile + + # Test declining confirmation + result = runner.invoke(profile_commands, [ + 'delete', 'test_profile', + '--database', temp_db + ], input='n\n') + + assert result.exit_code == 0 + assert "Deletion cancelled" in result.output + + # Profile should still exist + profile_manager = ProfileManager(temp_db) + profile = profile_manager.get_profile('test_profile') + assert profile.first_name == "John" + + def test_profile_set_default(self, runner, temp_db): + """Test setting profile as default.""" + # Create multiple profiles + profile_manager = ProfileManager(temp_db) + profile_manager.create_profile("profile1", ProfileData(first_name="User1")) + profile_manager.create_profile("profile2", ProfileData(first_name="User2")) + + result = runner.invoke(profile_commands, [ + 'set-default', 'profile2', + '--database', temp_db + ]) + + assert result.exit_code == 0 + assert "✅ Set 'profile2' as default profile" in result.output + + # Verify default was set + default_profile = profile_manager.get_default_profile() + assert default_profile.first_name == "User2" + + def test_profile_set_default_nonexistent(self, runner, temp_db): + """Test setting non-existent profile as default.""" + result = runner.invoke(profile_commands, [ + 'set-default', 'nonexistent', + '--database', temp_db + ]) + + assert result.exit_code == 1 + assert "not found" in result.output + + def test_profile_export_json(self, runner, setup_test_profile): + """Test exporting profile to JSON.""" + temp_db, _ = setup_test_profile + + result = runner.invoke(profile_commands, [ + 'export', 'test_profile', + '--format', 'json', + '--database', temp_db + ]) + + assert result.exit_code == 0 + + # Parse output as JSON + data = json.loads(result.output) + assert "profile_info" in data + assert "profile_data" in data + assert data["profile_data"]["first_name"] == "John" + + def test_profile_export_to_file(self, runner, setup_test_profile): + """Test exporting profile to file.""" + temp_db, _ = setup_test_profile + + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.json') as f: + output_file = f.name + + try: + result = runner.invoke(profile_commands, [ + 'export', 'test_profile', + '--output', output_file, + '--database', temp_db + ]) + + assert result.exit_code == 0 + assert f"✅ Exported profile 'test_profile' to {output_file}" in result.output + + # Verify file contents + data = json.loads(Path(output_file).read_text()) + assert data["profile_data"]["first_name"] == "John" + + finally: + os.unlink(output_file) + + def test_profile_import_json(self, runner, temp_db): + """Test importing profile from JSON file.""" + import_data = { + "profile_info": {"description": "Imported profile"}, + "profile_data": { + "first_name": "Imported", + "last_name": "User", + "contact": {"email": "imported@example.com"} + } + } + + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.json') as f: + json.dump(import_data, f) + import_file = f.name + + try: + result = runner.invoke(profile_commands, [ + 'import', 'imported_profile', import_file, + '--database', temp_db + ]) + + assert result.exit_code == 0 + assert "✅ Created profile 'imported_profile'" in result.output + + # Verify imported data + profile_manager = ProfileManager(temp_db) + profile = profile_manager.get_profile('imported_profile') + assert profile.first_name == "Imported" + assert profile.contact.email == "imported@example.com" + + finally: + os.unlink(import_file) + + def test_profile_import_nonexistent_file(self, runner, temp_db): + """Test importing from non-existent file.""" + result = runner.invoke(profile_commands, [ + 'import', 'test', '/nonexistent/file.json', + '--database', temp_db + ]) + + assert result.exit_code == 1 + assert "not found" in result.output + + def test_profile_import_overwrite(self, runner, setup_test_profile): + """Test importing with overwrite flag.""" + temp_db, _ = setup_test_profile + + import_data = { + "profile_data": { + "first_name": "Overwritten", + "contact": {"email": "overwritten@example.com"} + } + } + + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.json') as f: + json.dump(import_data, f) + import_file = f.name + + try: + result = runner.invoke(profile_commands, [ + 'import', 'test_profile', import_file, + '--overwrite', + '--database', temp_db + ]) + + assert result.exit_code == 0 + assert "✅ Updated profile 'test_profile'" in result.output + + # Verify overwrite + profile_manager = ProfileManager(temp_db) + profile = profile_manager.get_profile('test_profile') + assert profile.first_name == "Overwritten" + + finally: + os.unlink(import_file) + + def test_profile_variables_table_format(self, runner, setup_test_profile): + """Test showing template variables in table format.""" + temp_db, _ = setup_test_profile + + result = runner.invoke(profile_commands, [ + 'variables', + '--profile', 'test_profile', + '--database', temp_db + ]) + + assert result.exit_code == 0 + assert "📋 Template Variables - test_profile" in result.output + assert "first_name" in result.output + assert "John" in result.output + assert "contact.email" in result.output + assert "organization.name" in result.output + + def test_profile_variables_json_format(self, runner, setup_test_profile): + """Test showing template variables in JSON format.""" + temp_db, _ = setup_test_profile + + result = runner.invoke(profile_commands, [ + 'variables', + '--profile', 'test_profile', + '--format', 'json', + '--database', temp_db + ]) + + assert result.exit_code == 0 + + # Parse JSON output + data = json.loads(result.output) + assert data["first_name"] == "John" + assert data["contact.email"] == "john@example.com" + assert data["organization.name"] == "Tech Corp" + + def test_profile_variables_default_profile(self, runner, temp_db): + """Test showing variables from default profile.""" + # Create and set default profile + profile_manager = ProfileManager(temp_db) + profile_data = ProfileData(first_name="Default", last_name="User") + profile_manager.create_profile("default", profile_data, set_as_default=True) + + result = runner.invoke(profile_commands, [ + 'variables', # No --profile specified, should use default + '--database', temp_db + ]) + + assert result.exit_code == 0 + assert "📋 Template Variables - (default)" in result.output + assert "first_name" in result.output + assert "Default" in result.output + + def test_profile_variables_no_default(self, runner, temp_db): + """Test showing variables when no default profile set.""" + result = runner.invoke(profile_commands, [ + 'variables', + '--database', temp_db + ]) + + assert result.exit_code == 0 + assert "No default profile set" in result.output + + def test_profile_help_commands(self, runner): + """Test help output for profile commands.""" + # Test main profile help + result = runner.invoke(profile_commands, ['--help']) + assert result.exit_code == 0 + assert "User profile management commands" in result.output + + # Test create help + result = runner.invoke(profile_commands, ['create', '--help']) + assert result.exit_code == 0 + assert "Create a new user profile" in result.output + + # Test show help + result = runner.invoke(profile_commands, ['show', '--help']) + assert result.exit_code == 0 + assert "Show profile details" in result.output + + def test_profile_commands_missing_database(self, runner): + """Test profile commands without database specification.""" + # These should use default config path + result = runner.invoke(profile_commands, [ + 'list' + ]) + + # Should succeed with default database configuration + assert result.exit_code == 0 + + def test_complex_profile_workflow(self, runner, temp_db): + """Test complex workflow with multiple operations.""" + # Create profile + result = runner.invoke(profile_commands, [ + 'create', 'workflow_test', + '--first-name', 'Workflow', + '--last-name', 'Test', + '--email', 'workflow@example.com', + '--organization', 'Test Corp', + '--description', 'Workflow test profile', + '--database', temp_db + ]) + assert result.exit_code == 0 + + # Update profile + result = runner.invoke(profile_commands, [ + 'update', 'workflow_test', + '--first-name', 'Updated', + '--position', 'Manager', + '--database', temp_db + ]) + assert result.exit_code == 0 + + # Set as default + result = runner.invoke(profile_commands, [ + 'set-default', 'workflow_test', + '--database', temp_db + ]) + assert result.exit_code == 0 + + # Show variables + result = runner.invoke(profile_commands, [ + 'variables', + '--database', temp_db + ]) + assert result.exit_code == 0 + assert "Updated" in result.output # Updated name + assert "Manager" in result.output # New position + + # Export profile + result = runner.invoke(profile_commands, [ + 'export', 'workflow_test', + '--database', temp_db + ]) + assert result.exit_code == 0 + + # Verify export contains updates + data = json.loads(result.output) + assert data["profile_data"]["first_name"] == "Updated" + assert data["profile_data"]["organization"]["position"] == "Manager" \ No newline at end of file diff --git a/tests/test_profile_manager.py b/tests/test_profile_manager.py new file mode 100644 index 00000000..69330b86 --- /dev/null +++ b/tests/test_profile_manager.py @@ -0,0 +1,568 @@ +""" +Tests for MarkiTect User Profile Management System. + +This module tests the complete user profile management functionality including: +- CRUD operations for user profiles +- Profile validation and schema compliance +- Database integration and data persistence +- Profile inheritance and merging +- Template variable extraction +- Export/import functionality +""" + +import pytest +import tempfile +import os +import json +from datetime import datetime +from pathlib import Path + +from markitect.profile.manager import ProfileManager, ProfileNotFoundError, ProfileValidationError +from markitect.profile.schema import ProfileSchema, ProfileData, ContactInfo, Address, Organization + + +class TestProfileSchema: + """Test suite for profile schema and validation.""" + + def test_profile_data_creation(self): + """Test ProfileData dataclass creation.""" + profile = ProfileData( + first_name="John", + last_name="Doe", + contact=ContactInfo(email="john@example.com"), + address=Address(city="Boston", country="USA") + ) + + assert profile.first_name == "John" + assert profile.last_name == "Doe" + assert profile.contact.email == "john@example.com" + assert profile.address.city == "Boston" + + def test_profile_data_to_dict(self): + """Test converting ProfileData to dictionary.""" + profile = ProfileData( + first_name="Jane", + last_name="Smith", + contact=ContactInfo(email="jane@example.com", phone="123-456-7890") + ) + + profile_dict = profile.to_dict() + + assert profile_dict["first_name"] == "Jane" + assert profile_dict["last_name"] == "Smith" + assert profile_dict["contact"]["email"] == "jane@example.com" + assert profile_dict["contact"]["phone"] == "123-456-7890" + + def test_profile_data_from_dict(self): + """Test creating ProfileData from dictionary.""" + data = { + "first_name": "Bob", + "last_name": "Johnson", + "contact": { + "email": "bob@example.com", + "phone": "098-765-4321" + }, + "organization": { + "name": "ACME Corp", + "position": "Developer" + } + } + + profile = ProfileData.from_dict(data) + + assert profile.first_name == "Bob" + assert profile.contact.email == "bob@example.com" + assert profile.organization.name == "ACME Corp" + + def test_profile_schema_validation_success(self): + """Test successful profile schema validation.""" + valid_data = { + "first_name": "Alice", + "last_name": "Wilson", + "contact": { + "email": "alice@example.com" + }, + "address": { + "city": "New York", + "country": "USA" + } + } + + # Should not raise exception + ProfileSchema.validate(valid_data) + assert ProfileSchema.is_valid(valid_data) is True + + def test_profile_schema_validation_failure(self): + """Test profile schema validation with invalid data.""" + invalid_data = { + "first_name": "A" * 150, # Too long + "contact": { + "email": "invalid-email" # Invalid email format + } + } + + with pytest.raises(Exception): # ValidationError + ProfileSchema.validate(invalid_data) + + assert ProfileSchema.is_valid(invalid_data) is False + + def test_profile_schema_get_field_description(self): + """Test getting field descriptions from schema.""" + email_desc = ProfileSchema.get_field_description("contact.email") + assert "Email address" in email_desc + + name_desc = ProfileSchema.get_field_description("first_name") + assert "first name" in name_desc.lower() + + invalid_desc = ProfileSchema.get_field_description("nonexistent.field") + assert invalid_desc is None + + def test_profile_schema_get_all_fields(self): + """Test getting all available field paths.""" + fields = ProfileSchema.get_all_fields() + + assert "first_name" in fields + assert "contact.email" in fields + assert "organization.name" in fields + assert "address.city" in fields + assert len(fields) > 10 # Should have many fields + + def test_create_empty_profile(self): + """Test creating empty profile with timestamps.""" + profile = ProfileSchema.create_empty_profile() + + assert profile.first_name is None + assert profile.contact is not None + assert profile.created_at is not None + assert profile.updated_at is not None + + +class TestProfileManager: + """Test suite for profile manager functionality.""" + + @pytest.fixture + def temp_db(self): + """Create temporary database for testing.""" + fd, path = tempfile.mkstemp(suffix='.db') + os.close(fd) + yield path + os.unlink(path) + + @pytest.fixture + def profile_manager(self, temp_db): + """Create profile manager with temporary database.""" + return ProfileManager(temp_db) + + @pytest.fixture + def sample_profile_data(self): + """Sample profile data for testing.""" + return ProfileData( + first_name="John", + last_name="Doe", + full_name="John Doe", + contact=ContactInfo( + email="john.doe@example.com", + phone="555-0123" + ), + organization=Organization( + name="Tech Corp", + position="Senior Developer" + ), + address=Address( + city="San Francisco", + state="CA", + country="USA" + ) + ) + + def test_create_profile_success(self, profile_manager, sample_profile_data): + """Test successful profile creation.""" + profile_id = profile_manager.create_profile( + name="personal", + data=sample_profile_data, + description="My personal profile" + ) + + assert profile_id is not None + assert isinstance(profile_id, int) + + # Verify profile was created + retrieved_profile = profile_manager.get_profile("personal") + assert retrieved_profile.first_name == "John" + assert retrieved_profile.last_name == "Doe" + assert retrieved_profile.contact.email == "john.doe@example.com" + + def test_create_profile_with_dict(self, profile_manager): + """Test profile creation with dictionary data.""" + profile_data = { + "first_name": "Jane", + "last_name": "Smith", + "contact": { + "email": "jane@example.com" + } + } + + profile_id = profile_manager.create_profile("work", profile_data) + assert profile_id is not None + + retrieved_profile = profile_manager.get_profile("work") + assert retrieved_profile.first_name == "Jane" + + def test_create_profile_duplicate_name(self, profile_manager, sample_profile_data): + """Test creating profile with duplicate name fails.""" + profile_manager.create_profile("test", sample_profile_data) + + with pytest.raises(ValueError, match="already exists"): + profile_manager.create_profile("test", sample_profile_data) + + def test_create_profile_invalid_data(self, profile_manager): + """Test creating profile with invalid data fails.""" + invalid_data = { + "first_name": "A" * 150, # Too long + "contact": { + "email": "invalid-email" + } + } + + with pytest.raises(ProfileValidationError): + profile_manager.create_profile("invalid", invalid_data) + + def test_create_profile_set_default(self, profile_manager, sample_profile_data): + """Test creating profile and setting as default.""" + profile_id = profile_manager.create_profile( + "default_test", + sample_profile_data, + set_as_default=True + ) + + # Verify it's set as default + default_profile = profile_manager.get_default_profile() + assert default_profile is not None + assert default_profile.first_name == "John" + + def test_get_profile_not_found(self, profile_manager): + """Test getting non-existent profile raises error.""" + with pytest.raises(ProfileNotFoundError): + profile_manager.get_profile("nonexistent") + + def test_get_profile_info(self, profile_manager, sample_profile_data): + """Test getting profile metadata.""" + profile_manager.create_profile("info_test", sample_profile_data, description="Test profile") + + profile_info = profile_manager.get_profile_info("info_test") + + assert profile_info["name"] == "info_test" + assert profile_info["description"] == "Test profile" + assert profile_info["is_active"] is True + assert profile_info["is_default"] is False + assert "created_at" in profile_info + assert "updated_at" in profile_info + + def test_update_profile_success(self, profile_manager, sample_profile_data): + """Test successful profile update.""" + profile_manager.create_profile("update_test", sample_profile_data) + + # Update some fields + updated_data = ProfileData( + first_name="Johnny", + last_name="Doe", + contact=ContactInfo(email="johnny@example.com") + ) + + success = profile_manager.update_profile("update_test", updated_data, "Updated description") + assert success is True + + # Verify updates + updated_profile = profile_manager.get_profile("update_test") + assert updated_profile.first_name == "Johnny" + assert updated_profile.contact.email == "johnny@example.com" + + profile_info = profile_manager.get_profile_info("update_test") + assert profile_info["description"] == "Updated description" + + def test_update_profile_not_found(self, profile_manager): + """Test updating non-existent profile fails.""" + with pytest.raises(ProfileNotFoundError): + profile_manager.update_profile("nonexistent", ProfileData()) + + def test_delete_profile_soft_delete(self, profile_manager, sample_profile_data): + """Test soft delete (deactivate) profile.""" + profile_manager.create_profile("delete_test", sample_profile_data) + + success = profile_manager.delete_profile("delete_test", hard_delete=False) + assert success is True + + # Profile should not be found in active profiles + with pytest.raises(ProfileNotFoundError): + profile_manager.get_profile("delete_test") + + # But should appear in list with inactive profiles + all_profiles = profile_manager.list_profiles(include_inactive=True) + inactive_names = [p["name"] for p in all_profiles if not p["is_active"]] + assert "delete_test" in inactive_names + + def test_delete_profile_hard_delete(self, profile_manager, sample_profile_data): + """Test hard delete (permanent) profile.""" + profile_manager.create_profile("hard_delete_test", sample_profile_data) + + success = profile_manager.delete_profile("hard_delete_test", hard_delete=True) + assert success is True + + # Profile should not appear anywhere + all_profiles = profile_manager.list_profiles(include_inactive=True) + all_names = [p["name"] for p in all_profiles] + assert "hard_delete_test" not in all_names + + def test_list_profiles_active_only(self, profile_manager, sample_profile_data): + """Test listing active profiles only.""" + # Create multiple profiles + profile_manager.create_profile("active1", sample_profile_data) + profile_manager.create_profile("active2", sample_profile_data) + profile_manager.create_profile("to_deactivate", sample_profile_data) + + # Deactivate one + profile_manager.delete_profile("to_deactivate", hard_delete=False) + + profiles = profile_manager.list_profiles(include_inactive=False) + active_names = [p["name"] for p in profiles] + + assert "active1" in active_names + assert "active2" in active_names + assert "to_deactivate" not in active_names + + def test_list_profiles_include_inactive(self, profile_manager, sample_profile_data): + """Test listing all profiles including inactive.""" + profile_manager.create_profile("active", sample_profile_data) + profile_manager.create_profile("inactive", sample_profile_data) + profile_manager.delete_profile("inactive", hard_delete=False) + + profiles = profile_manager.list_profiles(include_inactive=True) + all_names = [p["name"] for p in profiles] + + assert "active" in all_names + assert "inactive" in all_names + assert len(profiles) == 2 + + def test_set_default_profile(self, profile_manager, sample_profile_data): + """Test setting default profile.""" + # Create multiple profiles + profile_manager.create_profile("profile1", sample_profile_data) + profile_manager.create_profile("profile2", sample_profile_data) + + # Set profile2 as default + success = profile_manager.set_default_profile("profile2") + assert success is True + + # Verify default + default_profile = profile_manager.get_default_profile() + assert default_profile is not None + assert default_profile.first_name == "John" # From sample data + + # Check that only profile2 is marked as default + profiles = profile_manager.list_profiles() + default_profiles = [p for p in profiles if p["is_default"]] + assert len(default_profiles) == 1 + assert default_profiles[0]["name"] == "profile2" + + def test_get_default_profile_none_set(self, profile_manager): + """Test getting default profile when none is set.""" + default_profile = profile_manager.get_default_profile() + assert default_profile is None + + def test_export_profile_json(self, profile_manager, sample_profile_data): + """Test exporting profile to JSON format.""" + profile_manager.create_profile("export_test", sample_profile_data, "Test for export") + + exported = profile_manager.export_profile("export_test", format="json") + + # Parse and verify + data = json.loads(exported) + assert "profile_info" in data + assert "profile_data" in data + assert data["profile_info"]["name"] == "export_test" + assert data["profile_data"]["first_name"] == "John" + + def test_export_profile_yaml(self, profile_manager, sample_profile_data): + """Test exporting profile to YAML format.""" + profile_manager.create_profile("yaml_test", sample_profile_data) + + try: + exported = profile_manager.export_profile("yaml_test", format="yaml") + assert "first_name: John" in exported + assert "profile_info:" in exported + except ValueError as e: + if "PyYAML" in str(e): + pytest.skip("PyYAML not available") + raise + + def test_export_profile_unsupported_format(self, profile_manager, sample_profile_data): + """Test exporting profile with unsupported format.""" + profile_manager.create_profile("format_test", sample_profile_data) + + with pytest.raises(ValueError, match="Unsupported export format"): + profile_manager.export_profile("format_test", format="xml") + + def test_import_profile_json(self, profile_manager): + """Test importing profile from JSON.""" + import_data = { + "profile_info": { + "description": "Imported profile" + }, + "profile_data": { + "first_name": "Imported", + "last_name": "User", + "contact": { + "email": "imported@example.com" + } + } + } + + json_data = json.dumps(import_data) + profile_id = profile_manager.import_profile("imported", json_data, format="json") + + assert profile_id is not None + + # Verify imported data + imported_profile = profile_manager.get_profile("imported") + assert imported_profile.first_name == "Imported" + assert imported_profile.contact.email == "imported@example.com" + + profile_info = profile_manager.get_profile_info("imported") + assert profile_info["description"] == "Imported profile" + + def test_import_profile_overwrite(self, profile_manager, sample_profile_data): + """Test importing profile with overwrite.""" + # Create existing profile + profile_manager.create_profile("overwrite_test", sample_profile_data) + + # Import new data + import_data = { + "profile_data": { + "first_name": "Overwritten", + "contact": {"email": "new@example.com"} + } + } + + json_data = json.dumps(import_data) + profile_id = profile_manager.import_profile("overwrite_test", json_data, overwrite=True) + + # Verify overwrite + profile = profile_manager.get_profile("overwrite_test") + assert profile.first_name == "Overwritten" + + def test_import_profile_no_overwrite_fails(self, profile_manager, sample_profile_data): + """Test importing existing profile without overwrite fails.""" + profile_manager.create_profile("existing", sample_profile_data) + + import_data = {"profile_data": {"first_name": "New"}} + json_data = json.dumps(import_data) + + with pytest.raises(ValueError, match="already exists"): + profile_manager.import_profile("existing", json_data, overwrite=False) + + def test_merge_profiles(self, profile_manager): + """Test merging two profiles.""" + # Create base profile + base_data = ProfileData( + first_name="Base", + last_name="User", + contact=ContactInfo(email="base@example.com", phone="123-456-7890"), + address=Address(city="BaseCity") + ) + profile_manager.create_profile("base", base_data) + + # Create override profile + override_data = ProfileData( + first_name="Override", + contact=ContactInfo(email="override@example.com"), + organization=Organization(name="Override Corp") + ) + profile_manager.create_profile("override", override_data) + + # Merge profiles + merged_profile = profile_manager.merge_profiles("base", "override") + + # Verify merge results + assert merged_profile.first_name == "Override" # Overridden + assert merged_profile.last_name == "User" # From base + assert merged_profile.contact.email == "override@example.com" # Overridden + assert merged_profile.contact.phone == "123-456-7890" # From base + assert merged_profile.address.city == "BaseCity" # From base + assert merged_profile.organization.name == "Override Corp" # From override + + def test_get_template_variables(self, profile_manager, sample_profile_data): + """Test extracting template variables from profile.""" + profile_manager.create_profile("template_test", sample_profile_data, set_as_default=True) + + variables = profile_manager.get_template_variables("template_test") + + # Check flattened variables + assert variables["first_name"] == "John" + assert variables["last_name"] == "Doe" + assert variables["contact.email"] == "john.doe@example.com" + assert variables["organization.name"] == "Tech Corp" + assert variables["address.city"] == "San Francisco" + + # Check computed variable + assert variables["full_name"] == "John Doe" + + def test_get_template_variables_default_profile(self, profile_manager, sample_profile_data): + """Test getting template variables from default profile.""" + profile_manager.create_profile("default_vars", sample_profile_data, set_as_default=True) + + # Get variables without specifying profile name + variables = profile_manager.get_template_variables() + + assert variables["first_name"] == "John" + assert "contact.email" in variables + + def test_get_template_variables_no_default(self, profile_manager): + """Test getting template variables when no default profile.""" + variables = profile_manager.get_template_variables() + assert variables == {} + + def test_database_integration(self, profile_manager, sample_profile_data): + """Test database persistence and retrieval.""" + # Create profile + profile_id = profile_manager.create_profile("db_test", sample_profile_data) + + # Create new manager instance with same database + new_manager = ProfileManager(profile_manager.db_path) + + # Verify data persists + retrieved_profile = new_manager.get_profile("db_test") + assert retrieved_profile.first_name == "John" + assert retrieved_profile.contact.email == "john.doe@example.com" + + def test_profile_timestamps(self, profile_manager, sample_profile_data): + """Test profile creation and update timestamps.""" + before_create = datetime.now().isoformat() + profile_manager.create_profile("timestamp_test", sample_profile_data) + after_create = datetime.now().isoformat() + + profile_info = profile_manager.get_profile_info("timestamp_test") + assert before_create <= profile_info["created_at"] <= after_create + assert before_create <= profile_info["updated_at"] <= after_create + + # Update profile + before_update = datetime.now().isoformat() + profile_manager.update_profile("timestamp_test", ProfileData(first_name="Updated")) + after_update = datetime.now().isoformat() + + updated_info = profile_manager.get_profile_info("timestamp_test") + assert updated_info["created_at"] == profile_info["created_at"] # Unchanged + assert before_update <= updated_info["updated_at"] <= after_update + + def test_edge_cases(self, profile_manager): + """Test edge cases and boundary conditions.""" + # Empty profile + empty_profile = ProfileData() + profile_manager.create_profile("empty", empty_profile) + retrieved = profile_manager.get_profile("empty") + assert retrieved.first_name is None + + # Profile with only custom fields + custom_profile = ProfileData(custom_fields={"hobby": "coding", "level": "expert"}) + profile_manager.create_profile("custom", custom_profile) + retrieved_custom = profile_manager.get_profile("custom") + assert retrieved_custom.custom_fields["hobby"] == "coding" \ No newline at end of file