Files
tegwick b83dc14f7b feat: implement comprehensive User Profile Management System (issue #107)
Complete user profile management system with CRUD operations and CLI integration:

## 🎯 Core Features Delivered
- **ProfileManager**: Complete CRUD operations with database integration
- **JSON Schema validation**: Comprehensive profile data validation
- **Multiple profile support**: Named profiles (personal, work, etc.)
- **Default profile system**: Set and manage default profiles
- **Profile inheritance**: Merge profiles with override capabilities
- **Template integration**: Extract flattened variables for template filling

## 📋 Profile Schema & Data Model
- **Structured data classes**: ProfileData, ContactInfo, Address, Organization
- **JSON Schema validation**: Full validation with field descriptions
- **Flexible structure**: Support for nested data and custom fields
- **Timestamp management**: Automatic created_at/updated_at tracking

## 🖥️ CLI Integration Complete
- **9 CLI Commands**: create, show, list, update, delete, set-default, export, import, variables
- **Multiple formats**: JSON, YAML, and table output formats
- **Interactive mode**: Guided profile creation and updates
- **Export/Import**: Full profile portability with validation
- **Template variables**: Extract flattened variables for template systems

## 📊 Implementation Stats
- **ProfileManager**: 500+ lines with comprehensive functionality
- **ProfileSchema**: 350+ lines with validation and data structures
- **CLI Commands**: 450+ lines of professional command interface
- **Test Coverage**: 66 tests (36 core + 30 CLI) with 100% pass rate

## 🚀 **Ready for Template Integration**
Foundation complete for Issue #99 (Auto Fill Templates) with:
- Template variable extraction from profiles
- Default profile system for seamless integration
- Profile merging for complex template scenarios
- Professional CLI for user profile management

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-04 01:53:31 +02:00

569 lines
23 KiB
Python

"""
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'}")