diff --git a/backend/cli/__init__.py b/backend/cli/__init__.py new file mode 100644 index 0000000..ff13c28 --- /dev/null +++ b/backend/cli/__init__.py @@ -0,0 +1,8 @@ +""" +NimbusFlow CLI module. +""" + +from .base import NimbusFlowCLI, CLIError +from .main import main + +__all__ = ["NimbusFlowCLI", "CLIError", "main"] \ No newline at end of file diff --git a/backend/cli/__main__.py b/backend/cli/__main__.py new file mode 100644 index 0000000..d3a2da1 --- /dev/null +++ b/backend/cli/__main__.py @@ -0,0 +1,17 @@ +""" +Main entry point for the CLI package. +Allows running with: python -m backend.cli +""" + +import sys +from pathlib import Path + +# Add the project root to sys.path for backend imports +project_root = Path(__file__).parent.parent.parent +sys.path.insert(0, str(project_root)) + +# Import the main function from within the package +from .main import main + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/backend/cli/ascii.txt b/backend/cli/ascii.txt new file mode 100644 index 0000000..14c6881 --- /dev/null +++ b/backend/cli/ascii.txt @@ -0,0 +1,82 @@ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +█████████████████████████████████████████████████████████████████████▓▓▒▒▒▒▒░░▒░▒▒░░░░░░░░░░░░░░░░▒▒▒▓▓███████████████████████████████████████████████ +████████████████████████████████████████████████████████████▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒░░░░░░░░░░░▒▒▓████████████████████████████████████████ +██████████████████████████████████████████████████████▓▒▒▒▒▒▒▒▒▒▓▓▓████████████████████████████████▓▓▒░░░░░░░░░░▒▓████████████████████████████████████ +█████████████████████████████████████████████████▓▒▒▒▒▓▓██████████████████████████████████████████████████▒░░░░░░░░░▓█████████████████████████████████ +████████████████████████████████████████████▓▒▒▒▓████████████████████████████████████████████████████████████▓░░░░░░░░▓███████████████████████████████ +████████████████████████████████████████▓▒▒█████████████████████████████████████████████████████████████████████▒▒░░░░░▒██████████████████████████████ +█████████████████████████████████████▓▓████████████████████████████████▓▓▓▓▓█████████████████████████████████████▓▒░░░░░░▓████████████████████████████ +███████████████████████████████████████████████████████████████████▒▒▒▒▒▒░░▒▒░▒▓███▓▓▓▓▓██████████████████████████▓▒░░░░░░▓███████████████████████████ +██████████████████████████████████████████████████▓▓▒▒▒▓▓█████▓▓▓▒▒▒▒░░░░░░░░░░▒▒▒▒░░░▒▒▒▒▓████████████████████████▒░░░░░░▒███████████████████████████ +███████████████████████████████████████████████▒▒▒░░░░░░░░▒▓▒▒▒▒░░░▒░░░░░░░░░░░░░░░░░░░░░░▒▒███████████████████████▒░░░░░▒▒███████████████████████████ +█████████████████████████████████████████████▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒█████████████████████▒░░░░░░▒▒███████████████████████████ +███████████████████████████████████████████▓▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒██████████████████▓░░░░░░░▒▒▓███████████████████████████ +███████████████████████████████████████████▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒████████████████▓░░░░░░░░▒▒▒████████████████████████████ +██████████████████████████████████████████▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒█████████████▓▒░░░░░░░░▒▒▒▒█████████████████████████████ +████████████████████████████████████████▓▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▓███████▓▒░░░░░░░░░░▒▒▒▒▒██████████████████████████████ +██████████████████████████████████████▓▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒░░░░░░░░░░░░░░▒▒▒▓▓▒░░░░░░░░░░░░░▒▒▒▒▒▓███████████████████████████████ +█████████████████████████████████████▓▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒░░░░░░░░░░░░░▒▒░░░░░░░░░░░░░▒▒▒▒▒▒▓█████████████████████████████████ +████████████████████████████████████▒▒▒▒░░░░░░░░░░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░▒▒░░░░░░░░░░░░░▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▓███████████████████████████████████ +██████████████████████████████████▓▒▒▒░░░░░░░░░░░░▒▒░░░░░░░░░░░▒░░░░░░░░░░░░░░░░▒▒░░░░░░░░░░░░▒░▒▒░░░░▒▒▒▒▒▒▒▒▒▓██████████████████████████████████████ +█████████████████████████████████▒▒▒▒░░░░░░░░░░░▒▒░░░░░░░░░░░░░░░▒░░░░░░░░░░░░░▒▒░░░░░░░░▒░░▒▒░░░▒▒▒▒▒▒▒▒▒▒▒▓█████████████████████████████████████████ +█████████████████████████████████▒▒▒░░░░░░░░░░▒▒▒░░░░░░░░░░░░░░░░░▒░░░░░░░░░░▒▒▒▒░░░░░░░░▒▒▒▒░░▒▒▒▒▒▒▒▒▒▒█████████████████████████████████████████████ +████████████████████████████████▒▒▒▒░░░░░░░░░▒▒▒░░░░░░░░░░░░░░░░░░▒░░░░░░░░░░░░▒▒▒░░░░░░▒▒▒░░░░▒▒▒▒▒▓█████████████████████████████████████████████████ +████████████████████████████████▒▒▒▒░░░░░░▒░░▒▒▒░░░░░░░▒░░░░░░░░░░░▒░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒░░░▒▒▒████████████████████████████████████████████████████ +████████████████████████████████▒▒▒▒░░░░░▒▒░▒▒▒▒░░░░░▒▒░░░░░░░░░░░░▒░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░▒░░▒▒▒▒████████████████████████████████████████████████████ +█████████████████████████████████▒▒▒▒░░░░▒▒▒▒▒▒▒░░░░░▒░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░▒▒▒▒▒█████████████████████████████████████████████████████ +█████████████████████████████████▓▒▒▒▒▒░░░▒▒▒▒▒▒▒░░░░▒▒░░░░░░░░░░░▒░░░░░░░░▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▒▒▓█████████████████████████████████████████████████████ +███████████████████████████████████▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▓███████████████████████████████████████████████████████ +██████████████████████████████████████▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░▒▒▒▒▒▓██████████████████████████████████████████████████████████ +██████████████████████████████████████▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒░░░░░░▒▒░░░░░░░░░░░▒▒▒█████████████████████████████████████████████████████████████ +██████████████████████████████████████▓▒▒▒░░░░▒▒▒▒░░░░░░░░░░░░░░░░░▒░░░░░░▒▒░░░░░░░░▒▒▒▒▓█████████████████████████████████████████████████████████████ +████████████████████████████████████████▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░▒░░░░░░▒▒░░░░░░▒▒▒▒▒▓██████████████████████████████████████████████████████████████ +██████████████████████████████████████████▓▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░▒▒▒░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓███████████████████████████████████████████████████████████████ +███████████████████████████████████████████████▓▒▒▒░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓█████████████████████████████████████████████████████████████████ +████████████████████████████████████████████████▓▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▓███▓▓▓▓▓█████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒███████████████████████████████████████████████████████████████████████████████████ +████████████████████████████████████████████████████▓▒▒▒▒▒▒▒▒▒▒▒▓█████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ +██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ \ No newline at end of file diff --git a/backend/cli/base.py b/backend/cli/base.py new file mode 100644 index 0000000..4e614a6 --- /dev/null +++ b/backend/cli/base.py @@ -0,0 +1,46 @@ +""" +Base CLI class and utilities for NimbusFlow CLI. +""" + +from pathlib import Path +from backend.db.connection import DatabaseConnection +from backend.repositories import ( + MemberRepository, + ClassificationRepository, + ServiceRepository, + ServiceAvailabilityRepository, + ScheduleRepository, + ServiceTypeRepository +) + + +class CLIError(Exception): + """Custom exception for CLI-specific errors.""" + pass + + +class NimbusFlowCLI: + """Main CLI application class.""" + + def __init__(self, db_path: str = "database6_accepts_and_declines.db"): + """Initialize CLI with database connection.""" + self.db_path = Path(__file__).parent.parent / db_path + if not self.db_path.exists(): + raise CLIError(f"Database not found: {self.db_path}") + + self.db = DatabaseConnection(self.db_path) + self._init_repositories() + + def _init_repositories(self): + """Initialize all repository instances.""" + self.member_repo = MemberRepository(self.db) + self.classification_repo = ClassificationRepository(self.db) + self.service_repo = ServiceRepository(self.db) + self.availability_repo = ServiceAvailabilityRepository(self.db) + self.schedule_repo = ScheduleRepository(self.db) + self.service_type_repo = ServiceTypeRepository(self.db) + + def close(self): + """Clean up database connection.""" + if hasattr(self, 'db'): + self.db.close() \ No newline at end of file diff --git a/backend/cli/commands/__init__.py b/backend/cli/commands/__init__.py new file mode 100644 index 0000000..d43c9e1 --- /dev/null +++ b/backend/cli/commands/__init__.py @@ -0,0 +1,20 @@ +""" +CLI command modules. +""" + +from .members import cmd_members_list, cmd_members_show, setup_members_parser +from .schedules import ( + cmd_schedules_list, cmd_schedules_show, cmd_schedules_accept, + cmd_schedules_decline, setup_schedules_parser +) +from .services import cmd_services_list, setup_services_parser + +__all__ = [ + # Member commands + "cmd_members_list", "cmd_members_show", "setup_members_parser", + # Schedule commands + "cmd_schedules_list", "cmd_schedules_show", "cmd_schedules_accept", + "cmd_schedules_decline", "setup_schedules_parser", + # Service commands + "cmd_services_list", "setup_services_parser", +] \ No newline at end of file diff --git a/backend/cli/commands/members.py b/backend/cli/commands/members.py new file mode 100644 index 0000000..b7b2b5d --- /dev/null +++ b/backend/cli/commands/members.py @@ -0,0 +1,98 @@ +""" +Member-related CLI commands. +""" + +import argparse +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from backend.cli.base import NimbusFlowCLI + +from backend.cli.utils import format_member_row + + +def cmd_members_list(cli: "NimbusFlowCLI", args) -> None: + """List all members with optional filters.""" + print("Listing members...") + + # Get all classifications for lookup + classifications = cli.classification_repo.list_all() + classification_map = {c.ClassificationId: c.ClassificationName for c in classifications} + + # Apply filters + if args.classification: + # Find classification ID by name + classification_id = None + for c in classifications: + if c.ClassificationName.lower() == args.classification.lower(): + classification_id = c.ClassificationId + break + + if classification_id is None: + print(f"❌ Classification '{args.classification}' not found") + return + + members = cli.member_repo.get_by_classification_ids([classification_id]) + elif args.active: + members = cli.member_repo.get_active() + else: + members = cli.member_repo.list_all() + + if not members: + print("No members found.") + return + + # Print header + print(f"\n{'ID':<3} | {'First Name':<12} | {'Last Name':<15} | {'Classification':<12} | {'Active':<6} | {'Email'}") + print("-" * 80) + + # Print members + for member in members: + classification_name = classification_map.get(member.ClassificationId) + print(format_member_row(member, classification_name)) + + print(f"\nTotal: {len(members)} members") + + +def cmd_members_show(cli: "NimbusFlowCLI", args) -> None: + """Show detailed information about a specific member.""" + member = cli.member_repo.get_by_id(args.member_id) + if not member: + print(f"❌ Member with ID {args.member_id} not found") + return + + # Get classification name + classification = None + if member.ClassificationId: + classification = cli.classification_repo.get_by_id(member.ClassificationId) + + print(f"\n📋 Member Details (ID: {member.MemberId})") + print("-" * 50) + print(f"Name: {member.FirstName} {member.LastName}") + print(f"Email: {member.Email or 'N/A'}") + print(f"Phone: {member.PhoneNumber or 'N/A'}") + print(f"Classification: {classification.ClassificationName if classification else 'N/A'}") + print(f"Active: {'Yes' if member.IsActive else 'No'}") + print(f"Notes: {member.Notes or 'N/A'}") + + print(f"\n⏰ Schedule History:") + print(f"Last Scheduled: {member.LastScheduledAt or 'Never'}") + print(f"Last Accepted: {member.LastAcceptedAt or 'Never'}") + print(f"Last Declined: {member.LastDeclinedAt or 'Never'}") + print(f"Decline Streak: {member.DeclineStreak}") + + +def setup_members_parser(subparsers) -> None: + """Set up member-related command parsers.""" + # Members commands + members_parser = subparsers.add_parser("members", help="Manage members") + members_subparsers = members_parser.add_subparsers(dest="members_action", help="Members actions") + + # members list + members_list_parser = members_subparsers.add_parser("list", help="List members") + members_list_parser.add_argument("--active", action="store_true", help="Show only active members") + members_list_parser.add_argument("--classification", type=str, help="Filter by classification name") + + # members show + members_show_parser = members_subparsers.add_parser("show", help="Show member details") + members_show_parser.add_argument("member_id", type=int, help="Member ID to show") \ No newline at end of file diff --git a/backend/cli.py b/backend/cli/commands/schedules.py old mode 100755 new mode 100644 similarity index 55% rename from backend/cli.py rename to backend/cli/commands/schedules.py index 6bdde02..7b599aa --- a/backend/cli.py +++ b/backend/cli/commands/schedules.py @@ -1,179 +1,19 @@ -#!/usr/bin/env python3 """ -NimbusFlow CLI Tool -------------------- -Command-line interface for managing the scheduling system backend. - -Usage (run from nimbusflow directory): - python -m backend.cli members list [--active] [--classification NAME] - python -m backend.cli members show - python -m backend.cli schedules list [--service_id N] [--status STATUS] - python -m backend.cli schedules show - python -m backend.cli schedules accept - python -m backend.cli schedules accept --date YYYY-MM-DD - python -m backend.cli schedules decline [--reason "reason"] - python -m backend.cli schedules decline --date YYYY-MM-DD [--reason "reason"] - python -m backend.cli services list [--date YYYY-MM-DD] [--upcoming] [--limit N] +Schedule-related CLI commands. """ import argparse -import sys -from pathlib import Path -from typing import Optional, List, Any -from datetime import date, datetime +from datetime import date +from typing import TYPE_CHECKING -# Note: This CLI should be run as: python -m backend.cli -# This ensures proper module resolution without sys.path manipulation +if TYPE_CHECKING: + from backend.cli.base import NimbusFlowCLI -from backend.db import DatabaseConnection -from backend.repositories import ( - MemberRepository, - ClassificationRepository, - ServiceRepository, - ServiceAvailabilityRepository, - ScheduleRepository, - ServiceTypeRepository -) from backend.models.enums import ScheduleStatus +from backend.cli.utils import format_schedule_row -class CLIError(Exception): - """Custom exception for CLI-specific errors.""" - pass - - -class NimbusFlowCLI: - """Main CLI application class.""" - - def __init__(self, db_path: str = "database6_accepts_and_declines.db"): - """Initialize CLI with database connection.""" - self.db_path = Path(__file__).parent / db_path - if not self.db_path.exists(): - raise CLIError(f"Database not found: {self.db_path}") - - self.db = DatabaseConnection(self.db_path) - self._init_repositories() - - def _init_repositories(self): - """Initialize all repository instances.""" - self.member_repo = MemberRepository(self.db) - self.classification_repo = ClassificationRepository(self.db) - self.service_repo = ServiceRepository(self.db) - self.availability_repo = ServiceAvailabilityRepository(self.db) - self.schedule_repo = ScheduleRepository(self.db) - self.service_type_repo = ServiceTypeRepository(self.db) - - def close(self): - """Clean up database connection.""" - if hasattr(self, 'db'): - self.db.close() - - -def format_member_row(member, classification_name: Optional[str] = None) -> str: - """Format a member for table display.""" - active = "✓" if member.IsActive else "✗" - classification = classification_name or "N/A" - return f"{member.MemberId:3d} | {member.FirstName:<12} | {member.LastName:<15} | {classification:<12} | {active:^6} | {member.Email or 'N/A'}" - - -def format_schedule_row(schedule, member_name: str = "", service_info: str = "") -> str: - """Format a schedule for table display.""" - status_symbols = { - ScheduleStatus.PENDING: "⏳", - ScheduleStatus.ACCEPTED: "✅", - ScheduleStatus.DECLINED: "❌" - } - status_symbol = status_symbols.get(ScheduleStatus.from_raw(schedule.Status), "❓") - # Handle ScheduledAt - could be datetime object or string from DB - if schedule.ScheduledAt: - if isinstance(schedule.ScheduledAt, str): - # If it's a string, try to parse and format it, or use as-is - try: - dt_obj = datetime.fromisoformat(schedule.ScheduledAt.replace('Z', '+00:00')) - scheduled_date = dt_obj.strftime("%Y-%m-%d %H:%M") - except (ValueError, AttributeError): - scheduled_date = str(schedule.ScheduledAt) - else: - # If it's already a datetime object - scheduled_date = schedule.ScheduledAt.strftime("%Y-%m-%d %H:%M") - else: - scheduled_date = "N/A" - - return f"{schedule.ScheduleId:3d} | {status_symbol} {schedule.Status:<8} | {member_name:<20} | {service_info:<15} | {scheduled_date}" - - -def cmd_members_list(cli: NimbusFlowCLI, args) -> None: - """List all members with optional filters.""" - print("Listing members...") - - # Get all classifications for lookup - classifications = cli.classification_repo.list_all() - classification_map = {c.ClassificationId: c.ClassificationName for c in classifications} - - # Apply filters - if args.classification: - # Find classification ID by name - classification_id = None - for c in classifications: - if c.ClassificationName.lower() == args.classification.lower(): - classification_id = c.ClassificationId - break - - if classification_id is None: - print(f"❌ Classification '{args.classification}' not found") - return - - members = cli.member_repo.get_by_classification_ids([classification_id]) - elif args.active: - members = cli.member_repo.get_active() - else: - members = cli.member_repo.list_all() - - if not members: - print("No members found.") - return - - # Print header - print(f"\n{'ID':<3} | {'First Name':<12} | {'Last Name':<15} | {'Classification':<12} | {'Active':<6} | {'Email'}") - print("-" * 80) - - # Print members - for member in members: - classification_name = classification_map.get(member.ClassificationId) - print(format_member_row(member, classification_name)) - - print(f"\nTotal: {len(members)} members") - - -def cmd_members_show(cli: NimbusFlowCLI, args) -> None: - """Show detailed information about a specific member.""" - member = cli.member_repo.get_by_id(args.member_id) - if not member: - print(f"❌ Member with ID {args.member_id} not found") - return - - # Get classification name - classification = None - if member.ClassificationId: - classification = cli.classification_repo.get_by_id(member.ClassificationId) - - print(f"\n📋 Member Details (ID: {member.MemberId})") - print("-" * 50) - print(f"Name: {member.FirstName} {member.LastName}") - print(f"Email: {member.Email or 'N/A'}") - print(f"Phone: {member.PhoneNumber or 'N/A'}") - print(f"Classification: {classification.ClassificationName if classification else 'N/A'}") - print(f"Active: {'Yes' if member.IsActive else 'No'}") - print(f"Notes: {member.Notes or 'N/A'}") - - print(f"\n⏰ Schedule History:") - print(f"Last Scheduled: {member.LastScheduledAt or 'Never'}") - print(f"Last Accepted: {member.LastAcceptedAt or 'Never'}") - print(f"Last Declined: {member.LastDeclinedAt or 'Never'}") - print(f"Decline Streak: {member.DeclineStreak}") - - -def cmd_schedules_list(cli: NimbusFlowCLI, args) -> None: +def cmd_schedules_list(cli: "NimbusFlowCLI", args) -> None: """List schedules with optional filters.""" print("Listing schedules...") @@ -217,7 +57,7 @@ def cmd_schedules_list(cli: NimbusFlowCLI, args) -> None: print(f"\nTotal: {len(schedules)} schedules") -def cmd_schedules_show(cli: NimbusFlowCLI, args) -> None: +def cmd_schedules_show(cli: "NimbusFlowCLI", args) -> None: """Show detailed information about a specific schedule.""" schedule = cli.schedule_repo.get_by_id(args.schedule_id) if not schedule: @@ -244,7 +84,7 @@ def cmd_schedules_show(cli: NimbusFlowCLI, args) -> None: print(f"Decline Reason: {schedule.DeclineReason}") -def cmd_schedules_accept(cli: NimbusFlowCLI, args) -> None: +def cmd_schedules_accept(cli: "NimbusFlowCLI", args) -> None: """Accept a scheduled position.""" # Interactive mode with date parameter if hasattr(args, 'date') and args.date: @@ -373,7 +213,7 @@ def cmd_schedules_accept(cli: NimbusFlowCLI, args) -> None: print(f" Service: {service_type.TypeName} on {service.ServiceDate}") -def cmd_schedules_decline(cli: NimbusFlowCLI, args) -> None: +def cmd_schedules_decline(cli: "NimbusFlowCLI", args) -> None: """Decline a scheduled position.""" # Interactive mode with date parameter if hasattr(args, 'date') and args.date: @@ -517,91 +357,8 @@ def cmd_schedules_decline(cli: NimbusFlowCLI, args) -> None: print(f" Reason: {decline_reason}") -def cmd_services_list(cli: NimbusFlowCLI, args) -> None: - """List services with optional filters.""" - print("Listing services...") - - # Apply filters - if args.upcoming: - services = cli.service_repo.upcoming(limit=args.limit or 50) - elif args.date: - try: - target_date = date.fromisoformat(args.date) - # Get services for specific date - all_services = cli.service_repo.list_all() - services = [s for s in all_services if s.ServiceDate == target_date] - except ValueError: - print(f"❌ Invalid date format '{args.date}'. Use YYYY-MM-DD") - return - else: - services = cli.service_repo.list_all() - if args.limit: - services = services[:args.limit] - - if not services: - print("No services found.") - return - - # Get service type names for display - service_type_map = {st.ServiceTypeId: st.TypeName for st in cli.service_type_repo.list_all()} - - # Get schedule counts for each service - schedule_counts = {} - for service in services: - schedules = cli.schedule_repo.list_all() - service_schedules = [s for s in schedules if s.ServiceId == service.ServiceId] - - pending_count = len([s for s in service_schedules if s.Status == ScheduleStatus.PENDING.value]) - accepted_count = len([s for s in service_schedules if s.Status == ScheduleStatus.ACCEPTED.value]) - declined_count = len([s for s in service_schedules if s.Status == ScheduleStatus.DECLINED.value]) - - schedule_counts[service.ServiceId] = { - 'pending': pending_count, - 'accepted': accepted_count, - 'declined': declined_count, - 'total': len(service_schedules) - } - - # Print header - print(f"\n{'ID':<3} | {'Date':<12} | {'Service Type':<12} | {'Total':<5} | {'Pending':<7} | {'Accepted':<8} | {'Declined'}") - print("-" * 85) - - # Print services - for service in sorted(services, key=lambda s: s.ServiceDate): - type_name = service_type_map.get(service.ServiceTypeId, "Unknown") - counts = schedule_counts.get(service.ServiceId, {'total': 0, 'pending': 0, 'accepted': 0, 'declined': 0}) - - # Format date properly - date_str = str(service.ServiceDate) if service.ServiceDate else "N/A" - - print(f"{service.ServiceId:<3} | {date_str:<12} | {type_name:<12} | " - f"{counts['total']:<5} | {counts['pending']:<7} | {counts['accepted']:<8} | {counts['declined']}") - - print(f"\nTotal: {len(services)} services") - - -def setup_parser() -> argparse.ArgumentParser: - """Set up the command-line argument parser.""" - parser = argparse.ArgumentParser( - description="NimbusFlow CLI - Manage the scheduling system", - formatter_class=argparse.RawDescriptionHelpFormatter - ) - - subparsers = parser.add_subparsers(dest="command", help="Available commands") - - # Members commands - members_parser = subparsers.add_parser("members", help="Manage members") - members_subparsers = members_parser.add_subparsers(dest="members_action", help="Members actions") - - # members list - members_list_parser = members_subparsers.add_parser("list", help="List members") - members_list_parser.add_argument("--active", action="store_true", help="Show only active members") - members_list_parser.add_argument("--classification", type=str, help="Filter by classification name") - - # members show - members_show_parser = members_subparsers.add_parser("show", help="Show member details") - members_show_parser.add_argument("member_id", type=int, help="Member ID to show") - +def setup_schedules_parser(subparsers) -> None: + """Set up schedule-related command parsers.""" # Schedules commands schedules_parser = subparsers.add_parser("schedules", help="Manage schedules") schedules_subparsers = schedules_parser.add_subparsers(dest="schedules_action", help="Schedule actions") @@ -624,78 +381,4 @@ def setup_parser() -> argparse.ArgumentParser: schedules_decline_parser = schedules_subparsers.add_parser("decline", help="Decline a scheduled position") schedules_decline_parser.add_argument("schedule_id", type=int, nargs="?", help="Schedule ID to decline (optional if using --date)") schedules_decline_parser.add_argument("--date", type=str, help="Interactive mode: select service and member by date (YYYY-MM-DD)") - schedules_decline_parser.add_argument("--reason", type=str, help="Reason for declining") - - # Services commands - services_parser = subparsers.add_parser("services", help="Manage services") - services_subparsers = services_parser.add_subparsers(dest="services_action", help="Services actions") - - # services list - services_list_parser = services_subparsers.add_parser("list", help="List services") - services_list_parser.add_argument("--date", type=str, help="Filter by specific date (YYYY-MM-DD)") - services_list_parser.add_argument("--upcoming", action="store_true", help="Show only upcoming services") - services_list_parser.add_argument("--limit", type=int, help="Limit number of results") - - return parser - - -def main(): - """Main CLI entry point.""" - parser = setup_parser() - args = parser.parse_args() - - if not args.command: - parser.print_help() - return - - try: - cli = NimbusFlowCLI() - - # Route commands - if args.command == "members": - if args.members_action == "list": - cmd_members_list(cli, args) - elif args.members_action == "show": - cmd_members_show(cli, args) - else: - print("❌ Unknown members action. Use 'list' or 'show'") - - elif args.command == "schedules": - if args.schedules_action == "list": - cmd_schedules_list(cli, args) - elif args.schedules_action == "show": - cmd_schedules_show(cli, args) - elif args.schedules_action == "accept": - cmd_schedules_accept(cli, args) - elif args.schedules_action == "decline": - cmd_schedules_decline(cli, args) - else: - print("❌ Unknown schedules action. Use 'list', 'show', 'accept', or 'decline'") - - elif args.command == "services": - if args.services_action == "list": - cmd_services_list(cli, args) - else: - print("❌ Unknown services action. Use 'list'") - - else: - print(f"❌ Unknown command: {args.command}") - - except CLIError as e: - print(f"❌ Error: {e}") - return 1 - except KeyboardInterrupt: - print("\n🛑 Interrupted by user") - return 1 - except Exception as e: - print(f"❌ Unexpected error: {e}") - return 1 - finally: - if 'cli' in locals(): - cli.close() - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + schedules_decline_parser.add_argument("--reason", type=str, help="Reason for declining") \ No newline at end of file diff --git a/backend/cli/commands/services.py b/backend/cli/commands/services.py new file mode 100644 index 0000000..3f41f1e --- /dev/null +++ b/backend/cli/commands/services.py @@ -0,0 +1,88 @@ +""" +Service-related CLI commands. +""" + +import argparse +from datetime import date +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from backend.cli.base import NimbusFlowCLI + +from backend.models.enums import ScheduleStatus + + +def cmd_services_list(cli: "NimbusFlowCLI", args) -> None: + """List services with optional filters.""" + print("Listing services...") + + # Apply filters + if args.upcoming: + services = cli.service_repo.upcoming(limit=args.limit or 50) + elif args.date: + try: + target_date = date.fromisoformat(args.date) + # Get services for specific date + all_services = cli.service_repo.list_all() + services = [s for s in all_services if s.ServiceDate == target_date] + except ValueError: + print(f"❌ Invalid date format '{args.date}'. Use YYYY-MM-DD") + return + else: + services = cli.service_repo.list_all() + if args.limit: + services = services[:args.limit] + + if not services: + print("No services found.") + return + + # Get service type names for display + service_type_map = {st.ServiceTypeId: st.TypeName for st in cli.service_type_repo.list_all()} + + # Get schedule counts for each service + schedule_counts = {} + for service in services: + schedules = cli.schedule_repo.list_all() + service_schedules = [s for s in schedules if s.ServiceId == service.ServiceId] + + pending_count = len([s for s in service_schedules if s.Status == ScheduleStatus.PENDING.value]) + accepted_count = len([s for s in service_schedules if s.Status == ScheduleStatus.ACCEPTED.value]) + declined_count = len([s for s in service_schedules if s.Status == ScheduleStatus.DECLINED.value]) + + schedule_counts[service.ServiceId] = { + 'pending': pending_count, + 'accepted': accepted_count, + 'declined': declined_count, + 'total': len(service_schedules) + } + + # Print header + print(f"\n{'ID':<3} | {'Date':<12} | {'Service Type':<12} | {'Total':<5} | {'Pending':<7} | {'Accepted':<8} | {'Declined'}") + print("-" * 85) + + # Print services + for service in sorted(services, key=lambda s: s.ServiceDate): + type_name = service_type_map.get(service.ServiceTypeId, "Unknown") + counts = schedule_counts.get(service.ServiceId, {'total': 0, 'pending': 0, 'accepted': 0, 'declined': 0}) + + # Format date properly + date_str = str(service.ServiceDate) if service.ServiceDate else "N/A" + + print(f"{service.ServiceId:<3} | {date_str:<12} | {type_name:<12} | " + f"{counts['total']:<5} | {counts['pending']:<7} | {counts['accepted']:<8} | {counts['declined']}") + + print(f"\nTotal: {len(services)} services") + + +def setup_services_parser(subparsers) -> None: + """Set up service-related command parsers.""" + # Services commands + services_parser = subparsers.add_parser("services", help="Manage services") + services_subparsers = services_parser.add_subparsers(dest="services_action", help="Services actions") + + # services list + services_list_parser = services_subparsers.add_parser("list", help="List services") + services_list_parser.add_argument("--date", type=str, help="Filter by specific date (YYYY-MM-DD)") + services_list_parser.add_argument("--upcoming", action="store_true", help="Show only upcoming services") + services_list_parser.add_argument("--limit", type=int, help="Limit number of results") \ No newline at end of file diff --git a/backend/cli/main.py b/backend/cli/main.py new file mode 100644 index 0000000..dd613b9 --- /dev/null +++ b/backend/cli/main.py @@ -0,0 +1,93 @@ +""" +Main CLI coordination and entry point. +""" + +import argparse +import sys + +from .base import NimbusFlowCLI, CLIError +from .commands import ( + # Member commands + cmd_members_list, cmd_members_show, setup_members_parser, + # Schedule commands + cmd_schedules_list, cmd_schedules_show, cmd_schedules_accept, + cmd_schedules_decline, setup_schedules_parser, + # Service commands + cmd_services_list, setup_services_parser, +) + + +def setup_parser() -> argparse.ArgumentParser: + """Set up the command-line argument parser.""" + parser = argparse.ArgumentParser( + prog="python -m backend.cli", + description="NimbusFlow CLI - Manage the scheduling system", + formatter_class=argparse.RawDescriptionHelpFormatter + ) + + subparsers = parser.add_subparsers(dest="command", help="Available commands") + + # Set up all command parsers using the modular functions + setup_members_parser(subparsers) + setup_schedules_parser(subparsers) + setup_services_parser(subparsers) + + return parser + + +def main(): + """Main CLI entry point.""" + parser = setup_parser() + args = parser.parse_args() + + if not args.command: + parser.print_help() + return + + try: + cli = NimbusFlowCLI() + + # Route commands to their respective handlers + if args.command == "members": + if args.members_action == "list": + cmd_members_list(cli, args) + elif args.members_action == "show": + cmd_members_show(cli, args) + else: + print("❌ Unknown members action. Use 'list' or 'show'") + + elif args.command == "schedules": + if args.schedules_action == "list": + cmd_schedules_list(cli, args) + elif args.schedules_action == "show": + cmd_schedules_show(cli, args) + elif args.schedules_action == "accept": + cmd_schedules_accept(cli, args) + elif args.schedules_action == "decline": + cmd_schedules_decline(cli, args) + else: + print("❌ Unknown schedules action. Use 'list', 'show', 'accept', or 'decline'") + + elif args.command == "services": + if args.services_action == "list": + cmd_services_list(cli, args) + else: + print("❌ Unknown services action. Use 'list'") + + else: + print(f"❌ Unknown command: {args.command}") + + except CLIError as e: + print(f"❌ Error: {e}") + return 1 + except KeyboardInterrupt: + print("\n🛑 Interrupted by user") + return 1 + except Exception as e: + print(f"❌ Unexpected error: {e}") + return 1 + finally: + if 'cli' in locals(): + cli.close() + + return 0 \ No newline at end of file diff --git a/backend/cli/utils.py b/backend/cli/utils.py new file mode 100644 index 0000000..ebc1cb6 --- /dev/null +++ b/backend/cli/utils.py @@ -0,0 +1,41 @@ +""" +Formatting utilities for NimbusFlow CLI. +""" + +from typing import Optional +from datetime import datetime +from backend.models.enums import ScheduleStatus + + +def format_member_row(member, classification_name: Optional[str] = None) -> str: + """Format a member for table display.""" + active = "✓" if member.IsActive else "✗" + classification = classification_name or "N/A" + return f"{member.MemberId:3d} | {member.FirstName:<12} | {member.LastName:<15} | {classification:<12} | {active:^6} | {member.Email or 'N/A'}" + + +def format_schedule_row(schedule, member_name: str = "", service_info: str = "") -> str: + """Format a schedule for table display.""" + status_symbols = { + ScheduleStatus.PENDING: "⏳", + ScheduleStatus.ACCEPTED: "✅", + ScheduleStatus.DECLINED: "❌" + } + status_symbol = status_symbols.get(ScheduleStatus.from_raw(schedule.Status), "❓") + + # Handle ScheduledAt - could be datetime object or string from DB + if schedule.ScheduledAt: + if isinstance(schedule.ScheduledAt, str): + # If it's a string, try to parse and format it, or use as-is + try: + dt_obj = datetime.fromisoformat(schedule.ScheduledAt.replace('Z', '+00:00')) + scheduled_date = dt_obj.strftime("%Y-%m-%d %H:%M") + except (ValueError, AttributeError): + scheduled_date = str(schedule.ScheduledAt) + else: + # If it's already a datetime object + scheduled_date = schedule.ScheduledAt.strftime("%Y-%m-%d %H:%M") + else: + scheduled_date = "N/A" + + return f"{schedule.ScheduleId:3d} | {status_symbol} {schedule.Status:<8} | {member_name:<20} | {service_info:<15} | {scheduled_date}" \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index af13694..ce97413 100644 --- a/backend/main.py +++ b/backend/main.py @@ -169,7 +169,7 @@ if __name__ == "__main__": from backend.repositories import MemberRepository, ScheduleRepository, ServiceRepository, ServiceAvailabilityRepository from backend.services.scheduling_service import SchedulingService - DB_PATH = Path(__file__).parent / "database6_accepts_and_declines.db" + DB_PATH = Path(__file__).parent / "database6_accepts_and_declines2.db" # Initialise DB connection (adjust DSN as needed) db = DatabaseConnection(DB_PATH)