feat(cli): add new schedule member by name feature

This commit is contained in:
2025-09-11 17:02:54 -04:00
parent 0768e4816d
commit 0e536c5b5f
46 changed files with 232 additions and 21093 deletions

View File

@@ -558,16 +558,16 @@ def cmd_schedules_decline(cli: "NimbusFlowCLI", args) -> None:
# Clear screen after service selection
print("\033[2J\033[H")
# Find pending schedules for this service
# Find pending OR accepted schedules for this service
all_schedules = cli.schedule_repo.list_all()
pending_schedules = [
available_schedules = [
s for s in all_schedules
if s.ServiceId == selected_service.ServiceId and s.Status == ScheduleStatus.PENDING.value
if s.ServiceId == selected_service.ServiceId and s.Status in [ScheduleStatus.PENDING.value, ScheduleStatus.ACCEPTED.value]
]
if not pending_schedules:
if not available_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}")
print(f"❌ No pending or accepted schedules found for {service_type_name} on {args.date}")
return
# Get member info for display
@@ -578,28 +578,31 @@ def cmd_schedules_decline(cli: "NimbusFlowCLI", args) -> None:
print(f"{TableColors.HEADER}Members scheduled for {service_name} on {args.date}{TableColors.RESET}")
print(f"{TableColors.BORDER}" * 70 + f"{TableColors.RESET}")
print()
for i, schedule in enumerate(pending_schedules, 1):
for i, schedule in enumerate(available_schedules, 1):
member = member_map.get(schedule.MemberId)
status_color = TableColors.SUCCESS if schedule.Status == ScheduleStatus.ACCEPTED.value else TableColors.WARNING
status_text = f"{status_color}{schedule.Status.upper()}{TableColors.RESET}"
if member:
print(f" {TableColors.CYAN}{i}.{TableColors.RESET} {member.FirstName} {member.LastName}")
print(f" {TableColors.CYAN}{i}.{TableColors.RESET} {member.FirstName} {member.LastName} {TableColors.DIM}({status_text}){TableColors.RESET}")
else:
print(f" {TableColors.CYAN}{i}.{TableColors.RESET} {TableColors.DIM}Unknown Member (ID: {schedule.MemberId}){TableColors.RESET}")
print(f" {TableColors.CYAN}{i}.{TableColors.RESET} {TableColors.DIM}Unknown Member (ID: {schedule.MemberId}) ({status_text}){TableColors.RESET}")
print()
# Let user select member
try:
print(f"\n{TableColors.INPUT_BOX}┌─ Select member to decline (1-{len(pending_schedules)}) ─┐{TableColors.RESET}")
print(f"\n{TableColors.INPUT_BOX}┌─ Select member to decline (1-{len(available_schedules)}) ─┐{TableColors.RESET}")
choice = input(f"{TableColors.INPUT_BOX}└─> {TableColors.RESET}").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):
if member_index < 0 or member_index >= len(available_schedules):
print("❌ Invalid selection")
return
selected_schedule = pending_schedules[member_index]
selected_schedule = available_schedules[member_index]
except (KeyboardInterrupt, EOFError):
print("\n🛑 Cancelled")
return
@@ -635,10 +638,6 @@ def cmd_schedules_decline(cli: "NimbusFlowCLI", args) -> None:
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)
@@ -646,19 +645,37 @@ def cmd_schedules_decline(cli: "NimbusFlowCLI", args) -> 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)
# Show what we're about to decline
was_accepted = schedule_to_decline.Status == ScheduleStatus.ACCEPTED.value
status_text = "accepted" if was_accepted else "pending"
# 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"\n{TableColors.WARNING}About to decline {status_text} schedule:{TableColors.RESET}")
print(f" Member: {TableColors.BOLD}{member.FirstName} {member.LastName}{TableColors.RESET}")
print(f" Service: {service_type.TypeName} on {service.ServiceDate}")
if decline_reason:
print(f" Reason: {decline_reason}")
if decline_reason:
print(f" Reason: {decline_reason}")
# Use the scheduling service to handle decline logic properly
try:
action, updated_schedule_id = cli.scheduling_service.decline_service_for_user(
member_id=schedule_to_decline.MemberId,
service_id=schedule_to_decline.ServiceId,
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"\n{TableColors.SUCCESS}✅ Schedule {updated_schedule_id} declined successfully!{TableColors.RESET}")
if was_accepted:
print(f"{TableColors.WARNING} Note: This was previously accepted - member moved back to scheduling pool{TableColors.RESET}")
except Exception as e:
print(f"{TableColors.ERROR}❌ Failed to decline schedule: {e}{TableColors.RESET}")
return
def cmd_schedules_schedule(cli: "NimbusFlowCLI", args) -> None:
@@ -734,6 +751,10 @@ def cmd_schedules_schedule(cli: "NimbusFlowCLI", args) -> None:
print(f"{TableColors.HEADER}Selected Service: {service_type_name} on {service.ServiceDate}{TableColors.RESET}")
# Check if we're doing name-based scheduling
if hasattr(args, 'member_name') and args.member_name:
return _schedule_specific_member(cli, service, service_type_name, args.member_name)
# Get classification constraints if not provided
classification_ids = []
if args.classifications:
@@ -848,6 +869,137 @@ def cmd_schedules_schedule(cli: "NimbusFlowCLI", args) -> None:
return
def _schedule_specific_member(cli: "NimbusFlowCLI", service, service_type_name: str, member_name: str) -> None:
"""Helper function to schedule a specific member by name."""
# Search for matching members
all_members = cli.member_repo.list_all()
search_terms = member_name.lower().split()
matching_members = []
for member in all_members:
member_text = f"{member.FirstName} {member.LastName}".lower()
# Match if all search terms are found in the member's name
if all(term in member_text for term in search_terms):
matching_members.append(member)
if not matching_members:
print(f"{TableColors.ERROR}❌ No members found matching '{member_name}'{TableColors.RESET}")
return
# If multiple matches, let user select
selected_member = None
if len(matching_members) == 1:
selected_member = matching_members[0]
print(f"\n{TableColors.SUCCESS}Found member: {selected_member.FirstName} {selected_member.LastName}{TableColors.RESET}")
else:
print(f"\n{TableColors.HEADER}Multiple members found matching '{member_name}':{TableColors.RESET}")
print(f"{TableColors.BORDER}" * 50 + f"{TableColors.RESET}")
print()
for i, member in enumerate(matching_members, 1):
status = "Active" if member.IsActive else "Inactive"
status_color = TableColors.SUCCESS if member.IsActive else TableColors.DIM
print(f" {TableColors.CYAN}{i}.{TableColors.RESET} {TableColors.BOLD}{member.FirstName} {member.LastName}{TableColors.RESET} {TableColors.DIM}({status_color}{status}{TableColors.RESET}{TableColors.DIM}){TableColors.RESET}")
print()
try:
print(f"\n{TableColors.INPUT_BOX}┌─ Select member (1-{len(matching_members)}) ─┐{TableColors.RESET}")
choice = input(f"{TableColors.INPUT_BOX}└─> {TableColors.RESET}").strip()
if not choice or not choice.isdigit():
print(f"{TableColors.ERROR}❌ Invalid selection{TableColors.RESET}")
return
member_index = int(choice) - 1
if member_index < 0 or member_index >= len(matching_members):
print(f"{TableColors.ERROR}❌ Invalid selection{TableColors.RESET}")
return
selected_member = matching_members[member_index]
except (KeyboardInterrupt, EOFError):
print(f"\n{TableColors.WARNING}🛑 Operation cancelled{TableColors.RESET}")
return
# Check if member is active
if not selected_member.IsActive:
print(f"\n{TableColors.WARNING}⚠️ Warning: {selected_member.FirstName} {selected_member.LastName} is marked as inactive{TableColors.RESET}")
try:
confirm = input(f"{TableColors.INPUT_BOX}Continue anyway? (y/N) {TableColors.RESET}").strip().lower()
if confirm not in ['y', 'yes']:
print(f"{TableColors.WARNING}Scheduling cancelled{TableColors.RESET}")
return
except (KeyboardInterrupt, EOFError):
print(f"\n{TableColors.WARNING}🛑 Operation cancelled{TableColors.RESET}")
return
# Get member's classification
if not selected_member.ClassificationId:
print(f"{TableColors.ERROR}{selected_member.FirstName} {selected_member.LastName} has no classification assigned{TableColors.RESET}")
return
member_classification = cli.classification_repo.get_by_id(selected_member.ClassificationId)
if not member_classification:
print(f"{TableColors.ERROR}❌ Could not find classification for {selected_member.FirstName} {selected_member.LastName}{TableColors.RESET}")
return
classification_names = [member_classification.ClassificationName]
# Check service availability
if not cli.availability_repo.get(selected_member.MemberId, service.ServiceTypeId):
print(f"{TableColors.ERROR}{selected_member.FirstName} {selected_member.LastName} is not available for {service_type_name} services{TableColors.RESET}")
return
# Check for existing schedules on the same date
if cli.schedule_repo.has_schedule_on_date(selected_member.MemberId, str(service.ServiceDate)):
print(f"{TableColors.ERROR}{selected_member.FirstName} {selected_member.LastName} already has a schedule on {service.ServiceDate}{TableColors.RESET}")
return
# Check for existing schedule for this specific service
existing_schedule = cli.schedule_repo.get_one(member_id=selected_member.MemberId, service_id=service.ServiceId)
if existing_schedule:
status_text = existing_schedule.Status.upper()
print(f"{TableColors.ERROR}{selected_member.FirstName} {selected_member.LastName} already has a {status_text} schedule for this service{TableColors.RESET}")
return
# Show confirmation
print(f"\n{TableColors.HEADER}Scheduling Confirmation{TableColors.RESET}")
print(f"{TableColors.BORDER}" * 50 + f"{TableColors.RESET}")
print(f" {TableColors.BOLD}Member:{TableColors.RESET} {selected_member.FirstName} {selected_member.LastName}")
print(f" {TableColors.BOLD}Service:{TableColors.RESET} {service_type_name} on {service.ServiceDate}")
print(f" {TableColors.BOLD}Classifications:{TableColors.RESET} {', '.join(classification_names)}")
print()
try:
print(f"\n{TableColors.INPUT_BOX}┌─ Create this schedule? (Y/n) ─┐{TableColors.RESET}")
confirm = input(f"{TableColors.INPUT_BOX}└─> {TableColors.RESET}").strip().lower()
if confirm in ['n', 'no']:
print(f"{TableColors.WARNING}Scheduling cancelled{TableColors.RESET}")
return
except (KeyboardInterrupt, EOFError):
print(f"\n{TableColors.WARNING}🛑 Operation cancelled{TableColors.RESET}")
return
# Create the schedule
try:
from backend.models.enums import ScheduleStatus
schedule = cli.schedule_repo.create(
service_id=service.ServiceId,
member_id=selected_member.MemberId,
status=ScheduleStatus.PENDING,
)
# Update the member's LastScheduledAt timestamp
cli.member_repo.touch_last_scheduled(selected_member.MemberId)
print(f"\n{TableColors.SUCCESS}✅ Successfully scheduled {selected_member.FirstName} {selected_member.LastName}!{TableColors.RESET}")
print(f"{TableColors.DIM}Schedule ID: {schedule.ScheduleId}{TableColors.RESET}")
print(f"{TableColors.DIM}Status: Pending (awaiting member response){TableColors.RESET}")
except Exception as e:
print(f"{TableColors.ERROR}❌ Failed to create schedule: {e}{TableColors.RESET}")
return
def setup_schedules_parser(subparsers) -> None:
"""Set up schedule-related command parsers."""
# Schedules commands
@@ -880,7 +1032,8 @@ def setup_schedules_parser(subparsers) -> None:
schedules_remove_parser.add_argument("--date", type=str, help="Interactive mode: select service and members by date (YYYY-MM-DD)")
# schedules schedule
schedules_schedule_parser = schedules_subparsers.add_parser("schedule", help="Schedule next member for a service (cycles through eligible members)")
schedules_schedule_parser = schedules_subparsers.add_parser("schedule", help="Schedule next member for a service (cycles through eligible members or by name)")
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)")
schedules_schedule_parser.add_argument("--classifications", nargs="*", help="Classification names to filter by (e.g., Soprano Alto)")
schedules_schedule_parser.add_argument("--member-name", type=str, help="Schedule a specific member by name (first, last, or both)")

View File

@@ -177,7 +177,7 @@ def display_schedules_menu():
print(f" {Colors.CYAN}2.{Colors.RESET} {Colors.GREEN}Accept a schedule{Colors.RESET}")
print(f" {Colors.CYAN}3.{Colors.RESET} {Colors.RED}Decline a schedule{Colors.RESET}")
print(f" {Colors.CYAN}4.{Colors.RESET} {Colors.ERROR}Remove scheduled members{Colors.RESET}")
print(f" {Colors.CYAN}5.{Colors.RESET} {Colors.YELLOW}Schedule next member for service{Colors.RESET}")
print(f" {Colors.CYAN}5.{Colors.RESET} {Colors.YELLOW}Schedule member for service{Colors.RESET}")
print(f" {Colors.CYAN}6.{Colors.RESET} {Colors.DIM}Back to main menu{Colors.RESET}")
print()
@@ -417,7 +417,27 @@ def handle_schedules_menu(cli: "NimbusFlowCLI"):
date = get_date_input("Enter date to schedule for")
if date:
clear_screen()
cmd_schedules_schedule(cli, MockArgs(service_id=None, date=date, classifications=None))
# Ask if they want to schedule by name or use round-robin
print(f"\n{Colors.HEADER}Scheduling Options{Colors.RESET}")
print(f"{Colors.GREY}" * 50 + f"{Colors.RESET}")
print()
print(f" {Colors.CYAN}1.{Colors.RESET} {Colors.YELLOW}Round-robin scheduling{Colors.RESET} (choose next available member)")
print(f" {Colors.CYAN}2.{Colors.RESET} {Colors.GREEN}Schedule by name{Colors.RESET} (choose specific member)")
print()
schedule_choice = get_user_choice(2)
clear_screen()
if schedule_choice == 1:
# Round-robin scheduling
cmd_schedules_schedule(cli, MockArgs(service_id=None, date=date, classifications=None, member_name=None))
else:
# Name-based scheduling
member_name = get_text_input("Enter member name to search for (first, last, or both)", True)
if member_name:
clear_screen()
cmd_schedules_schedule(cli, MockArgs(service_id=None, date=date, classifications=None, member_name=member_name))
input(f"\n{Colors.DIM}Press Enter to continue...{Colors.RESET}")
elif choice == 6: # Back to main menu

View File

@@ -213,12 +213,12 @@ class ScheduleRepository(BaseRepository[ScheduleModel]):
# ------------------------------------------------------------------
def has_schedule_on_date(self, member_id: int, service_date: str) -> bool:
"""
Return ``True`` if *any* schedule (regardless of status) exists for
Return ``True`` if any *active* schedule (pending or accepted) exists for
``member_id`` on the calendar day ``service_date`` (format YYYYMMDD).
This abstracts the a member can only be scheduled once per day
This abstracts the "a member can only be actively scheduled once per day"
rule so the service layer does not need to know the underlying
table layout.
table layout. Declined schedules do not count as blocking.
"""
sql = f"""
SELECT 1
@@ -226,9 +226,10 @@ class ScheduleRepository(BaseRepository[ScheduleModel]):
JOIN Services AS sv ON s.ServiceId = sv.ServiceId
WHERE s.MemberId = ?
AND sv.ServiceDate = ?
AND s.Status IN (?, ?)
LIMIT 1
"""
row = self.db.fetchone(sql, (member_id, service_date))
row = self.db.fetchone(sql, (member_id, service_date, ScheduleStatus.PENDING.value, ScheduleStatus.ACCEPTED.value))
return row is not None
# ------------------------------------------------------------------

View File

@@ -7,7 +7,7 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional, Tuple, List, Iterable
from typing import Optional, Tuple, List, Iterable, Literal
from backend.repositories import (
ClassificationRepository,

View File

@@ -223,28 +223,40 @@ def test_has_schedule_on_date(
service_repo: ServiceRepository,
clean_schedules
):
"""Test checking if member has any schedule on a specific date."""
"""Test checking if member has any active schedule on a specific date."""
# Create services on different dates
service_today = service_repo.create(
service_today_9am = service_repo.create(
service_type_id=1,
service_date=dt.date(2025, 9, 15)
)
service_today_11am = service_repo.create(
service_type_id=2,
service_date=dt.date(2025, 9, 15)
)
service_tomorrow = service_repo.create(
service_type_id=2,
service_date=dt.date(2025, 9, 16)
)
# Create schedule for today
# Create pending schedule for today
schedule_repo.create(
service_id=service_today.ServiceId,
service_id=service_today_9am.ServiceId,
member_id=1,
status=ScheduleStatus.PENDING
)
# Create declined schedule for today (should not block)
schedule_repo.create(
service_id=service_today_11am.ServiceId,
member_id=2,
status=ScheduleStatus.DECLINED
)
# Test has_schedule_on_date
assert schedule_repo.has_schedule_on_date(1, "2025-09-15")
assert not schedule_repo.has_schedule_on_date(1, "2025-09-16")
assert not schedule_repo.has_schedule_on_date(2, "2025-09-15")
assert schedule_repo.has_schedule_on_date(1, "2025-09-15") # pending schedule blocks
assert not schedule_repo.has_schedule_on_date(2, "2025-09-15") # declined schedule doesn't block
assert not schedule_repo.has_schedule_on_date(1, "2025-09-16") # different date
assert not schedule_repo.has_schedule_on_date(3, "2025-09-15") # different member
# ----------------------------------------------------------------------