Files
nimbusflow/backend/cli/commands/schedules.py

584 lines
26 KiB
Python

"""
Schedule-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
from backend.cli.utils import format_schedule_row, create_table_header, create_table_separator, TableColors
def cmd_schedules_list(cli: "NimbusFlowCLI", args) -> None:
"""List schedules with optional filters."""
schedules = cli.schedule_repo.list_all()
# Apply filters
if args.service_id:
schedules = [s for s in schedules if s.ServiceId == args.service_id]
if args.status:
try:
status_enum = ScheduleStatus.from_raw(args.status.lower())
schedules = [s for s in schedules if s.Status == status_enum.value]
except ValueError:
print(f"{TableColors.ERROR}Invalid status '{args.status}'. Valid options: pending, accepted, declined{TableColors.RESET}")
return
if not schedules:
print(f"{TableColors.DIM}No schedules found.{TableColors.RESET}")
return
# Get member and service info for display
member_map = {m.MemberId: f"{m.FirstName} {m.LastName}" for m in cli.member_repo.list_all()}
service_map = {}
service_type_map = {st.ServiceTypeId: st.TypeName for st in cli.service_type_repo.list_all()}
for service in cli.service_repo.list_all():
type_name = service_type_map.get(service.ServiceTypeId, "Unknown")
service_map[service.ServiceId] = f"{type_name} {service.ServiceDate}"
# Print styled header
print()
print(create_table_header("ID ", "Status ", "Member ", "Service ", "Scheduled"))
print(create_table_separator(95))
# Print schedules
for schedule in schedules:
member_name = member_map.get(schedule.MemberId, f"ID:{schedule.MemberId}")
service_info = service_map.get(schedule.ServiceId, f"ID:{schedule.ServiceId}")
print(format_schedule_row(schedule, member_name, service_info))
print()
print(f"{TableColors.SUCCESS}Total: {len(schedules)} schedules{TableColors.RESET}")
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:
print(f"{TableColors.ERROR}Schedule with ID {args.schedule_id} not found{TableColors.RESET}")
return
# Get related information
member = cli.member_repo.get_by_id(schedule.MemberId)
service = cli.service_repo.get_by_id(schedule.ServiceId)
service_type = None
if service:
service_type = cli.service_type_repo.get_by_id(service.ServiceTypeId)
print(f"\n{TableColors.HEADER}Schedule Details (ID: {schedule.ScheduleId}){TableColors.RESET}")
print(f"{TableColors.BORDER}{'' * 50}{TableColors.RESET}")
# Member info with color
if member:
print(f"{TableColors.BOLD}Member:{TableColors.RESET} {TableColors.BOLD}{member.FirstName} {member.LastName}{TableColors.RESET} {TableColors.DIM}(ID: {member.MemberId}){TableColors.RESET}")
else:
print(f"{TableColors.BOLD}Member:{TableColors.RESET} {TableColors.DIM}Member ID: {schedule.MemberId}{TableColors.RESET}")
# Service info with color
if service_type and service:
print(f"{TableColors.BOLD}Service:{TableColors.RESET} {TableColors.YELLOW}{service_type.TypeName}{TableColors.RESET} on {TableColors.BLUE}{service.ServiceDate}{TableColors.RESET}")
else:
print(f"{TableColors.BOLD}Service:{TableColors.RESET} {TableColors.DIM}Unknown{TableColors.RESET}")
# Status with appropriate color
status_enum = ScheduleStatus.from_raw(schedule.Status)
if status_enum == ScheduleStatus.PENDING:
status_color = TableColors.WARNING
elif status_enum == ScheduleStatus.ACCEPTED:
status_color = TableColors.SUCCESS
elif status_enum == ScheduleStatus.DECLINED:
status_color = TableColors.ERROR
else:
status_color = TableColors.DIM
print(f"{TableColors.BOLD}Status:{TableColors.RESET} {status_color}{schedule.Status.upper()}{TableColors.RESET}")
print(f"{TableColors.BOLD}Scheduled At:{TableColors.RESET} {schedule.ScheduledAt or f'{TableColors.DIM}N/A{TableColors.RESET}'}")
print(f"{TableColors.BOLD}Accepted At:{TableColors.RESET} {schedule.AcceptedAt or f'{TableColors.DIM}N/A{TableColors.RESET}'}")
print(f"{TableColors.BOLD}Declined At:{TableColors.RESET} {schedule.DeclinedAt or f'{TableColors.DIM}N/A{TableColors.RESET}'}")
print(f"{TableColors.BOLD}Expires At:{TableColors.RESET} {schedule.ExpiresAt or f'{TableColors.DIM}N/A{TableColors.RESET}'}")
if schedule.DeclineReason:
print(f"{TableColors.BOLD}Decline Reason:{TableColors.RESET} {TableColors.ERROR}{schedule.DeclineReason}{TableColors.RESET}")
def cmd_schedules_accept(cli: "NimbusFlowCLI", args) -> None:
"""Accept a scheduled position."""
# Interactive mode with date parameter
if hasattr(args, 'date') and args.date:
try:
target_date = date.fromisoformat(args.date)
except ValueError:
print(f"❌ Invalid date format '{args.date}'. Use YYYY-MM-DD")
return
# Find services for the specified date
all_services = cli.service_repo.list_all()
services_on_date = [s for s in all_services if s.ServiceDate == target_date]
if not services_on_date:
print(f"❌ No services found for {args.date}")
return
# Get service types for display
service_type_map = {st.ServiceTypeId: st.TypeName for st in cli.service_type_repo.list_all()}
# Show available services for the date
print(f"\n📅 Services available on {args.date}:")
print("-" * 40)
for i, service in enumerate(services_on_date, 1):
type_name = service_type_map.get(service.ServiceTypeId, "Unknown")
print(f"{i}. {type_name} (Service ID: {service.ServiceId})")
# Let user select service
try:
choice = input(f"\nSelect service (1-{len(services_on_date)}): ").strip()
if not choice or not choice.isdigit():
print("❌ Invalid selection")
return
service_index = int(choice) - 1
if service_index < 0 or service_index >= len(services_on_date):
print("❌ Invalid selection")
return
selected_service = services_on_date[service_index]
except (KeyboardInterrupt, EOFError):
print("\n🛑 Cancelled")
return
# Find pending schedules for this service
all_schedules = cli.schedule_repo.list_all()
pending_schedules = [
s for s in all_schedules
if s.ServiceId == selected_service.ServiceId and s.Status == ScheduleStatus.PENDING.value
]
if not pending_schedules:
service_type_name = service_type_map.get(selected_service.ServiceTypeId, "Unknown")
print(f"❌ No pending schedules found for {service_type_name} on {args.date}")
return
# Get member info for display
member_map = {m.MemberId: m for m in cli.member_repo.list_all()}
# Show available members to accept
print(f"\n👥 Members scheduled for {service_type_map.get(selected_service.ServiceTypeId, 'Unknown')} on {args.date}:")
print("-" * 60)
for i, schedule in enumerate(pending_schedules, 1):
member = member_map.get(schedule.MemberId)
if member:
print(f"{i}. {member.FirstName} {member.LastName} (Schedule ID: {schedule.ScheduleId})")
else:
print(f"{i}. Unknown Member (ID: {schedule.MemberId}) (Schedule ID: {schedule.ScheduleId})")
# Let user select member
try:
choice = input(f"\nSelect member to accept (1-{len(pending_schedules)}): ").strip()
if not choice or not choice.isdigit():
print("❌ Invalid selection")
return
member_index = int(choice) - 1
if member_index < 0 or member_index >= len(pending_schedules):
print("❌ Invalid selection")
return
selected_schedule = pending_schedules[member_index]
except (KeyboardInterrupt, EOFError):
print("\n🛑 Cancelled")
return
# Accept the selected schedule
schedule_to_accept = selected_schedule
# Direct mode with schedule ID
elif hasattr(args, 'schedule_id') and args.schedule_id:
schedule_to_accept = cli.schedule_repo.get_by_id(args.schedule_id)
if not schedule_to_accept:
print(f"❌ Schedule with ID {args.schedule_id} not found")
return
else:
print("❌ Either --date or schedule_id must be provided")
return
# Common validation and acceptance logic
if schedule_to_accept.Status == ScheduleStatus.ACCEPTED.value:
print(f"⚠️ Schedule {schedule_to_accept.ScheduleId} is already accepted")
return
if schedule_to_accept.Status == ScheduleStatus.DECLINED.value:
print(f"⚠️ Schedule {schedule_to_accept.ScheduleId} was previously declined")
return
# Get member and service info for display
member = cli.member_repo.get_by_id(schedule_to_accept.MemberId)
service = cli.service_repo.get_by_id(schedule_to_accept.ServiceId)
service_type = None
if service:
service_type = cli.service_type_repo.get_by_id(service.ServiceTypeId)
# Mark the schedule as accepted
cli.schedule_repo.mark_accepted(schedule_to_accept.ScheduleId)
# Update member's acceptance timestamp
cli.member_repo.set_last_accepted(schedule_to_accept.MemberId)
print(f"✅ Schedule {schedule_to_accept.ScheduleId} accepted successfully!")
if member and service and service_type:
print(f" Member: {member.FirstName} {member.LastName}")
print(f" Service: {service_type.TypeName} on {service.ServiceDate}")
def cmd_schedules_decline(cli: "NimbusFlowCLI", args) -> None:
"""Decline a scheduled position."""
# Interactive mode with date parameter
if hasattr(args, 'date') and args.date:
try:
target_date = date.fromisoformat(args.date)
except ValueError:
print(f"❌ Invalid date format '{args.date}'. Use YYYY-MM-DD")
return
# Find services for the specified date
all_services = cli.service_repo.list_all()
services_on_date = [s for s in all_services if s.ServiceDate == target_date]
if not services_on_date:
print(f"❌ No services found for {args.date}")
return
# Get service types for display
service_type_map = {st.ServiceTypeId: st.TypeName for st in cli.service_type_repo.list_all()}
# Show available services for the date
print(f"\n📅 Services available on {args.date}:")
print("-" * 40)
for i, service in enumerate(services_on_date, 1):
type_name = service_type_map.get(service.ServiceTypeId, "Unknown")
print(f"{i}. {type_name} (Service ID: {service.ServiceId})")
# Let user select service
try:
choice = input(f"\nSelect service (1-{len(services_on_date)}): ").strip()
if not choice or not choice.isdigit():
print("❌ Invalid selection")
return
service_index = int(choice) - 1
if service_index < 0 or service_index >= len(services_on_date):
print("❌ Invalid selection")
return
selected_service = services_on_date[service_index]
except (KeyboardInterrupt, EOFError):
print("\n🛑 Cancelled")
return
# Find pending schedules for this service
all_schedules = cli.schedule_repo.list_all()
pending_schedules = [
s for s in all_schedules
if s.ServiceId == selected_service.ServiceId and s.Status == ScheduleStatus.PENDING.value
]
if not pending_schedules:
service_type_name = service_type_map.get(selected_service.ServiceTypeId, "Unknown")
print(f"❌ No pending schedules found for {service_type_name} on {args.date}")
return
# Get member info for display
member_map = {m.MemberId: m for m in cli.member_repo.list_all()}
# Show available members to decline
print(f"\n👥 Members scheduled for {service_type_map.get(selected_service.ServiceTypeId, 'Unknown')} on {args.date}:")
print("-" * 60)
for i, schedule in enumerate(pending_schedules, 1):
member = member_map.get(schedule.MemberId)
if member:
print(f"{i}. {member.FirstName} {member.LastName} (Schedule ID: {schedule.ScheduleId})")
else:
print(f"{i}. Unknown Member (ID: {schedule.MemberId}) (Schedule ID: {schedule.ScheduleId})")
# Let user select member
try:
choice = input(f"\nSelect member to decline (1-{len(pending_schedules)}): ").strip()
if not choice or not choice.isdigit():
print("❌ Invalid selection")
return
member_index = int(choice) - 1
if member_index < 0 or member_index >= len(pending_schedules):
print("❌ Invalid selection")
return
selected_schedule = pending_schedules[member_index]
except (KeyboardInterrupt, EOFError):
print("\n🛑 Cancelled")
return
# Get decline reason if not provided
decline_reason = args.reason if hasattr(args, 'reason') and args.reason else None
if not decline_reason:
try:
decline_reason = input("\nEnter decline reason (optional, press Enter to skip): ").strip()
if not decline_reason:
decline_reason = None
except (KeyboardInterrupt, EOFError):
print("\n🛑 Cancelled")
return
# Decline the selected schedule
schedule_to_decline = selected_schedule
# Direct mode with schedule ID
elif hasattr(args, 'schedule_id') and args.schedule_id:
schedule_to_decline = cli.schedule_repo.get_by_id(args.schedule_id)
if not schedule_to_decline:
print(f"❌ Schedule with ID {args.schedule_id} not found")
return
decline_reason = args.reason if hasattr(args, 'reason') else None
else:
print("❌ Either --date or schedule_id must be provided")
return
# Common validation and decline logic
if schedule_to_decline.Status == ScheduleStatus.DECLINED.value:
print(f"⚠️ Schedule {schedule_to_decline.ScheduleId} is already declined")
return
if schedule_to_decline.Status == ScheduleStatus.ACCEPTED.value:
print(f"⚠️ Schedule {schedule_to_decline.ScheduleId} was previously accepted")
return
# Get member and service info for display
member = cli.member_repo.get_by_id(schedule_to_decline.MemberId)
service = cli.service_repo.get_by_id(schedule_to_decline.ServiceId)
service_type = None
if service:
service_type = cli.service_type_repo.get_by_id(service.ServiceTypeId)
# Mark the schedule as declined
cli.schedule_repo.mark_declined(schedule_to_decline.ScheduleId, decline_reason=decline_reason)
# Update member's decline timestamp (using service date)
if service:
cli.member_repo.set_last_declined(schedule_to_decline.MemberId, str(service.ServiceDate))
print(f"❌ Schedule {schedule_to_decline.ScheduleId} declined successfully!")
if member and service and service_type:
print(f" Member: {member.FirstName} {member.LastName}")
print(f" Service: {service_type.TypeName} on {service.ServiceDate}")
if decline_reason:
print(f" Reason: {decline_reason}")
def cmd_schedules_schedule(cli: "NimbusFlowCLI", args) -> None:
"""Schedule next member for a service with preview and confirmation."""
# Determine if we're using service ID or date-based selection
service = None
if hasattr(args, 'date') and args.date:
# Date-based selection
try:
target_date = date.fromisoformat(args.date)
except ValueError:
print(f"❌ Invalid date format '{args.date}'. Use YYYY-MM-DD")
return
# Find services for the specified date
all_services = cli.service_repo.list_all()
services_on_date = [s for s in all_services if s.ServiceDate == target_date]
if not services_on_date:
print(f"❌ No services found for {args.date}")
return
# Get service types for display
service_type_map = {st.ServiceTypeId: st.TypeName for st in cli.service_type_repo.list_all()}
# Show available services for the date
print(f"\n📅 Services available on {args.date}:")
print("-" * 50)
for i, svc in enumerate(services_on_date, 1):
type_name = service_type_map.get(svc.ServiceTypeId, "Unknown")
print(f"{i}. {type_name} (Service ID: {svc.ServiceId})")
# Let user select service
try:
choice = input(f"\nSelect service (1-{len(services_on_date)}): ").strip()
if not choice or not choice.isdigit():
print("❌ Invalid selection")
return
service_index = int(choice) - 1
if service_index < 0 or service_index >= len(services_on_date):
print("❌ Invalid selection")
return
service = services_on_date[service_index]
except (KeyboardInterrupt, EOFError):
print("\n🛑 Operation cancelled")
return
elif hasattr(args, 'service_id') and args.service_id:
# Service ID based selection
print(f"Scheduling for service ID {args.service_id}...")
service = cli.service_repo.get_by_id(args.service_id)
if not service:
print(f"❌ Service ID {args.service_id} not found.")
return
else:
print("❌ Either --date or service_id must be provided")
return
# Get service type name
service_type = cli.service_type_repo.get_by_id(service.ServiceTypeId)
service_type_name = service_type.TypeName if service_type else "Unknown"
print(f"\n📅 Selected Service: {service_type_name} on {service.ServiceDate}")
# Get classification constraints if not provided
classification_ids = []
if args.classifications:
# Convert classification names to IDs
all_classifications = cli.classification_repo.list_all()
classification_map = {c.ClassificationName.lower(): c.ClassificationId for c in all_classifications}
for class_name in args.classifications:
class_id = classification_map.get(class_name.lower())
if class_id:
classification_ids.append(class_id)
else:
print(f"❌ Unknown classification: {class_name}")
return
else:
# If no classifications specified, ask user to select
all_classifications = cli.classification_repo.list_all()
print("\n🎵 Available classifications:")
for i, classification in enumerate(all_classifications, 1):
print(f" {i}. {classification.ClassificationName}")
while True:
try:
choice = input(f"\n🎯 Select classification(s) (1-{len(all_classifications)}, comma-separated): ").strip()
if not choice:
continue
selections = [int(x.strip()) for x in choice.split(',')]
if all(1 <= sel <= len(all_classifications) for sel in selections):
classification_ids = [all_classifications[sel-1].ClassificationId for sel in selections]
break
else:
print(f"❌ Please enter numbers between 1 and {len(all_classifications)}")
except ValueError:
print("❌ Please enter valid numbers")
except (KeyboardInterrupt, EOFError):
print("\n🛑 Operation cancelled")
return
# Get selected classification names for display
selected_classifications = [c.ClassificationName for c in cli.classification_repo.list_all()
if c.ClassificationId in classification_ids]
print(f"\n🔍 Looking for eligible members in: {', '.join(selected_classifications)}")
excluded_members = set()
while True:
# Preview who would be scheduled (excluding any we've already shown)
preview_result = cli.scheduling_service.preview_next_member(
classification_ids=classification_ids,
service_id=service.ServiceId,
only_active=True,
exclude_member_ids=excluded_members,
)
if not preview_result:
if excluded_members:
print("❌ No more eligible members found for this service.")
else:
print("❌ No eligible members found for this service.")
return
member_id, first_name, last_name = preview_result
# Show preview
print(f"\n✨ Next available member:")
print(f" 👤 {first_name} {last_name} (ID: {member_id})")
# Confirm scheduling
try:
confirm = input(f"\n🤔 Schedule {first_name} {last_name} for this service? (y/N/q to quit): ").strip().lower()
if confirm in ['y', 'yes']:
# Actually create the schedule
result = cli.scheduling_service.schedule_next_member(
classification_ids=classification_ids,
service_id=service.ServiceId,
only_active=True,
exclude_member_ids=excluded_members,
)
if result:
scheduled_member_id, scheduled_first, scheduled_last, schedule_id = result
print(f"\n✅ Successfully scheduled {scheduled_first} {scheduled_last}!")
print(f" 📋 Schedule ID: {schedule_id}")
print(f" 📧 Status: Pending (awaiting member response)")
else:
print("❌ Failed to create schedule. The member may no longer be eligible.")
return
elif confirm in ['q', 'quit']:
print("🛑 Scheduling cancelled")
return
else:
# User declined this member - add to exclusion list and continue
excluded_members.add(member_id)
print(f"⏭️ Skipping {first_name} {last_name}, looking for next member...")
except (KeyboardInterrupt, EOFError):
print("\n🛑 Operation cancelled")
return
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")
# schedules list
schedules_list_parser = schedules_subparsers.add_parser("list", help="List schedules")
schedules_list_parser.add_argument("--service-id", type=int, help="Filter by service ID")
schedules_list_parser.add_argument("--status", type=str, help="Filter by status (pending/accepted/declined)")
# schedules show
schedules_show_parser = schedules_subparsers.add_parser("show", help="Show schedule details")
schedules_show_parser.add_argument("schedule_id", type=int, help="Schedule ID to show")
# schedules accept
schedules_accept_parser = schedules_subparsers.add_parser("accept", help="Accept a scheduled position")
schedules_accept_parser.add_argument("schedule_id", type=int, nargs="?", help="Schedule ID to accept (optional if using --date)")
schedules_accept_parser.add_argument("--date", type=str, help="Interactive mode: select service and member by date (YYYY-MM-DD)")
# schedules decline
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")
# schedules schedule
schedules_schedule_parser = schedules_subparsers.add_parser("schedule", help="Schedule next member for a service (cycles through eligible members)")
schedules_schedule_parser.add_argument("service_id", type=int, nargs="?", help="Service ID to schedule for (optional if using --date)")
schedules_schedule_parser.add_argument("--date", type=str, help="Interactive mode: select service by date (YYYY-MM-DD)")
schedules_schedule_parser.add_argument("--classifications", nargs="*", help="Classification names to filter by (e.g., Soprano Alto)")