From a7b596e57337abf4f0b181aad358fdadf8698cc9 Mon Sep 17 00:00:00 2001 From: Giovani Date: Wed, 27 Aug 2025 15:35:09 -0400 Subject: [PATCH] feat(backend): create cli to interact with db --- CLAUDE.md | 124 ++++ backend/cli.py | 701 +++++++++++++++++++ backend/database/repository.py | 4 +- backend/db/__init__.py | 4 +- backend/db/base_repository.py | 2 +- backend/models/__init__.py | 4 +- backend/repositories/__init__.py | 14 +- backend/repositories/classification.py | 4 +- backend/repositories/member.py | 4 +- backend/repositories/schedule.py | 6 +- backend/repositories/service.py | 4 +- backend/repositories/service_availability.py | 4 +- backend/repositories/service_type.py | 4 +- backend/services/scheduling_service.py | 4 +- 14 files changed, 854 insertions(+), 29 deletions(-) create mode 100644 CLAUDE.md create mode 100755 backend/cli.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a0b893f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,124 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Architecture + +NimbusFlow is a scheduling system with a **monorepo structure** containing separate backend and frontend applications: + +- **Backend**: Python-based scheduling service using SQLite with a repository pattern +- **Frontend**: React + TypeScript + Vite application + +### Backend Architecture + +The backend follows a **layered architecture** with clear separation of concerns: + +``` +backend/ +├── db/ # Database connection layer +├── models/ # Data models and enums +├── repositories/ # Data access layer +├── services/ # Business logic layer +└── tests/ # Test suite +``` + +**Key Components:** + +- **DatabaseConnection** (`db/connection.py`): SQLite wrapper with context manager support +- **Repository Pattern**: Each domain entity has its own repository (Member, Service, Classification, etc.) +- **SchedulingService** (`services/scheduling_service.py`): Core business logic for round-robin scheduling with boost/cooldown algorithms +- **Data Models** (`models/dataclasses.py`): Dataclasses with SQLite row conversion utilities + +**Database Schema** (`schema.sql`): SQLite-based with tables for Members, Services, Classifications, Schedules, and audit logging. Uses foreign key constraints and indexes for performance. + +### Frontend Architecture + +React application using: +- TypeScript for type safety +- Vite for fast development and building +- ESLint for code linting + +## Development Commands + +### Backend + +**Setup:** +```bash +cd backend +# Activate virtual environment (already exists) +source venv/bin/activate +``` + +**Testing:** +```bash +cd backend +# Run tests using pytest from virtual environment +venv/bin/pytest +# Run specific test file +venv/bin/pytest tests/repositories/test_member.py +# Run with coverage +venv/bin/pytest --cov +``` + +**Run Application:** +```bash +cd backend +# Run the demo script +python main.py +``` + +### Frontend + +**Setup:** +```bash +cd frontend +npm install +``` + +**Development:** +```bash +cd frontend +# Start development server +npm run dev +# Build for production +npm run build +# Run linting +npm run lint +# Preview production build +npm run preview +``` + +## Core Business Logic + +The **SchedulingService** implements a sophisticated member scheduling algorithm: + +1. **Round-robin scheduling** based on `Members.LastAcceptedAt` timestamps +2. **5-day decline boost** - recently declined members get priority +3. **Same-day exclusion** - members can't be scheduled for multiple services on the same day +4. **Service availability** - members must be eligible for the specific service type +5. **Status constraints** - prevents double-booking (pending/accepted/declined statuses) + +**Key Methods:** +- `schedule_next_member()` - Core scheduling logic with multi-classification support +- `decline_service_for_user()` - Handle member declining assignments + +## Database + +The system uses **SQLite** with the following key tables: +- `Members` - Core member data with scheduling timestamps +- `Services` - Service instances on specific dates +- `Schedules` - Member-service assignments with status tracking +- `Classifications` - Member roles (Soprano, Alto, Tenor, Baritone) +- `ServiceTypes` - Time slots (9AM, 11AM, 6PM) +- `ServiceAvailability` - Member eligibility for service types + +**Audit Tables**: `AcceptedLog`, `DeclineLog`, `ScheduledLog` for comprehensive tracking. + +## Testing + +Backend uses **pytest** with fixtures for database setup. Tests cover: +- Repository layer functionality +- Business logic in services +- Database schema constraints + +All tests use in-memory SQLite databases created fresh for each test. \ No newline at end of file diff --git a/backend/cli.py b/backend/cli.py new file mode 100755 index 0000000..6bdde02 --- /dev/null +++ b/backend/cli.py @@ -0,0 +1,701 @@ +#!/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] +""" + +import argparse +import sys +from pathlib import Path +from typing import Optional, List, Any +from datetime import date, datetime + +# Note: This CLI should be run as: python -m backend.cli +# This ensures proper module resolution without sys.path manipulation + +from backend.db import DatabaseConnection +from backend.repositories import ( + MemberRepository, + ClassificationRepository, + ServiceRepository, + ServiceAvailabilityRepository, + ScheduleRepository, + ServiceTypeRepository +) +from backend.models.enums import ScheduleStatus + + +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: + """List schedules with optional filters.""" + print("Listing schedules...") + + 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"❌ Invalid status '{args.status}'. Valid options: pending, accepted, declined") + return + + if not schedules: + print("No schedules found.") + 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 header + print(f"\n{'ID':<3} | {'Status':<10} | {'Member':<20} | {'Service':<15} | {'Scheduled'}") + print("-" * 80) + + # 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(f"\nTotal: {len(schedules)} schedules") + + +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"❌ Schedule with ID {args.schedule_id} not found") + 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📅 Schedule Details (ID: {schedule.ScheduleId})") + print("-" * 50) + print(f"Member: {member.FirstName} {member.LastName} (ID: {member.MemberId})" if member else f"Member ID: {schedule.MemberId}") + print(f"Service: {service_type.TypeName if service_type else 'Unknown'} on {service.ServiceDate if service else 'Unknown'}") + print(f"Status: {schedule.Status.upper()}") + print(f"Scheduled At: {schedule.ScheduledAt}") + print(f"Accepted At: {schedule.AcceptedAt or 'N/A'}") + print(f"Declined At: {schedule.DeclinedAt or 'N/A'}") + print(f"Expires At: {schedule.ExpiresAt or 'N/A'}") + if schedule.DeclineReason: + print(f"Decline Reason: {schedule.DeclineReason}") + + +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_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") + + # 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") + + # 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 diff --git a/backend/database/repository.py b/backend/database/repository.py index f0f52f3..9c7dfbe 100644 --- a/backend/database/repository.py +++ b/backend/database/repository.py @@ -1,8 +1,8 @@ import datetime as dt from typing import Optional, Tuple, List -from .connection import DatabaseConnection -from .models import ( +from backend.db.connection import DatabaseConnection +from backend.models import ( Classification, Member, ServiceType, diff --git a/backend/db/__init__.py b/backend/db/__init__.py index c2f848f..d052402 100644 --- a/backend/db/__init__.py +++ b/backend/db/__init__.py @@ -1,3 +1,3 @@ # database/__init__.py -from .connection import DatabaseConnection -from .base_repository import BaseRepository +from backend.db.connection import DatabaseConnection +from backend.db.base_repository import BaseRepository diff --git a/backend/db/base_repository.py b/backend/db/base_repository.py index f119ef6..18b2eca 100644 --- a/backend/db/base_repository.py +++ b/backend/db/base_repository.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import TypeVar, Generic, List, Sequence, Any, Mapping, Tuple -from .connection import DatabaseConnection +from backend.db.connection import DatabaseConnection # Generic type for the model (your dataclasses such as Member, Service, …) T = TypeVar("T") diff --git a/backend/models/__init__.py b/backend/models/__init__.py index dfed863..d35ec78 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -9,7 +9,7 @@ # ------------------------------------------------------------ # Re‑export all dataclass models -from .dataclasses import ( # noqa: F401 (re‑exported names) +from backend.models.dataclasses import ( # noqa: F401 (re‑exported names) AcceptedLog, Classification, DeclineLog, @@ -22,7 +22,7 @@ from .dataclasses import ( # noqa: F401 (re‑exported names) ) # Re‑export any enums that belong to the model layer -from .enums import ScheduleStatus # noqa: F401 +from backend.models.enums import ScheduleStatus # noqa: F401 # Optional: define what ``from myapp.models import *`` should export. # This is useful for documentation tools and for IDE auto‑completion. diff --git a/backend/repositories/__init__.py b/backend/repositories/__init__.py index b208dad..4824308 100644 --- a/backend/repositories/__init__.py +++ b/backend/repositories/__init__.py @@ -1,12 +1,12 @@ -from .classification import ClassificationRepository -from .member import MemberRepository -from .schedule import ScheduleRepository -from .service import ServiceRepository -from .service_availability import ServiceAvailabilityRepository -from .service_type import ServiceTypeRepository +from backend.repositories.classification import ClassificationRepository +from backend.repositories.member import MemberRepository +from backend.repositories.schedule import ScheduleRepository +from backend.repositories.service import ServiceRepository +from backend.repositories.service_availability import ServiceAvailabilityRepository +from backend.repositories.service_type import ServiceTypeRepository __all__ = [ - "ClassificationRepository" + "ClassificationRepository", "MemberRepository", "ScheduleRepository", "ServiceRepository", diff --git a/backend/repositories/classification.py b/backend/repositories/classification.py index a3bae30..52cfb45 100644 --- a/backend/repositories/classification.py +++ b/backend/repositories/classification.py @@ -7,8 +7,8 @@ from __future__ import annotations from typing import List, Optional -from ..db import BaseRepository -from ..models import Classification as ClassificationModel +from backend.db import BaseRepository +from backend.models import Classification as ClassificationModel class ClassificationRepository(BaseRepository[ClassificationModel]): diff --git a/backend/repositories/member.py b/backend/repositories/member.py index 559c147..d94c917 100644 --- a/backend/repositories/member.py +++ b/backend/repositories/member.py @@ -10,8 +10,8 @@ from __future__ import annotations import datetime as _dt from typing import List, Sequence, Optional -from ..db import BaseRepository, DatabaseConnection -from ..models import Member as MemberModel +from backend.db import BaseRepository, DatabaseConnection +from backend.models import Member as MemberModel class MemberRepository(BaseRepository[MemberModel]): diff --git a/backend/repositories/schedule.py b/backend/repositories/schedule.py index a888fe8..60c0be2 100644 --- a/backend/repositories/schedule.py +++ b/backend/repositories/schedule.py @@ -7,9 +7,9 @@ from __future__ import annotations from typing import Any, List, Optional, Sequence -from ..db import BaseRepository -from ..models import Schedule as ScheduleModel -from ..models import ScheduleStatus +from backend.db import BaseRepository +from backend.models import Schedule as ScheduleModel +from backend.models import ScheduleStatus class ScheduleRepository(BaseRepository[ScheduleModel]): diff --git a/backend/repositories/service.py b/backend/repositories/service.py index d6882cd..a335629 100644 --- a/backend/repositories/service.py +++ b/backend/repositories/service.py @@ -8,8 +8,8 @@ from __future__ import annotations from datetime import date, datetime from typing import List, Optional, Sequence, Any -from ..db import BaseRepository -from ..models import Service as ServiceModel +from backend.db import BaseRepository +from backend.models import Service as ServiceModel # ---------------------------------------------------------------------- diff --git a/backend/repositories/service_availability.py b/backend/repositories/service_availability.py index eb42905..a21671d 100644 --- a/backend/repositories/service_availability.py +++ b/backend/repositories/service_availability.py @@ -7,8 +7,8 @@ from __future__ import annotations from typing import List, Optional, Sequence, Any -from ..db import BaseRepository -from ..models import ServiceAvailability as ServiceAvailabilityModel +from backend.db import BaseRepository +from backend.models import ServiceAvailability as ServiceAvailabilityModel class ServiceAvailabilityRepository(BaseRepository[ServiceAvailabilityModel]): diff --git a/backend/repositories/service_type.py b/backend/repositories/service_type.py index 462ab00..2898c9f 100644 --- a/backend/repositories/service_type.py +++ b/backend/repositories/service_type.py @@ -7,8 +7,8 @@ from __future__ import annotations from typing import List, Optional -from ..db import BaseRepository -from ..models import ServiceType as ServiceTypeModel +from backend.db import BaseRepository +from backend.models import ServiceType as ServiceTypeModel class ServiceTypeRepository(BaseRepository[ServiceTypeModel]): diff --git a/backend/services/scheduling_service.py b/backend/services/scheduling_service.py index e5cccbd..e500ad4 100644 --- a/backend/services/scheduling_service.py +++ b/backend/services/scheduling_service.py @@ -9,14 +9,14 @@ from __future__ import annotations from datetime import datetime, timezone from typing import Optional, Tuple, List -from ..repositories import ( +from backend.repositories import ( ClassificationRepository, MemberRepository, ServiceRepository, ServiceAvailabilityRepository, ScheduleRepository ) -from ..models import ScheduleStatus +from backend.models import ScheduleStatus class SchedulingService: