Compare commits

...

9 Commits

73 changed files with 7727 additions and 4405 deletions

View File

@@ -1 +1 @@
3.12.4
3.13.7

35
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,35 @@
{
"version": "0.2.0",
"configurations": [
{
// Use IntelliSense to find out which attributes exist for C# debugging
// Use hover for the description of the existing attributes
// For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md.
"name": ".NET Core Launch (web)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
// If you have changed target frameworks, make sure to update the program path.
"program": "${workspaceFolder}/frontend/bin/Debug/net8.0/NimbusFlow.Frontend.dll",
"args": [],
"cwd": "${workspaceFolder}/frontend",
"stopAtEntry": false,
// Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser
"serverReadyAction": {
"action": "openExternally",
"pattern": "\\bNow listening on:\\s+(https?://\\S+)"
},
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach"
}
]
}

41
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,41 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/frontend/NimbusFlow.Frontend.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "publish",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/frontend/NimbusFlow.Frontend.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "watch",
"command": "dotnet",
"type": "process",
"args": [
"watch",
"run",
"--project",
"${workspaceFolder}/frontend/NimbusFlow.Frontend.csproj"
],
"problemMatcher": "$msCompile"
}
]
}

408
CLAUDE.md
View File

@@ -2,123 +2,389 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Architecture
## Project Overview
NimbusFlow is a scheduling system with a **monorepo structure** containing separate backend and frontend applications:
NimbusFlow is a comprehensive scheduling system with a **monorepo structure** containing separate backend and frontend applications designed for church music ministry scheduling. The system handles round-robin member scheduling with sophisticated algorithms for fair distribution and availability management.
- **Backend**: Python-based scheduling service using SQLite with a repository pattern
- **Frontend**: React + TypeScript + Vite application
### Architecture Overview
### Backend Architecture
- **Backend**: Python-based scheduling service with FastAPI REST API, SQLite database, and comprehensive CLI
- **Frontend**: .NET 8 Blazor Server application with Bootstrap UI for web-based management
## 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
├── api/ # FastAPI REST API server
├── cli/ # Command-line interface
├── db/ # Database connection layer
├── models/ # Data models and enums
── repositories/ # Data access layer (Repository pattern)
├── services/ # Business logic layer
├── tests/ # Comprehensive test suite
├── utils/ # Utility functions
├── requirements.txt # Python dependencies
└── schema.sql # Database schema
```
**Key Components:**
### 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
#### API Layer (`api/`)
- **app.py**: FastAPI application with comprehensive REST endpoints
- **__main__.py**: Uvicorn server entry point with auto-reload
- Supports CORS for frontend communication on `localhost:5059`
- Pydantic models for request/response validation with C# naming convention compatibility
**Database Schema** (`schema.sql`): SQLite-based with tables for Members, Services, Classifications, Schedules, and audit logging. Uses foreign key constraints and indexes for performance.
#### CLI Layer (`cli/`)
- **main.py**: Command-line interface coordinator with subcommands
- **interactive.py**: Interactive mode for user-friendly operations
- **commands/**: Modular command implementations (members, schedules, services)
- **base.py**: Base CLI class with common functionality
### Frontend Architecture
#### Database Layer (`db/`)
- **connection.py**: SQLite wrapper with context manager support
- **base_repository.py**: Abstract base repository with common CRUD operations
- Thread-safe connection handling for FastAPI
React application using:
- TypeScript for type safety
- Vite for fast development and building
- ESLint for code linting
#### Repository Pattern (`repositories/`)
- **MemberRepository** (`member.py`): Member CRUD operations
- **ScheduleRepository** (`schedule.py`): Schedule management with status updates
- **ServiceRepository** (`service.py`): Service instance management
- **ClassificationRepository** (`classification.py`): Member role management
- **ServiceTypeRepository** (`service_type.py`): Time slot definitions
- **ServiceAvailabilityRepository** (`service_availability.py`): Member eligibility
#### Business Logic (`services/`)
- **SchedulingService** (`scheduling_service.py`): Core scheduling algorithms
- Round-robin scheduling based on `LastAcceptedAt` timestamps
- 5-day decline boost for recently declined members
- Same-day exclusion rules
- Multi-classification support
- Status-aware scheduling (pending/accepted/declined)
#### Data Models (`models/`)
- **dataclasses.py**: Core data models with SQLite row conversion utilities
- **enums.py**: Enumerated types for system constants
- Models: Member, Service, Schedule, Classification, ServiceType, etc.
### Database Schema (`schema.sql`)
SQLite-based with comprehensive relational design:
**Core Tables:**
- `Members`: Member information with scheduling timestamps
- `Services`: Service instances with dates and types
- `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
- Foreign key constraints and indexes for performance
## Frontend Architecture
.NET 8 Blazor Server application with the following structure:
```
frontend/
├── Components/
│ ├── Layout/ # Layout components (NavMenu, MainLayout)
│ ├── Pages/ # Razor pages (Members, Schedules, Services)
│ ├── App.razor # Root application component
│ └── _Imports.razor # Global imports
├── Models/ # C# models matching backend structure
├── Services/ # HTTP client services for API communication
├── Properties/ # Launch settings and configuration
├── wwwroot/ # Static files and assets
└── Program.cs # Application startup and DI configuration
```
### Key Components
#### Services Layer
- **ApiService.cs**: HTTP client implementation with JSON serialization
- **IApiService.cs**: Service interface for dependency injection
- Configured for `http://localhost:8000/api/` backend communication
- Camel case JSON handling for API compatibility
#### Models (`Models/`)
- **Member.cs**: Complete data models matching backend structure
- Models: Member, Classification, Service, ServiceType, Schedule
- Navigation properties for related data
- Compatible with FastAPI Pydantic models
#### Razor Components (`Components/Pages/`)
- **Members.razor**: Member management interface
- **Schedules.razor**: Schedule management and workflow
- **Services.razor**: Service administration
- **Home.razor**: Dashboard and overview
- Bootstrap 5 styling with responsive design
#### Dependency Injection (`Program.cs`)
- HTTP client configuration for backend API
- Service registration with scoped lifetime
- HTTPS redirection and static file serving
## Development Commands
### Backend
### Backend Development
**Setup:**
**Environment Setup:**
```bash
cd backend
# Activate virtual environment (already exists)
source venv/bin/activate
# Virtual environment is pre-configured in .venv/
source .venv/bin/activate
```
**Dependencies:**
```bash
# Install from requirements.txt
pip install -r requirements.txt
```
**API Server:**
```bash
cd backend
source .venv/bin/activate
python -m api
# Starts FastAPI server on http://localhost:8000
# API documentation at http://localhost:8000/docs
```
**CLI Usage:**
```bash
cd backend
source .venv/bin/activate
python -m cli --help
python -m cli members list
python -m cli schedules schedule --help
```
**Demo/Development Data:**
```bash
cd backend
source .venv/bin/activate
python main.py # Runs demo data population
```
**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
source .venv/bin/activate
pytest # Run all tests
pytest tests/repositories/ # Run repository tests
pytest --cov # Run with coverage
```
**Run Application:**
```bash
cd backend
# Run the demo script
python main.py
```
### Frontend
### Frontend Development
**Setup:**
```bash
cd frontend
npm install
dotnet restore
```
**Development:**
**Development Server:**
```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
dotnet watch # Hot reload development
# Or: dotnet run
```
**Build & Deploy:**
```bash
cd frontend
dotnet build # Development build
dotnet build -c Release # Production build
dotnet publish -c Release # Publish for deployment
```
**Access:**
- Development: http://localhost:5059
- Expects backend API at http://localhost:8000/api/
## Core Business Logic
The **SchedulingService** implements a sophisticated member scheduling algorithm:
### 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)
The **SchedulingService** implements sophisticated member scheduling:
1. **Round-robin fairness**: Based on `LastAcceptedAt` timestamps
2. **Decline boost**: 5-day priority for recently declined members
3. **Same-day exclusion**: Members cannot serve multiple services per day
4. **Classification matching**: Multi-classification support for flexible roles
5. **Availability filtering**: Service type eligibility checking
6. **Status management**: Prevents double-booking across pending/accepted states
**Key Methods:**
- `schedule_next_member()` - Core scheduling logic with multi-classification support
- `decline_service_for_user()` - Handle member declining assignments
- `schedule_next_member(classification_ids, service_id)`: Core scheduling with multi-role support
- `decline_service_for_user(member_id, service_id, reason)`: Decline handling with boost logic
## Database
### API Endpoints
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
**Member Management:**
- `GET /api/members` - List all members
- `GET /api/members/{id}` - Get member details
- `POST /api/members` - Create new member
- `PUT /api/members/{id}` - Update member
- `DELETE /api/members/{id}` - Delete member
**Audit Tables**: `AcceptedLog`, `DeclineLog`, `ScheduledLog` for comprehensive tracking.
**Scheduling Operations:**
- `GET /api/schedules` - List all schedules
- `POST /api/schedules/schedule-next` - Schedule next available member
- `POST /api/schedules/{id}/accept` - Accept assignment
- `POST /api/schedules/{id}/decline` - Decline with reason
- `DELETE /api/schedules/{id}` - Remove schedule
**Service Management:**
- `GET /api/services` - List services
- `POST /api/services` - Create new service
- `GET /api/service-types` - List service types
## Database Management
**Schema Location:** `backend/schema.sql`
**Database File:** `backend/db/sqlite/database.db` (auto-created)
**Key Features:**
- Automatic database creation from schema if missing
- Foreign key constraints enforced
- Comprehensive indexing for performance
- Audit logging for all schedule operations
## Testing
Backend uses **pytest** with fixtures for database setup. Tests cover:
- Repository layer functionality
- Business logic in services
- Database schema constraints
**Backend Testing with pytest:**
- Repository layer unit tests with in-memory SQLite
- Service layer business logic tests
- Fixtures for database setup and test data
- Comprehensive coverage of scheduling algorithms
All tests use in-memory SQLite databases created fresh for each test.
**Test Structure:**
```
tests/
├── conftest.py # Shared fixtures
├── repositories/ # Repository layer tests
└── services/ # Business logic tests
```
## Development Workflow
1. **Backend First**: Start with API development and testing
2. **Database Schema**: Modify `schema.sql` for data model changes
3. **Repository Layer**: Update repositories for new database operations
4. **Service Layer**: Implement business logic in services
5. **API Layer**: Add FastAPI endpoints with Pydantic models
6. **Frontend Models**: Update C# models to match API
7. **Frontend Services**: Extend ApiService for new endpoints
8. **Frontend UI**: Create/update Razor components
9. **Testing**: Add tests for new functionality
10. **Integration**: Test full stack communication
## Color Palette & Design System
The NimbusFlow brand uses a sophisticated color palette inspired by the logo's golden cloud and deep navy background, creating a professional yet warm aesthetic suitable for church ministry applications.
### Primary Colors
```css
:root {
/* Primary Brand Colors */
--nimbus-navy: #1a2332; /* Deep navy background */
--nimbus-navy-light: #2a3441; /* Lighter navy variant */
--nimbus-navy-dark: #0f1419; /* Darker navy variant */
/* Golden Accent Colors */
--nimbus-gold: #ffd700; /* Primary gold */
--nimbus-gold-light: #ffed4e; /* Light gold highlight */
--nimbus-gold-dark: #e6c200; /* Dark gold shadow */
--nimbus-amber: #ffb347; /* Warm amber accent */
/* Neutral Colors */
--nimbus-white: #ffffff; /* Pure white */
--nimbus-gray-100: #f8f9fa; /* Light gray */
--nimbus-gray-300: #dee2e6; /* Medium light gray */
--nimbus-gray-600: #6c757d; /* Medium gray */
--nimbus-gray-800: #343a40; /* Dark gray */
}
```
### Bootstrap Integration
```css
/* Override Bootstrap variables */
:root {
--bs-primary: var(--nimbus-navy);
--bs-primary-rgb: 26, 35, 50;
--bs-secondary: var(--nimbus-gold);
--bs-secondary-rgb: 255, 215, 0;
--bs-success: #28a745;
--bs-warning: var(--nimbus-amber);
--bs-info: #17a2b8;
--bs-danger: #dc3545;
}
```
### Usage Guidelines
**Primary Navy (`--nimbus-navy`):**
- Navigation bars and headers
- Card backgrounds in dark mode
- Button primary states
- Footer backgrounds
**Golden Accent (`--nimbus-gold`):**
- Call-to-action buttons
- Active states and highlights
- Icons and accent elements
- Success indicators
**Amber (`--nimbus-amber`):**
- Warning states
- Pending status indicators
- Hover effects on interactive elements
### Accessibility Considerations
All color combinations meet WCAG 2.1 AA standards:
- **Navy on White**: 12.6:1 contrast ratio
- **Gold on Navy**: 4.8:1 contrast ratio
- **White on Navy**: 12.6:1 contrast ratio
- **Dark Gray on Light Gray**: 7.2:1 contrast ratio
### Implementation Example
```razor
<!-- Primary navigation -->
<nav class="navbar navbar-expand-lg" style="background-color: var(--nimbus-navy);">
<div class="navbar-brand text-white">
<i class="bi bi-cloud" style="color: var(--nimbus-gold);"></i>
NimbusFlow
</div>
</nav>
<!-- Action button -->
<button class="btn" style="background-color: var(--nimbus-gold); color: var(--nimbus-navy);">
Schedule Next Member
</button>
<!-- Status badges -->
<span class="badge" style="background-color: var(--nimbus-amber); color: var(--nimbus-navy);">
Pending
</span>
```
## Important Notes
- **Thread Safety**: FastAPI uses custom DatabaseConnection for thread safety
- **JSON Compatibility**: Backend uses PascalCase/camelCase aliasing for C# compatibility
- **Error Handling**: Comprehensive HTTP status codes and error responses
- **CORS**: Configured for frontend origins on localhost:5059
- **Virtual Environment**: Pre-configured Python environment in `backend/.venv/`
- **Auto-reload**: Both backend (Uvicorn) and frontend (dotnet watch) support hot reload
- **Brand Colors**: Use the defined color palette consistently across all UI components

7
backend/api/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
"""
FastAPI module for NimbusFlow backend.
"""
from .app import app
__all__ = ["app"]

14
backend/api/__main__.py Normal file
View File

@@ -0,0 +1,14 @@
"""
Entry point for running the FastAPI server with python -m backend.api
"""
import uvicorn
if __name__ == "__main__":
uvicorn.run(
"backend.api:app",
host="0.0.0.0",
port=8000,
reload=True,
log_level="info"
)

574
backend/api/app.py Normal file
View File

@@ -0,0 +1,574 @@
"""
FastAPI application for NimbusFlow backend.
Provides REST API endpoints for the frontend Blazor application.
"""
from __future__ import annotations
from pathlib import Path
from typing import List, Optional, Union
from datetime import datetime, date
from fastapi import FastAPI, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
# Import the existing backend modules
from backend.db import DatabaseConnection
from backend.repositories import (
MemberRepository,
ClassificationRepository,
ServiceRepository,
ServiceTypeRepository,
ScheduleRepository,
ServiceAvailabilityRepository,
)
from backend.services.scheduling_service import SchedulingService
from backend.models.dataclasses import (
Member as DbMember,
Classification as DbClassification,
Service as DbService,
ServiceType as DbServiceType,
Schedule as DbSchedule,
)
# Initialize FastAPI app
app = FastAPI(title="NimbusFlow API", version="1.0.0")
# Add CORS middleware to allow frontend access
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:5059",
"https://localhost:5059"
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Database path
DB_PATH = Path(__file__).parent.parent / "db" / "sqlite" / "database.db"
# Configure SQLite to allow threading
import sqlite3
sqlite3.threadsafety = 3
# Custom DatabaseConnection for FastAPI that handles threading
class FastAPIDatabaseConnection(DatabaseConnection):
"""DatabaseConnection that allows cross-thread usage for FastAPI."""
def __init__(
self,
db_path: Union[str, Path],
*,
timeout: float = 5.0,
detect_types: int = sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES,
) -> None:
# Call parent constructor but modify connection to allow threading
self._conn: sqlite3.Connection = sqlite3.connect(
str(db_path),
timeout=timeout,
detect_types=detect_types,
check_same_thread=False # Allow cross-thread usage
)
# ``Row`` makes column access dictionarylike and preserves order.
self._conn.row_factory = sqlite3.Row
self._cursor: sqlite3.Cursor = self._conn.cursor()
# Pydantic models for API requests/responses
class Member(BaseModel):
memberId: int = Field(alias="MemberId")
firstName: str = Field(alias="FirstName")
lastName: str = Field(alias="LastName")
email: Optional[str] = Field(default=None, alias="Email")
phoneNumber: Optional[str] = Field(default=None, alias="PhoneNumber")
classificationId: Optional[int] = Field(default=None, alias="ClassificationId")
classificationName: Optional[str] = Field(default=None, alias="ClassificationName")
notes: Optional[str] = Field(default=None, alias="Notes")
isActive: int = Field(default=1, alias="IsActive")
lastScheduledAt: Optional[datetime] = Field(default=None, alias="LastScheduledAt")
lastAcceptedAt: Optional[datetime] = Field(default=None, alias="LastAcceptedAt")
lastDeclinedAt: Optional[datetime] = Field(default=None, alias="LastDeclinedAt")
declineStreak: int = Field(default=0, alias="DeclineStreak")
class Config:
populate_by_name = True
class MemberCreate(BaseModel):
firstName: str = Field(alias="FirstName")
lastName: str = Field(alias="LastName")
email: Optional[str] = Field(default=None, alias="Email")
phoneNumber: Optional[str] = Field(default=None, alias="PhoneNumber")
classificationId: Optional[int] = Field(default=None, alias="ClassificationId")
notes: Optional[str] = Field(default=None, alias="Notes")
isActive: int = Field(default=1, alias="IsActive")
class Config:
populate_by_name = True
class Classification(BaseModel):
classificationId: int = Field(alias="ClassificationId")
classificationName: str = Field(alias="ClassificationName")
class Config:
populate_by_name = True
class Service(BaseModel):
serviceId: int = Field(alias="ServiceId")
serviceTypeId: int = Field(alias="ServiceTypeId")
serviceDate: date = Field(alias="ServiceDate")
serviceTypeName: Optional[str] = Field(default=None, alias="ServiceTypeName")
class Config:
populate_by_name = True
class ServiceCreate(BaseModel):
serviceTypeId: int = Field(alias="ServiceTypeId")
serviceDate: date = Field(alias="ServiceDate")
class Config:
populate_by_name = True
class ServiceType(BaseModel):
serviceTypeId: int = Field(alias="ServiceTypeId")
typeName: str = Field(alias="TypeName")
class Config:
populate_by_name = True
class Schedule(BaseModel):
scheduleId: int = Field(alias="ScheduleId")
serviceId: int = Field(alias="ServiceId")
memberId: int = Field(alias="MemberId")
status: str = Field(alias="Status")
scheduledAt: datetime = Field(alias="ScheduledAt")
acceptedAt: Optional[datetime] = Field(default=None, alias="AcceptedAt")
declinedAt: Optional[datetime] = Field(default=None, alias="DeclinedAt")
expiresAt: Optional[datetime] = Field(default=None, alias="ExpiresAt")
declineReason: Optional[str] = Field(default=None, alias="DeclineReason")
member: Optional["Member"] = Field(default=None, alias="Member")
service: Optional["Service"] = Field(default=None, alias="Service")
class Config:
populate_by_name = True
class ScheduleNextRequest(BaseModel):
serviceId: int
classificationIds: List[int]
class DeclineRequest(BaseModel):
reason: Optional[str] = None
# Context manager for database operations
from contextlib import contextmanager
@contextmanager
def get_db_context():
"""Context manager to handle database connections properly."""
db = DatabaseConnection(DB_PATH)
try:
with db:
yield db
finally:
pass # Context manager handles cleanup
# Dependency to get database connection and repositories
def get_repositories():
# Create a new database connection for each request to avoid thread safety issues
db = FastAPIDatabaseConnection(DB_PATH)
return {
"db": db,
"member_repo": MemberRepository(db),
"classification_repo": ClassificationRepository(db),
"service_repo": ServiceRepository(db),
"service_type_repo": ServiceTypeRepository(db),
"schedule_repo": ScheduleRepository(db),
"availability_repo": ServiceAvailabilityRepository(db),
}
def get_scheduling_service(repos: dict = Depends(get_repositories)):
return SchedulingService(
classification_repo=repos["classification_repo"],
member_repo=repos["member_repo"],
service_repo=repos["service_repo"],
availability_repo=repos["availability_repo"],
schedule_repo=repos["schedule_repo"],
)
# Helper functions to convert between DB and API models
def db_member_to_api(db_member: DbMember, classification_name: str = None) -> Member:
return Member(
MemberId=db_member.MemberId,
FirstName=db_member.FirstName,
LastName=db_member.LastName,
Email=db_member.Email,
PhoneNumber=db_member.PhoneNumber,
ClassificationId=db_member.ClassificationId,
ClassificationName=classification_name,
Notes=db_member.Notes,
IsActive=db_member.IsActive,
LastScheduledAt=db_member.LastScheduledAt,
LastAcceptedAt=db_member.LastAcceptedAt,
LastDeclinedAt=db_member.LastDeclinedAt,
DeclineStreak=db_member.DeclineStreak,
)
def api_member_to_db(api_member: Member) -> DbMember:
return DbMember(
MemberId=api_member.memberId,
FirstName=api_member.firstName,
LastName=api_member.lastName,
Email=api_member.email,
PhoneNumber=api_member.phoneNumber,
ClassificationId=api_member.classificationId,
Notes=api_member.notes,
IsActive=api_member.isActive,
LastScheduledAt=api_member.lastScheduledAt,
LastAcceptedAt=api_member.lastAcceptedAt,
LastDeclinedAt=api_member.lastDeclinedAt,
DeclineStreak=api_member.declineStreak,
)
def db_classification_to_api(db_classification: DbClassification) -> Classification:
return Classification(
ClassificationId=db_classification.ClassificationId,
ClassificationName=db_classification.ClassificationName,
)
def db_service_to_api(db_service: DbService, service_type: DbServiceType = None) -> Service:
service_dict = {
"ServiceId": db_service.ServiceId,
"ServiceTypeId": db_service.ServiceTypeId,
"ServiceDate": db_service.ServiceDate,
}
# Add service type name if provided
if service_type:
service_dict["ServiceTypeName"] = service_type.TypeName
return Service(**service_dict)
def db_service_type_to_api(db_service_type: DbServiceType) -> ServiceType:
return ServiceType(
ServiceTypeId=db_service_type.ServiceTypeId,
TypeName=db_service_type.TypeName,
)
def db_schedule_to_api(db_schedule: DbSchedule, member: DbMember = None, service: DbService = None) -> Schedule:
schedule_dict = {
"ScheduleId": db_schedule.ScheduleId,
"ServiceId": db_schedule.ServiceId,
"MemberId": db_schedule.MemberId,
"Status": db_schedule.Status,
"ScheduledAt": db_schedule.ScheduledAt,
"AcceptedAt": db_schedule.AcceptedAt,
"DeclinedAt": db_schedule.DeclinedAt,
"ExpiresAt": db_schedule.ExpiresAt,
"DeclineReason": db_schedule.DeclineReason,
}
# Add nested member data if provided
if member:
schedule_dict["Member"] = db_member_to_api(member)
# Add nested service data if provided
if service:
schedule_dict["Service"] = db_service_to_api(service)
return Schedule(**schedule_dict)
# API Endpoints
# Member endpoints
@app.get("/api/members", response_model=List[Member])
async def get_members(repos: dict = Depends(get_repositories)):
db_members = repos["member_repo"].list_all()
result = []
for member in db_members:
# Fetch classification name if classification ID exists
classification_name = None
if member.ClassificationId:
classification = repos["classification_repo"].get_by_id(member.ClassificationId)
if classification:
classification_name = classification.ClassificationName
result.append(db_member_to_api(member, classification_name))
return result
@app.get("/api/members/{member_id}", response_model=Member)
async def get_member(member_id: int, repos: dict = Depends(get_repositories)):
db_member = repos["member_repo"].get_by_id(member_id)
if not db_member:
raise HTTPException(status_code=404, detail="Member not found")
return db_member_to_api(db_member)
@app.post("/api/members", response_model=Member)
async def create_member(member_data: MemberCreate, repos: dict = Depends(get_repositories)):
db_member = repos["member_repo"].create(
first_name=member_data.firstName,
last_name=member_data.lastName,
email=member_data.email,
phone_number=member_data.phoneNumber,
classification_id=member_data.classificationId,
notes=member_data.notes,
is_active=member_data.isActive,
)
return db_member_to_api(db_member)
@app.put("/api/members/{member_id}", response_model=Member)
async def update_member(member_id: int, member_data: Member, repos: dict = Depends(get_repositories)):
existing_member = repos["member_repo"].get_by_id(member_id)
if not existing_member:
raise HTTPException(status_code=404, detail="Member not found")
# Use the base repository _update method
updates = {
"FirstName": member_data.firstName,
"LastName": member_data.lastName,
"Email": member_data.email,
"PhoneNumber": member_data.phoneNumber,
"ClassificationId": member_data.classificationId,
"Notes": member_data.notes,
"IsActive": member_data.isActive,
}
repos["member_repo"]._update("Members", "MemberId", member_id, updates)
# Return the updated member
updated_member = repos["member_repo"].get_by_id(member_id)
return db_member_to_api(updated_member)
@app.delete("/api/members/{member_id}")
async def delete_member(member_id: int, repos: dict = Depends(get_repositories)):
existing_member = repos["member_repo"].get_by_id(member_id)
if not existing_member:
raise HTTPException(status_code=404, detail="Member not found")
repos["member_repo"]._delete("Members", "MemberId", member_id)
return {"message": "Member deleted successfully"}
@app.get("/api/members/{member_id}/schedules", response_model=List[Schedule])
async def get_member_schedules(member_id: int, repos: dict = Depends(get_repositories)):
existing_member = repos["member_repo"].get_by_id(member_id)
if not existing_member:
raise HTTPException(status_code=404, detail="Member not found")
# Get all schedules and filter by member ID (since there's no specific method)
all_schedules = repos["schedule_repo"].list_all()
member_schedules = [s for s in all_schedules if s.MemberId == member_id]
return [db_schedule_to_api(schedule) for schedule in member_schedules]
# Classification endpoints
@app.get("/api/classifications", response_model=List[Classification])
async def get_classifications(repos: dict = Depends(get_repositories)):
db_classifications = repos["classification_repo"].list_all()
return [db_classification_to_api(classification) for classification in db_classifications]
# Service endpoints
@app.get("/api/services", response_model=List[Service])
async def get_services(repos: dict = Depends(get_repositories)):
db_services = repos["service_repo"].list_all()
return [db_service_to_api(service) for service in db_services]
@app.get("/api/services/{service_id}", response_model=Service)
async def get_service(service_id: int, repos: dict = Depends(get_repositories)):
db_service = repos["service_repo"].get_by_id(service_id)
if not db_service:
raise HTTPException(status_code=404, detail="Service not found")
return db_service_to_api(db_service)
@app.post("/api/services", response_model=Service)
async def create_service(service_data: ServiceCreate, repos: dict = Depends(get_repositories)):
db_service = repos["service_repo"].create(
service_type_id=service_data.serviceTypeId,
service_date=service_data.serviceDate,
)
return db_service_to_api(db_service)
# Service Type endpoints
@app.get("/api/service-types", response_model=List[ServiceType])
async def get_service_types(repos: dict = Depends(get_repositories)):
db_service_types = repos["service_type_repo"].list_all()
return [db_service_type_to_api(service_type) for service_type in db_service_types]
# Schedule endpoints
@app.get("/api/schedules", response_model=List[Schedule])
async def get_schedules(repos: dict = Depends(get_repositories)):
db_schedules = repos["schedule_repo"].list_all()
result = []
for schedule in db_schedules:
# Fetch related member and service data
member = repos["member_repo"].get_by_id(schedule.MemberId)
service = repos["service_repo"].get_by_id(schedule.ServiceId)
# Fetch service type if service exists
service_with_type = None
if service:
service_type = repos["service_type_repo"].get_by_id(service.ServiceTypeId)
service_with_type = db_service_to_api(service, service_type)
# Convert to API format with nested data
schedule_dict = {
"ScheduleId": schedule.ScheduleId,
"ServiceId": schedule.ServiceId,
"MemberId": schedule.MemberId,
"Status": schedule.Status,
"ScheduledAt": schedule.ScheduledAt,
"AcceptedAt": schedule.AcceptedAt,
"DeclinedAt": schedule.DeclinedAt,
"ExpiresAt": schedule.ExpiresAt,
"DeclineReason": schedule.DeclineReason,
}
if member:
schedule_dict["Member"] = db_member_to_api(member)
if service_with_type:
schedule_dict["Service"] = service_with_type
result.append(Schedule(**schedule_dict))
return result
@app.get("/api/schedules/{schedule_id}", response_model=Schedule)
async def get_schedule(schedule_id: int, repos: dict = Depends(get_repositories)):
db_schedule = repos["schedule_repo"].get_by_id(schedule_id)
if not db_schedule:
raise HTTPException(status_code=404, detail="Schedule not found")
# Fetch related member and service data
member = repos["member_repo"].get_by_id(db_schedule.MemberId)
service = repos["service_repo"].get_by_id(db_schedule.ServiceId)
# Fetch service type if service exists
service_with_type = None
if service:
service_type = repos["service_type_repo"].get_by_id(service.ServiceTypeId)
service_with_type = db_service_to_api(service, service_type)
# Convert to API format with nested data
schedule_dict = {
"ScheduleId": db_schedule.ScheduleId,
"ServiceId": db_schedule.ServiceId,
"MemberId": db_schedule.MemberId,
"Status": db_schedule.Status,
"ScheduledAt": db_schedule.ScheduledAt,
"AcceptedAt": db_schedule.AcceptedAt,
"DeclinedAt": db_schedule.DeclinedAt,
"ExpiresAt": db_schedule.ExpiresAt,
"DeclineReason": db_schedule.DeclineReason,
}
if member:
schedule_dict["Member"] = db_member_to_api(member)
if service_with_type:
schedule_dict["Service"] = service_with_type
return Schedule(**schedule_dict)
@app.post("/api/schedules/{schedule_id}/accept", response_model=Schedule)
async def accept_schedule(schedule_id: int, repos: dict = Depends(get_repositories)):
db_schedule = repos["schedule_repo"].get_by_id(schedule_id)
if not db_schedule:
raise HTTPException(status_code=404, detail="Schedule not found")
repos["schedule_repo"].mark_accepted(schedule_id)
# Return the updated schedule
updated_schedule = repos["schedule_repo"].get_by_id(schedule_id)
return db_schedule_to_api(updated_schedule)
@app.post("/api/schedules/{schedule_id}/decline", response_model=Schedule)
async def decline_schedule(
schedule_id: int,
decline_data: DeclineRequest,
repos: dict = Depends(get_repositories),
scheduling_service: SchedulingService = Depends(get_scheduling_service)
):
db_schedule = repos["schedule_repo"].get_by_id(schedule_id)
if not db_schedule:
raise HTTPException(status_code=404, detail="Schedule not found")
# Use the scheduling service to decline (handles the business logic)
scheduling_service.decline_service_for_user(
member_id=db_schedule.MemberId,
service_id=db_schedule.ServiceId,
reason=decline_data.reason,
)
# Return the updated schedule
updated_schedule = repos["schedule_repo"].get_by_id(schedule_id)
return db_schedule_to_api(updated_schedule)
@app.delete("/api/schedules/{schedule_id}")
async def remove_schedule(schedule_id: int, repos: dict = Depends(get_repositories)):
existing_schedule = repos["schedule_repo"].get_by_id(schedule_id)
if not existing_schedule:
raise HTTPException(status_code=404, detail="Schedule not found")
repos["schedule_repo"]._delete("Schedules", "ScheduleId", schedule_id)
return {"message": "Schedule removed successfully"}
@app.post("/api/schedules/schedule-next", response_model=Optional[Schedule])
async def schedule_next_member(
request: ScheduleNextRequest,
scheduling_service: SchedulingService = Depends(get_scheduling_service),
repos: dict = Depends(get_repositories)
):
result = scheduling_service.schedule_next_member(
classification_ids=request.classificationIds,
service_id=request.serviceId,
only_active=True,
exclude_member_ids=set(),
)
if not result:
raise HTTPException(status_code=404, detail="No eligible member found")
# result is a tuple: (schedule_id, first_name, last_name, member_id)
schedule_id = result[0]
db_schedule = repos["schedule_repo"].get_by_id(schedule_id)
return db_schedule_to_api(db_schedule)
# Health check endpoint
@app.get("/api/health")
async def health_check():
return {"status": "healthy", "message": "NimbusFlow API is running"}

View File

@@ -1,42 +0,0 @@
████████████████████████████████████████████████████████████████████████████████████████████████████
████████████████████████████████████████████████████████████████████████████████████████████████████
████████████████████████████████████████████████████████████████████████████████████████████████████
█████████████████████████████████████████████▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▓▓████████████████████████████
███████████████████████████████████▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▒████████████████████
████████████████████████████▓▒▒▒▒▒▒▒▒▓▓████████████████████████████████▓▒▒▒▒▒▒░░░░░▒▓███████████████
██████████████████████▓▒▒▒▒▓██████████████████████████████████████████████████▒▒▒▒░░░░░▒████████████
█████████████████▓▒▒▓████████████████████████████████████████████████████████████▓▒▒▒░░░░▒██████████
█████████████▓▓▓████████████████████████████████████████████████████████████████████▒▒▒░░░░▒████████
██████████▓██████████████████████████████████████████████████████████████████████████▓▒▒░░░░▒███████
███████████████████████████████████████▓▒▒▒▒▒▒▒▒▒▒▒███████████████████████████████████▓▒░░░░░▒██████
████████████████████████▓▓▓▓█████████▓▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒█████████████████████████▒▒░░░░▒▓█████
███████████████████▓▒▒▒▒░░░░░░▒▓▓▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░▒▒██████████████████████▓▒░░░░░▒▒█████
█████████████████▓▒▒▒░░░░░░░░░░░░▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒█████████████████████▒░░░░░▒▒▓█████
████████████████▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒███████████████████▒░░░░░▒▒▒▓█████
███████████████▓▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒████████████████▓▒░░░░░░▒▒▒▒██████
███████████████▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒██████████████▒░░░░░░░▒▒▒▒▒███████
█████████████▓▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒░░░░░░░░░░░░▒▒▒▒▒▓███████▓▒░░░░░░░░░▒▒▒▒▒▓████████
███████████▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒░░░░░░░░░░░░▒▒▒▒▓▓▒░░░░░░░░░░░░▒▒▒▒▒▒██████████
██████████▓▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒░░░░░░░░░░░░▒▒▒▒░░░░░░░░░░▒▒▒▒▒▒▒▒▓███████████
█████████▒▒▒▒░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░▒▒▒▒░░░░░░░░░░░▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒▓██████████████
███████▒▒▒▒░░░░░░░░░░▒▒▒▒▒░░░░░░░░▒▒▒░░░░░░░░░░░░░░▒▒▒▒░░░░░▒░░░░▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▓████████████████
██████▒▒▒▒░░░░░░░░░░▒▒▒░░░░░░░░░░░░░░▒▒░░░░░░░░░░░▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓████████████████████
█████▒▒▒▒░░░░░░░░░▒▒▒░░░░░░░░░░░░░░░░░▒▒░░░░░░░░░▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓████████████████████████
█████▒▒▒▒░░░░░▒▒░▒▒▒▒░░░░░░░▒▒░░░░░░░░▒▒░░░░░░░░░░▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▒▒▒▓▓████████████████████████████
█████▒▒▒▒░░░░░▒▒░▒▒▒▒░░░░░▒▒░░░░░░░░░░▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒██████████████████████████████
█████▒▒▒▒░░░░▒▒▒░▒▒▒▒░░░░▒▒░░░░░░░░░░░▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓██████████████████████████████
█████▓▒▒▒▒▒░░▒▒▒▒▒▒▒▒░░░░▒▒░░░░░░░░░░▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒███████████████████████████████
██████▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒▒▒▒░░░░░░▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓████████████████████████████████
████████▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓██████████████████████████████████
██████████▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒███████████████████████████████████████
███████████▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒███████████████████████████████████████
███████████▓▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒████████████████████████████████████████
█████████████▓▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒█████████████████████████████████████████
███████████████████▓▒▒▒▒░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓██████████████████████████████████████████
████████████████████▓▒▒▒▒░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒▒▒▒▓█████████████████████████████████████████████
█████████████████████▓▒▒▒▒▒▒░░░▒▒▒▒▒▒▒▒▒▒▒▓█████████████████████████████████████████████████████████
███████████████████████▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒██████████████████████████████████████████████████████████████
███████████████████████████▓▒▒▒▒▒▒▓█████████████████████████████████████████████████████████████████
████████████████████████████████████████████████████████████████████████████████████████████████████
████████████████████████████████████████████████████████████████████████████████████████████████████
████████████████████████████████████████████████████████████████████████████████████████████████████

View File

@@ -2,7 +2,10 @@
Base CLI class and utilities for NimbusFlow CLI.
"""
import shutil
from datetime import datetime
from pathlib import Path
from typing import Optional
from backend.db.connection import DatabaseConnection
from backend.repositories import (
MemberRepository,
@@ -14,6 +17,19 @@ from backend.repositories import (
)
from backend.services.scheduling_service import SchedulingService
# Import Colors from interactive module for consistent styling
try:
from .interactive import Colors
except ImportError:
# Fallback colors if interactive module not available
class Colors:
RESET = '\033[0m'
SUCCESS = '\033[1m\033[92m'
WARNING = '\033[1m\033[93m'
ERROR = '\033[1m\033[91m'
CYAN = '\033[96m'
DIM = '\033[2m'
class CLIError(Exception):
"""Custom exception for CLI-specific errors."""
@@ -21,17 +37,125 @@ class CLIError(Exception):
class NimbusFlowCLI:
"""Main CLI application class."""
"""Main CLI application class with database versioning."""
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}")
def __init__(self, db_path: str = "database.db", create_version: bool = True):
"""Initialize CLI with database connection, always using most recent version."""
self.db_dir = Path(__file__).parent.parent / "db" / "sqlite"
self.base_db_path = self.db_dir / db_path
# Always find and use the most recent database version
self.db_path = self._get_most_recent_database()
if create_version:
# Create a new version based on the most recent one
self.db_path = self._create_versioned_database()
self.db = DatabaseConnection(self.db_path)
self._init_repositories()
def _get_most_recent_database(self) -> Path:
"""Get the most recent database version, or create base database if none exist."""
versions = self.list_database_versions()
if versions:
# Return the most recent versioned database
most_recent = versions[0] # Already sorted newest first
return most_recent
else:
# No versions exist, create base database if it doesn't exist
if not self.base_db_path.exists():
self._create_base_database()
return self.base_db_path
def _create_base_database(self) -> None:
"""Create the base database from schema.sql if it doesn't exist."""
# Ensure the directory exists
self.db_dir.mkdir(parents=True, exist_ok=True)
# Read the schema from the schema.sql file
schema_path = Path(__file__).parent.parent / "schema.sql"
if not schema_path.exists():
raise CLIError(f"Schema file not found: {schema_path}")
with open(schema_path, 'r') as f:
schema_sql = f.read()
# Create the database and execute the schema
with DatabaseConnection(self.base_db_path) as db:
db.executescript(schema_sql)
print(f"{Colors.SUCCESS}Created new database:{Colors.RESET} {Colors.CYAN}{self.base_db_path.name}{Colors.RESET}")
print(f"{Colors.DIM}Location: {self.base_db_path}{Colors.RESET}")
def _create_versioned_database(self) -> Path:
"""Create a versioned copy from the most recent database."""
source_db = self.db_path # Use the most recent database as source
# Generate timestamp-based version
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
version = self._get_next_version_number()
versioned_name = f"database_v{version}_{timestamp}.db"
versioned_path = self.db_dir / versioned_name
# Copy the most recent database to create the versioned copy
shutil.copy2(source_db, versioned_path)
print(f"{Colors.SUCCESS}Created versioned database:{Colors.RESET} {Colors.CYAN}{versioned_name}{Colors.RESET}")
print(f"{Colors.DIM}Based on: {source_db.name}{Colors.RESET}")
return versioned_path
def _get_next_version_number(self) -> int:
"""Get the next version number by checking existing versioned databases."""
version_pattern = "database_v*_*.db"
existing_versions = list(self.db_dir.glob(version_pattern))
if not existing_versions:
return 1
# Extract version numbers from existing files
versions = []
for db_file in existing_versions:
try:
# Parse version from filename like "database_v123_20250828_143022.db"
parts = db_file.stem.split('_')
if len(parts) >= 2 and parts[1].startswith('v'):
version_num = int(parts[1][1:]) # Remove 'v' prefix
versions.append(version_num)
except (ValueError, IndexError):
continue
return max(versions) + 1 if versions else 1
def list_database_versions(self) -> list[Path]:
"""List all versioned databases in chronological order."""
version_pattern = "database_v*_*.db"
versioned_dbs = list(self.db_dir.glob(version_pattern))
# Sort by modification time (newest first)
return sorted(versioned_dbs, key=lambda x: x.stat().st_mtime, reverse=True)
def cleanup_old_versions(self, keep_latest: int = 5) -> int:
"""Clean up old database versions, keeping only the latest N versions."""
versions = self.list_database_versions()
if len(versions) <= keep_latest:
return 0
versions_to_delete = versions[keep_latest:]
deleted_count = 0
for db_path in versions_to_delete:
try:
db_path.unlink()
deleted_count += 1
print(f"{Colors.DIM}Deleted old version: {db_path.name}{Colors.RESET}")
except OSError as e:
print(f"{Colors.WARNING}⚠️ Could not delete {db_path.name}: {e}{Colors.RESET}")
return deleted_count
def _init_repositories(self):
"""Initialize all repository instances."""
self.member_repo = MemberRepository(self.db)

View File

@@ -5,7 +5,7 @@ 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, cmd_schedules_schedule, setup_schedules_parser
cmd_schedules_decline, cmd_schedules_remove, cmd_schedules_schedule, setup_schedules_parser
)
from .services import cmd_services_list, setup_services_parser
@@ -14,7 +14,7 @@ __all__ = [
"cmd_members_list", "cmd_members_show", "setup_members_parser",
# Schedule commands
"cmd_schedules_list", "cmd_schedules_show", "cmd_schedules_accept",
"cmd_schedules_decline", "cmd_schedules_schedule", "setup_schedules_parser",
"cmd_schedules_decline", "cmd_schedules_remove", "cmd_schedules_schedule", "setup_schedules_parser",
# Service commands
"cmd_services_list", "setup_services_parser",
]

View File

@@ -21,6 +21,17 @@ def cmd_schedules_list(cli: "NimbusFlowCLI", args) -> None:
if args.service_id:
schedules = [s for s in schedules if s.ServiceId == args.service_id]
if hasattr(args, 'date') and args.date:
# Filter schedules by date - find services for that date first
try:
from datetime import date as date_type
target_date = date_type.fromisoformat(args.date)
services_on_date = [s.ServiceId for s in cli.service_repo.list_all() if s.ServiceDate == target_date]
schedules = [s for s in schedules if s.ServiceId in services_on_date]
except ValueError:
print(f"{TableColors.ERROR}Invalid date format '{args.date}'. Use YYYY-MM-DD{TableColors.RESET}")
return
if args.status:
try:
status_enum = ScheduleStatus.from_raw(args.status.lower())
@@ -128,15 +139,18 @@ def cmd_schedules_accept(cli: "NimbusFlowCLI", args) -> None:
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)
print(f"\n{TableColors.HEADER}Services available on {args.date}{TableColors.RESET}")
print(f"{TableColors.BORDER}" * 50 + f"{TableColors.RESET}")
print()
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})")
print(f" {TableColors.CYAN}{i}.{TableColors.RESET} {type_name}")
print()
# Let user select service
try:
choice = input(f"\nSelect service (1-{len(services_on_date)}): ").strip()
print(f"\n{TableColors.INPUT_BOX}┌─ Select service (1-{len(services_on_date)}) ─┐{TableColors.RESET}")
choice = input(f"{TableColors.INPUT_BOX}└─> {TableColors.RESET}").strip()
if not choice or not choice.isdigit():
print("❌ Invalid selection")
return
@@ -151,6 +165,9 @@ def cmd_schedules_accept(cli: "NimbusFlowCLI", args) -> None:
print("\n🛑 Cancelled")
return
# Clear screen after service selection
print("\033[2J\033[H")
# Find pending schedules for this service
all_schedules = cli.schedule_repo.list_all()
pending_schedules = [
@@ -167,72 +184,327 @@ def cmd_schedules_accept(cli: "NimbusFlowCLI", args) -> None:
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)
print(f"\n{TableColors.HEADER}Members scheduled for {service_type_map.get(selected_service.ServiceTypeId, 'Unknown')} on {args.date}:{TableColors.RESET}")
print(f"{TableColors.BORDER}{'' * 70}{TableColors.RESET}")
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})")
print(f"{TableColors.CYAN}{i:2d}.{TableColors.RESET} {TableColors.BOLD}{member.FirstName} {member.LastName}{TableColors.RESET} {TableColors.DIM}(Schedule ID: {schedule.ScheduleId}){TableColors.RESET}")
else:
print(f"{i}. Unknown Member (ID: {schedule.MemberId}) (Schedule ID: {schedule.ScheduleId})")
print(f"{TableColors.CYAN}{i:2d}.{TableColors.RESET} {TableColors.DIM}Unknown Member (ID: {schedule.MemberId}) (Schedule ID: {schedule.ScheduleId}){TableColors.RESET}")
# Let user select multiple members
print(f"\n{TableColors.SUCCESS}Multiple Selection Options:{TableColors.RESET}")
print(f"{TableColors.CYAN}Single:{TableColors.RESET} Enter a number (e.g., {TableColors.YELLOW}3{TableColors.RESET})")
print(f"{TableColors.CYAN}Multiple:{TableColors.RESET} Enter numbers separated by commas (e.g., {TableColors.YELLOW}1,3,5{TableColors.RESET})")
print(f"{TableColors.CYAN}Range:{TableColors.RESET} Enter a range (e.g., {TableColors.YELLOW}1-4{TableColors.RESET})")
print(f"{TableColors.CYAN}All:{TableColors.RESET} Enter {TableColors.YELLOW}all{TableColors.RESET} to select everyone")
# 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")
choice = input(f"\n{TableColors.INPUT_BOX}┌─ Select members to accept ─┐{TableColors.RESET}\n{TableColors.INPUT_BOX}└─> {TableColors.RESET}").strip()
if not choice:
print(f"{TableColors.ERROR}❌ No selection made{TableColors.RESET}")
return
member_index = int(choice) - 1
if member_index < 0 or member_index >= len(pending_schedules):
print("❌ Invalid selection")
selected_indices = []
if choice.lower() == 'all':
selected_indices = list(range(len(pending_schedules)))
elif '-' in choice and ',' not in choice:
# Handle range (e.g., "1-4")
try:
start, end = choice.split('-')
start_idx = int(start.strip()) - 1
end_idx = int(end.strip()) - 1
if start_idx < 0 or end_idx >= len(pending_schedules) or start_idx > end_idx:
print(f"{TableColors.ERROR}❌ Invalid range{TableColors.RESET}")
return
selected_indices = list(range(start_idx, end_idx + 1))
except (ValueError, IndexError):
print(f"{TableColors.ERROR}❌ Invalid range format{TableColors.RESET}")
return
else:
# Handle single number or comma-separated list
try:
numbers = [int(x.strip()) for x in choice.split(',')]
for num in numbers:
idx = num - 1
if idx < 0 or idx >= len(pending_schedules):
print(f"{TableColors.ERROR}❌ Invalid selection: {num}{TableColors.RESET}")
return
if idx not in selected_indices:
selected_indices.append(idx)
except ValueError:
print(f"{TableColors.ERROR}❌ Invalid input format{TableColors.RESET}")
return
if not selected_indices:
print(f"{TableColors.ERROR}❌ No valid selections{TableColors.RESET}")
return
selected_schedule = pending_schedules[member_index]
schedules_to_accept = [pending_schedules[i] for i in selected_indices]
except (KeyboardInterrupt, EOFError):
print("\n🛑 Cancelled")
print(f"\n{TableColors.WARNING}🛑 Cancelled{TableColors.RESET}")
return
# Accept the selected schedule
schedule_to_accept = selected_schedule
# Direct mode with schedule ID
# Direct mode with schedule ID (single schedule)
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")
print(f"{TableColors.ERROR}❌ Schedule with ID {args.schedule_id} not found{TableColors.RESET}")
return
schedules_to_accept = [schedule_to_accept]
else:
print("❌ Either --date or schedule_id must be provided")
print(f"{TableColors.ERROR}❌ Either --date or schedule_id must be provided{TableColors.RESET}")
return
# Common validation and acceptance logic
if schedule_to_accept.Status == ScheduleStatus.ACCEPTED.value:
print(f"⚠️ Schedule {schedule_to_accept.ScheduleId} is already accepted")
# Process multiple schedules
successful_accepts = []
failed_accepts = []
print(f"\n{TableColors.SUCCESS}Processing {len(schedules_to_accept)} schedule(s)...{TableColors.RESET}")
for schedule in schedules_to_accept:
# Validation for each schedule
if schedule.Status == ScheduleStatus.ACCEPTED.value:
failed_accepts.append((schedule, f"already accepted"))
continue
if schedule.Status == ScheduleStatus.DECLINED.value:
failed_accepts.append((schedule, f"previously declined"))
continue
# Get member and service info for display
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)
try:
# Mark the schedule as accepted
cli.schedule_repo.mark_accepted(schedule.ScheduleId)
# Update member's acceptance timestamp
cli.member_repo.set_last_accepted(schedule.MemberId)
successful_accepts.append((schedule, member, service, service_type))
except Exception as e:
failed_accepts.append((schedule, f"database error: {e}"))
# Display results
if successful_accepts:
print(f"\n{TableColors.SUCCESS}✅ Successfully accepted {len(successful_accepts)} schedule(s):{TableColors.RESET}")
for schedule, member, service, service_type in successful_accepts:
member_name = f"{member.FirstName} {member.LastName}" if member else f"Member ID {schedule.MemberId}"
service_info = f"{service_type.TypeName} on {service.ServiceDate}" if service and service_type else f"Service ID {schedule.ServiceId}"
print(f" {TableColors.CYAN}{TableColors.RESET} {TableColors.BOLD}{member_name}{TableColors.RESET} - {service_info}")
if failed_accepts:
print(f"\n{TableColors.WARNING}⚠️ Could not accept {len(failed_accepts)} schedule(s):{TableColors.RESET}")
for schedule, reason in failed_accepts:
member = cli.member_repo.get_by_id(schedule.MemberId)
member_name = f"{member.FirstName} {member.LastName}" if member else f"Member ID {schedule.MemberId}"
print(f" {TableColors.YELLOW}{TableColors.RESET} {TableColors.BOLD}{member_name}{TableColors.RESET} - {reason}")
if not successful_accepts and not failed_accepts:
print(f"{TableColors.DIM}No schedules processed{TableColors.RESET}")
def cmd_schedules_remove(cli: "NimbusFlowCLI", args) -> None:
"""Remove scheduled members and move them back to the front of the queue."""
# Interactive mode with date parameter
if hasattr(args, 'date') and args.date:
try:
target_date = date.fromisoformat(args.date)
except ValueError:
print(f"{TableColors.ERROR}❌ Invalid date format '{args.date}'. Use YYYY-MM-DD{TableColors.RESET}")
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"{TableColors.ERROR}❌ No services found for {args.date}{TableColors.RESET}")
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{TableColors.HEADER}Services available on {args.date}{TableColors.RESET}")
print(f"{TableColors.BORDER}" * 50 + f"{TableColors.RESET}")
print()
for i, service in enumerate(services_on_date, 1):
type_name = service_type_map.get(service.ServiceTypeId, "Unknown")
print(f" {TableColors.CYAN}{i}.{TableColors.RESET} {type_name}")
print()
# Let user select service
try:
print(f"\n{TableColors.INPUT_BOX}┌─ Select service (1-{len(services_on_date)}) ─┐{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
service_index = int(choice) - 1
if service_index < 0 or service_index >= len(services_on_date):
print(f"{TableColors.ERROR}❌ Invalid selection{TableColors.RESET}")
return
selected_service = services_on_date[service_index]
except (KeyboardInterrupt, EOFError):
print(f"\n{TableColors.WARNING}🛑 Cancelled{TableColors.RESET}")
return
# Clear screen after service selection
print("\033[2J\033[H")
# Find all schedules for this service (not just pending)
all_schedules = cli.schedule_repo.list_all()
service_schedules = [
s for s in all_schedules
if s.ServiceId == selected_service.ServiceId
]
if not service_schedules:
service_type_name = service_type_map.get(selected_service.ServiceTypeId, "Unknown")
print(f"{TableColors.WARNING}⚠️ No schedules found for {service_type_name} on {args.date}{TableColors.RESET}")
return
# Get member info for display
member_map = {m.MemberId: m for m in cli.member_repo.list_all()}
# Show available members to remove
print(f"\n{TableColors.HEADER}Members scheduled for {service_type_map.get(selected_service.ServiceTypeId, 'Unknown')} on {args.date}:{TableColors.RESET}")
print(f"{TableColors.BORDER}{'' * 70}{TableColors.RESET}")
for i, schedule in enumerate(service_schedules, 1):
member = member_map.get(schedule.MemberId)
status_color = TableColors.SUCCESS if schedule.Status == "accepted" else TableColors.WARNING if schedule.Status == "pending" else TableColors.ERROR
status_display = f"{status_color}{schedule.Status.upper()}{TableColors.RESET}"
if member:
print(f"{TableColors.CYAN}{i:2d}.{TableColors.RESET} {TableColors.BOLD}{member.FirstName} {member.LastName}{TableColors.RESET} {TableColors.DIM}({status_display}, ID: {schedule.ScheduleId}){TableColors.RESET}")
else:
print(f"{TableColors.CYAN}{i:2d}.{TableColors.RESET} {TableColors.DIM}Unknown Member (ID: {schedule.MemberId}) ({status_display}, ID: {schedule.ScheduleId}){TableColors.RESET}")
# Let user select multiple members to remove
print(f"\n{TableColors.SUCCESS}Multiple Selection Options:{TableColors.RESET}")
print(f"{TableColors.CYAN}Single:{TableColors.RESET} Enter a number (e.g., {TableColors.YELLOW}3{TableColors.RESET})")
print(f"{TableColors.CYAN}Multiple:{TableColors.RESET} Enter numbers separated by commas (e.g., {TableColors.YELLOW}1,3,5{TableColors.RESET})")
print(f"{TableColors.CYAN}Range:{TableColors.RESET} Enter a range (e.g., {TableColors.YELLOW}1-4{TableColors.RESET})")
print(f"{TableColors.CYAN}All:{TableColors.RESET} Enter {TableColors.YELLOW}all{TableColors.RESET} to remove everyone")
try:
choice = input(f"\n{TableColors.INPUT_BOX}┌─ Select members to remove ─┐{TableColors.RESET}\n{TableColors.INPUT_BOX}└─> {TableColors.RESET}").strip()
if not choice:
print(f"{TableColors.ERROR}❌ No selection made{TableColors.RESET}")
return
selected_indices = []
if choice.lower() == 'all':
selected_indices = list(range(len(service_schedules)))
elif '-' in choice and ',' not in choice:
# Handle range (e.g., "1-4")
try:
start, end = choice.split('-')
start_idx = int(start.strip()) - 1
end_idx = int(end.strip()) - 1
if start_idx < 0 or end_idx >= len(service_schedules) or start_idx > end_idx:
print(f"{TableColors.ERROR}❌ Invalid range{TableColors.RESET}")
return
selected_indices = list(range(start_idx, end_idx + 1))
except (ValueError, IndexError):
print(f"{TableColors.ERROR}❌ Invalid range format{TableColors.RESET}")
return
else:
# Handle single number or comma-separated list
try:
numbers = [int(x.strip()) for x in choice.split(',')]
for num in numbers:
idx = num - 1
if idx < 0 or idx >= len(service_schedules):
print(f"{TableColors.ERROR}❌ Invalid selection: {num}{TableColors.RESET}")
return
if idx not in selected_indices:
selected_indices.append(idx)
except ValueError:
print(f"{TableColors.ERROR}❌ Invalid input format{TableColors.RESET}")
return
if not selected_indices:
print(f"{TableColors.ERROR}❌ No valid selections{TableColors.RESET}")
return
schedules_to_remove = [service_schedules[i] for i in selected_indices]
except (KeyboardInterrupt, EOFError):
print(f"\n{TableColors.WARNING}🛑 Cancelled{TableColors.RESET}")
return
# Direct mode with schedule ID (single schedule)
elif hasattr(args, 'schedule_id') and args.schedule_id:
schedule_to_remove = cli.schedule_repo.get_by_id(args.schedule_id)
if not schedule_to_remove:
print(f"{TableColors.ERROR}❌ Schedule with ID {args.schedule_id} not found{TableColors.RESET}")
return
schedules_to_remove = [schedule_to_remove]
else:
print(f"{TableColors.ERROR}❌ Either --date or schedule_id must be provided{TableColors.RESET}")
return
if schedule_to_accept.Status == ScheduleStatus.DECLINED.value:
print(f"⚠️ Schedule {schedule_to_accept.ScheduleId} was previously declined")
return
# Process schedule removals
successful_removals = []
failed_removals = []
# 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)
print(f"\n{TableColors.WARNING}Processing {len(schedules_to_remove)} schedule removal(s)...{TableColors.RESET}")
# Mark the schedule as accepted
cli.schedule_repo.mark_accepted(schedule_to_accept.ScheduleId)
for schedule in schedules_to_remove:
# Get member and service info for display
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)
try:
# Delete the schedule
was_deleted = cli.schedule_repo.delete_schedule(schedule.ScheduleId)
if was_deleted:
# Move member back to front of round robin queue
cli.member_repo.reset_to_queue_front(schedule.MemberId)
successful_removals.append((schedule, member, service, service_type))
else:
failed_removals.append((schedule, "schedule not found"))
except Exception as e:
failed_removals.append((schedule, f"database error: {e}"))
# Update member's acceptance timestamp
cli.member_repo.set_last_accepted(schedule_to_accept.MemberId)
# Display results
if successful_removals:
print(f"\n{TableColors.SUCCESS}✅ Successfully removed {len(successful_removals)} schedule(s):{TableColors.RESET}")
print(f"{TableColors.SUCCESS} Members moved to front of round robin queue:{TableColors.RESET}")
for schedule, member, service, service_type in successful_removals:
member_name = f"{member.FirstName} {member.LastName}" if member else f"Member ID {schedule.MemberId}"
service_info = f"{service_type.TypeName} on {service.ServiceDate}" if service and service_type else f"Service ID {schedule.ServiceId}"
print(f" {TableColors.CYAN}{TableColors.RESET} {TableColors.BOLD}{member_name}{TableColors.RESET} - {service_info}")
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}")
if failed_removals:
print(f"\n{TableColors.ERROR}❌ Could not remove {len(failed_removals)} schedule(s):{TableColors.RESET}")
for schedule, reason in failed_removals:
member = cli.member_repo.get_by_id(schedule.MemberId)
member_name = f"{member.FirstName} {member.LastName}" if member else f"Member ID {schedule.MemberId}"
print(f" {TableColors.YELLOW}{TableColors.RESET} {TableColors.BOLD}{member_name}{TableColors.RESET} - {reason}")
if not successful_removals and not failed_removals:
print(f"{TableColors.DIM}No schedules processed{TableColors.RESET}")
def cmd_schedules_decline(cli: "NimbusFlowCLI", args) -> None:
@@ -257,15 +529,18 @@ def cmd_schedules_decline(cli: "NimbusFlowCLI", args) -> None:
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)
print(f"\n{TableColors.HEADER}Services available on {args.date}{TableColors.RESET}")
print(f"{TableColors.BORDER}" * 50 + f"{TableColors.RESET}")
print()
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})")
print(f" {TableColors.CYAN}{i}.{TableColors.RESET} {type_name}")
print()
# Let user select service
try:
choice = input(f"\nSelect service (1-{len(services_on_date)}): ").strip()
print(f"\n{TableColors.INPUT_BOX}┌─ Select service (1-{len(services_on_date)}) ─┐{TableColors.RESET}")
choice = input(f"{TableColors.INPUT_BOX}└─> {TableColors.RESET}").strip()
if not choice or not choice.isdigit():
print("❌ Invalid selection")
return
@@ -280,44 +555,54 @@ def cmd_schedules_decline(cli: "NimbusFlowCLI", args) -> None:
print("\n🛑 Cancelled")
return
# Find pending schedules for this service
# Clear screen after service selection
print("\033[2J\033[H")
# 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
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):
service_name = service_type_map.get(selected_service.ServiceTypeId, 'Unknown')
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(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"{i}. {member.FirstName} {member.LastName} (Schedule ID: {schedule.ScheduleId})")
print(f" {TableColors.CYAN}{i}.{TableColors.RESET} {member.FirstName} {member.LastName} {TableColors.DIM}({status_text}){TableColors.RESET}")
else:
print(f"{i}. Unknown Member (ID: {schedule.MemberId}) (Schedule ID: {schedule.ScheduleId})")
print(f" {TableColors.CYAN}{i}.{TableColors.RESET} {TableColors.DIM}Unknown Member (ID: {schedule.MemberId}) ({status_text}){TableColors.RESET}")
print()
# Let user select member
try:
choice = input(f"\nSelect member to decline (1-{len(pending_schedules)}): ").strip()
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
@@ -353,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)
@@ -364,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:
@@ -405,15 +704,18 @@ def cmd_schedules_schedule(cli: "NimbusFlowCLI", args) -> None:
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)
print(f"\n{TableColors.HEADER}Services available on {args.date}{TableColors.RESET}")
print(f"{TableColors.BORDER}" * 50 + f"{TableColors.RESET}")
print()
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})")
print(f" {TableColors.CYAN}{i}.{TableColors.RESET} {type_name}")
print()
# Let user select service
try:
choice = input(f"\nSelect service (1-{len(services_on_date)}): ").strip()
print(f"\n{TableColors.INPUT_BOX}┌─ Select service (1-{len(services_on_date)}) ─┐{TableColors.RESET}")
choice = input(f"{TableColors.INPUT_BOX}└─> {TableColors.RESET}").strip()
if not choice or not choice.isdigit():
print("❌ Invalid selection")
return
@@ -427,6 +729,9 @@ def cmd_schedules_schedule(cli: "NimbusFlowCLI", args) -> None:
except (KeyboardInterrupt, EOFError):
print("\n🛑 Operation cancelled")
return
# Clear screen after service selection
print("\033[2J\033[H")
elif hasattr(args, 'service_id') and args.service_id:
# Service ID based selection
@@ -444,7 +749,11 @@ def cmd_schedules_schedule(cli: "NimbusFlowCLI", args) -> None:
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}")
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 = []
@@ -463,13 +772,17 @@ def cmd_schedules_schedule(cli: "NimbusFlowCLI", args) -> None:
else:
# If no classifications specified, ask user to select
all_classifications = cli.classification_repo.list_all()
print("\n🎵 Available classifications:")
print(f"\n{TableColors.HEADER}Available classifications{TableColors.RESET}")
print(f"{TableColors.BORDER}" * 50 + f"{TableColors.RESET}")
print()
for i, classification in enumerate(all_classifications, 1):
print(f" {i}. {classification.ClassificationName}")
print(f" {TableColors.CYAN}{i}.{TableColors.RESET} {classification.ClassificationName}")
print()
while True:
try:
choice = input(f"\n🎯 Select classification(s) (1-{len(all_classifications)}, comma-separated): ").strip()
print(f"\n{TableColors.INPUT_BOX}┌─ Select classification(s) (1-{len(all_classifications)}, comma-separated) ─┐{TableColors.RESET}")
choice = input(f"{TableColors.INPUT_BOX}└─> {TableColors.RESET}").strip()
if not choice:
continue
@@ -489,7 +802,8 @@ def cmd_schedules_schedule(cli: "NimbusFlowCLI", args) -> None:
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)}")
print(f"\n{TableColors.SUCCESS}Looking for eligible members in: {', '.join(selected_classifications)}{TableColors.RESET}")
print()
excluded_members = set()
@@ -512,12 +826,15 @@ def cmd_schedules_schedule(cli: "NimbusFlowCLI", args) -> None:
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})")
print(f"\n{TableColors.HEADER}Next available member{TableColors.RESET}")
print(f"{TableColors.BORDER}" * 50 + f"{TableColors.RESET}")
print(f" {TableColors.CYAN}{first_name} {last_name}{TableColors.RESET}")
print()
# Confirm scheduling
try:
confirm = input(f"\n🤔 Schedule {first_name} {last_name} for this service? (y/N/q to quit): ").strip().lower()
print(f"\n{TableColors.INPUT_BOX}┌─ Schedule {first_name} {last_name} for this service? (y/N/q to quit) ─┐{TableColors.RESET}")
confirm = input(f"{TableColors.INPUT_BOX}└─> {TableColors.RESET}").strip().lower()
if confirm in ['y', 'yes']:
# Actually create the schedule
@@ -530,27 +847,159 @@ def cmd_schedules_schedule(cli: "NimbusFlowCLI", args) -> None:
if result:
scheduled_member_id, scheduled_first, scheduled_last, schedule_id = result
print(f"\nSuccessfully scheduled {scheduled_first} {scheduled_last}!")
print(f" 📋 Schedule ID: {schedule_id}")
print(f" 📧 Status: Pending (awaiting member response)")
print(f"\n{TableColors.SUCCESS}Successfully scheduled {scheduled_first} {scheduled_last}!{TableColors.RESET}")
print(f"{TableColors.DIM}Schedule ID: {schedule_id}{TableColors.RESET}")
print(f"{TableColors.DIM}Status: Pending (awaiting member response){TableColors.RESET}")
else:
print("❌ Failed to create schedule. The member may no longer be eligible.")
return
elif confirm in ['q', 'quit']:
print("🛑 Scheduling cancelled")
print(f"\n{TableColors.WARNING}Scheduling cancelled{TableColors.RESET}")
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...")
print(f"\n{TableColors.DIM}Skipping {first_name} {last_name}, looking for next member...{TableColors.RESET}")
print()
except (KeyboardInterrupt, EOFError):
print("\n🛑 Operation cancelled")
print(f"\n{TableColors.WARNING}Operation cancelled{TableColors.RESET}")
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
@@ -577,8 +1026,14 @@ def setup_schedules_parser(subparsers) -> None:
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 remove
schedules_remove_parser = schedules_subparsers.add_parser("remove", help="Remove scheduled members and move them to front of queue")
schedules_remove_parser.add_argument("schedule_id", type=int, nargs="?", help="Schedule ID to remove (optional if using --date)")
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

@@ -3,6 +3,7 @@ Interactive CLI interface for NimbusFlow.
"""
import sys
import time
from pathlib import Path
from typing import TYPE_CHECKING
@@ -11,7 +12,7 @@ if TYPE_CHECKING:
from .commands import (
cmd_members_list, cmd_members_show,
cmd_schedules_list, cmd_schedules_show, cmd_schedules_accept, cmd_schedules_decline, cmd_schedules_schedule,
cmd_schedules_list, cmd_schedules_show, cmd_schedules_accept, cmd_schedules_decline, cmd_schedules_remove, cmd_schedules_schedule,
cmd_services_list,
)
@@ -35,6 +36,13 @@ class Colors:
ERROR = '\033[1m\033[91m' # Bold Red
WARNING = '\033[1m\033[93m' # Bold Yellow
INPUT_BOX = '\033[90m' # Grey
# Gold shimmer colors
GOLD_DARK = '\033[38;5;130m' # Dark gold
GOLD_MEDIUM = '\033[38;5;178m' # Medium gold
GOLD_BRIGHT = '\033[38;5;220m' # Bright gold
GOLD_SHINE = '\033[1m\033[38;5;226m' # Bright shining gold
GOLD_WHITE = '\033[1m\033[97m' # Bright white for peak shine
def create_input_box(prompt: str, width: int = 60) -> str:
@@ -61,27 +69,77 @@ def clear_screen():
print("\033[2J\033[H")
def get_shimmer_color(position: int, shimmer_center: int, shimmer_width: int = 8) -> str:
"""Get the appropriate shimmer color based on distance from shimmer center."""
distance = abs(position - shimmer_center)
if distance == 0:
return Colors.GOLD_WHITE
elif distance == 1:
return Colors.GOLD_SHINE
elif distance <= 3:
return Colors.GOLD_BRIGHT
elif distance <= 5:
return Colors.GOLD_MEDIUM
elif distance <= shimmer_width:
return Colors.GOLD_DARK
else:
return Colors.GOLD_DARK
def animate_nimbusflow_text() -> None:
"""Animate the NimbusFlow ASCII text and frame with a gold shimmer effect."""
# Complete welcome screen lines including borders
welcome_lines = [
"╔════════════════════════════════════════════════════════════════════════════════════════╗",
"║ ║",
"║ ███╗ ██╗██╗███╗ ███╗██████╗ ██╗ ██╗███████╗███████╗██╗ ██████╗ ██╗ ██╗ ║",
"║ ████╗ ██║██║████╗ ████║██╔══██╗██║ ██║██╔════╝██╔════╝██║ ██╔═══██╗██║ ██║ ║",
"║ ██╔██╗ ██║██║██╔████╔██║██████╔╝██║ ██║███████╗█████╗ ██║ ██║ ██║██║ █╗ ██║ ║",
"║ ██║╚██╗██║██║██║╚██╔╝██║██╔══██╗██║ ██║╚════██║██╔══╝ ██║ ██║ ██║██║███╗██║ ║",
"║ ██║ ╚████║██║██║ ╚═╝ ██║██████╔╝╚██████╔╝███████║██║ ███████╗╚██████╔╝╚███╔███╔╝ ║",
"║ ╚═╝ ╚═══╝╚═╝╚═╝ ╚═╝╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚══════╝ ╚═════╝ ╚══╝╚══╝ ║",
"║ ║",
"╚════════════════════════════════════════════════════════════════════════════════════════╝"
]
# Calculate max width for animation
max_width = max(len(line) for line in welcome_lines)
# Animation parameters
shimmer_width = 12
total_steps = max_width + shimmer_width * 2
step_delay = 0.025 # Seconds between frames (even faster animation)
# Animate the shimmer effect
for step in range(total_steps):
shimmer_center = step - shimmer_width
# Move cursor up to overwrite previous frame (10 lines total)
if step > 0:
print(f"\033[{len(welcome_lines)}A", end="")
for line in welcome_lines:
for i, char in enumerate(line):
if char.isspace():
print(char, end="")
else:
color = get_shimmer_color(i, shimmer_center, shimmer_width)
print(f"{color}{char}{Colors.RESET}", end="")
print() # New line after each row
# Add a small delay for animation
time.sleep(step_delay)
def display_welcome():
"""Display welcome screen."""
"""Display welcome screen with animated shimmer effect."""
print("\033[2J\033[H") # Clear screen and move cursor to top
# NimbusFlow branding
welcome_text = """
╔════════════════════════════════════════════════════════════════════════════════════════════╗
║ ║
║ ███╗ ██╗██╗███╗ ███╗██████╗ ██╗ ██╗███████╗ ███████╗██╗ ██████╗ ██╗ ██╗ ║
║ ████╗ ██║██║████╗ ████║██╔══██╗██║ ██║██╔════╝ ██╔════╝██║ ██╔═══██╗██║ ██║ ║
║ ██╔██╗ ██║██║██╔████╔██║██████╔╝██║ ██║███████╗ █████╗ ██║ ██║ ██║██║ █╗ ██║ ║
║ ██║╚██╗██║██║██║╚██╔╝██║██╔══██╗██║ ██║╚════██║ ██╔══╝ ██║ ██║ ██║██║███╗██║ ║
║ ██║ ╚████║██║██║ ╚═╝ ██║██████╔╝╚██████╔╝███████║ ██║ ███████╗╚██████╔╝╚███╔███╔╝ ║
║ ╚═╝ ╚═══╝╚═╝╚═╝ ╚═╝╚═════╝ ╚═════╝ ╚══════╝ ╚═╝ ╚══════╝ ╚═════╝ ╚══╝╚══╝ ║
║ ║
║ 🎵 Scheduling System 🎵 ║
╚════════════════════════════════════════════════════════════════════════════════════════════╝
"""
print(welcome_text)
print() # Add some top padding
animate_nimbusflow_text()
print()
@@ -115,15 +173,12 @@ def display_schedules_menu():
print(f"\n{Colors.HEADER}Schedules{Colors.RESET}")
print(f"{Colors.GREY}" * 50 + f"{Colors.RESET}")
print()
print(f" {Colors.CYAN}1.{Colors.RESET} List all schedules")
print(f" {Colors.CYAN}2.{Colors.RESET} List pending schedules")
print(f" {Colors.CYAN}3.{Colors.RESET} List accepted schedules")
print(f" {Colors.CYAN}4.{Colors.RESET} List declined schedules")
print(f" {Colors.CYAN}5.{Colors.RESET} Show schedule details")
print(f" {Colors.CYAN}6.{Colors.RESET} {Colors.GREEN}Accept a schedule{Colors.RESET}")
print(f" {Colors.CYAN}7.{Colors.RESET} {Colors.RED}Decline a schedule{Colors.RESET}")
print(f" {Colors.CYAN}8.{Colors.RESET} {Colors.YELLOW}Schedule next member for service{Colors.RESET}")
print(f" {Colors.CYAN}9.{Colors.RESET} {Colors.DIM}Back to main menu{Colors.RESET}")
print(f" {Colors.CYAN}1.{Colors.RESET} Browse schedules")
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 member for service{Colors.RESET}")
print(f" {Colors.CYAN}6.{Colors.RESET} {Colors.DIM}Back to main menu{Colors.RESET}")
print()
@@ -193,6 +248,25 @@ def get_date_input(prompt: str = "Enter date (YYYY-MM-DD)") -> str:
return ""
def get_date_input_optional(prompt: str = "Enter date (YYYY-MM-DD)") -> str:
"""Get optional date input from user (allows empty input)."""
while True:
try:
print(create_simple_input_box(prompt))
date_str = input(f"{Colors.INPUT_BOX}└─> {Colors.RESET}").strip()
if not date_str:
return "" # Allow empty input
# Basic date format validation
if len(date_str) == 10 and date_str.count('-') == 2:
parts = date_str.split('-')
if len(parts[0]) == 4 and len(parts[1]) == 2 and len(parts[2]) == 2:
return date_str
print(f"{Colors.ERROR}Please use format YYYY-MM-DD (e.g., 2025-09-07){Colors.RESET}")
except (KeyboardInterrupt, EOFError):
print(f"\n{Colors.WARNING}Operation cancelled{Colors.RESET}")
return ""
class MockArgs:
"""Mock args object for interactive commands."""
def __init__(self, **kwargs):
@@ -248,73 +322,125 @@ def handle_schedules_menu(cli: "NimbusFlowCLI"):
while True:
clear_screen()
display_schedules_menu()
choice = get_user_choice(9)
choice = get_user_choice(6)
if choice == 1: # List all schedules
clear_screen()
print(f"{Colors.SUCCESS}Listing all schedules...{Colors.RESET}\n")
cmd_schedules_list(cli, MockArgs(service_id=None, status=None))
input(f"\n{Colors.DIM}Press Enter to continue...{Colors.RESET}")
elif choice == 2: # List pending schedules
clear_screen()
print(f"{Colors.WARNING}Listing pending schedules...{Colors.RESET}\n")
cmd_schedules_list(cli, MockArgs(service_id=None, status="pending"))
input(f"\n{Colors.DIM}Press Enter to continue...{Colors.RESET}")
elif choice == 3: # List accepted schedules
clear_screen()
print(f"{Colors.SUCCESS}Listing accepted schedules...{Colors.RESET}\n")
cmd_schedules_list(cli, MockArgs(service_id=None, status="accepted"))
input(f"\n{Colors.DIM}Press Enter to continue...{Colors.RESET}")
elif choice == 4: # List declined schedules
clear_screen()
print(f"{Colors.ERROR}Listing declined schedules...{Colors.RESET}\n")
cmd_schedules_list(cli, MockArgs(service_id=None, status="declined"))
input(f"\n{Colors.DIM}Press Enter to continue...{Colors.RESET}")
elif choice == 5: # Show schedule details
clear_screen()
schedule_id = get_text_input("Enter schedule ID", True)
if schedule_id.isdigit():
# Get date filter
date = get_date_input_optional("Enter date to filter schedules (or press Enter to skip)")
if not date:
clear_screen()
print(f"{Colors.SUCCESS}Showing details for schedule {schedule_id}...{Colors.RESET}\n")
cmd_schedules_show(cli, MockArgs(schedule_id=int(schedule_id)))
cmd_schedules_list(cli, MockArgs(service_id=None, status=None))
else:
print(f"{Colors.ERROR}Invalid schedule ID{Colors.RESET}")
# Find services for the date
try:
from datetime import date as date_type
target_date = date_type.fromisoformat(date)
all_services = cli.service_repo.list_all()
services_on_date = [s for s in all_services if s.ServiceDate == target_date]
except ValueError:
clear_screen()
print(f"{Colors.ERROR}Invalid date format. Please use YYYY-MM-DD format.{Colors.RESET}")
services_on_date = []
if not services_on_date:
clear_screen()
print(f"{Colors.ERROR}No services found for {date}{Colors.RESET}")
else:
clear_screen()
# Show available services for selection
service_type_map = {st.ServiceTypeId: st.TypeName for st in cli.service_type_repo.list_all()}
print(f"\n{Colors.HEADER}Services available on {date}{Colors.RESET}")
print(f"{Colors.GREY}" * 50 + f"{Colors.RESET}")
print()
for i, service in enumerate(services_on_date, 1):
type_name = service_type_map.get(service.ServiceTypeId, "Unknown")
print(f" {Colors.CYAN}{i}.{Colors.RESET} {type_name}")
print()
# Get service selection
try:
print(create_simple_input_box(f"Select service (1-{len(services_on_date)}) or press Enter to show all"))
choice_input = input(f"{Colors.INPUT_BOX}└─> {Colors.RESET}").strip()
if not choice_input:
# Empty input - show all services for this date
clear_screen()
cmd_schedules_list(cli, MockArgs(service_id=None, status=None, date=date))
elif not choice_input.isdigit():
print(f"{Colors.ERROR}Invalid selection{Colors.RESET}")
else:
service_choice = int(choice_input)
if service_choice < 1 or service_choice > len(services_on_date):
print(f"{Colors.ERROR}Please enter a number between 1 and {len(services_on_date)}{Colors.RESET}")
else:
clear_screen()
selected_service = services_on_date[service_choice - 1]
cmd_schedules_list(cli, MockArgs(service_id=selected_service.ServiceId, status=None))
except (KeyboardInterrupt, EOFError):
print(f"\n{Colors.WARNING}Operation cancelled{Colors.RESET}")
input(f"\n{Colors.DIM}Press Enter to continue...{Colors.RESET}")
elif choice == 6: # Accept schedule
elif choice == 2: # Accept schedule
clear_screen()
date = get_date_input("Enter date for interactive accept")
if date:
clear_screen()
print(f"{Colors.SUCCESS}Starting interactive accept for {date}...{Colors.RESET}\n")
cmd_schedules_accept(cli, MockArgs(date=date, schedule_id=None))
input(f"\n{Colors.DIM}Press Enter to continue...{Colors.RESET}")
elif choice == 7: # Decline schedule
elif choice == 3: # Decline schedule
clear_screen()
date = get_date_input("Enter date for interactive decline")
if date:
clear_screen()
reason = get_text_input("Enter decline reason (optional)", False)
clear_screen()
print(f"{Colors.ERROR}Starting interactive decline for {date}...{Colors.RESET}\n")
cmd_schedules_decline(cli, MockArgs(date=date, schedule_id=None, reason=reason))
input(f"\n{Colors.DIM}Press Enter to continue...{Colors.RESET}")
elif choice == 8: # Schedule next member
elif choice == 4: # Remove scheduled members
clear_screen()
date = get_date_input("Enter date to remove schedules for")
if date:
clear_screen()
cmd_schedules_remove(cli, MockArgs(date=date, schedule_id=None))
input(f"\n{Colors.DIM}Press Enter to continue...{Colors.RESET}")
elif choice == 5: # Schedule next member
clear_screen()
date = get_date_input("Enter date to schedule for")
if date:
clear_screen()
print(f"{Colors.WARNING}Starting scheduling for {date}...{Colors.RESET}\n")
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 == 9: # Back to main menu
elif choice == 6: # Back to main menu
break
@@ -355,7 +481,7 @@ def run_interactive_mode(cli: "NimbusFlowCLI"):
display_welcome()
print(f"{Colors.HEADER}Welcome to the NimbusFlow Interactive CLI{Colors.RESET}")
print(f"{Colors.DIM}Navigate through menus to manage your choir scheduling system.{Colors.RESET}")
print(f"{Colors.DIM}Navigate through menus to manage your scheduling system.{Colors.RESET}")
print()
input(f"{Colors.DIM}Press Enter to continue...{Colors.RESET}")

View File

@@ -11,7 +11,7 @@ from .commands import (
cmd_members_list, cmd_members_show, setup_members_parser,
# Schedule commands
cmd_schedules_list, cmd_schedules_show, cmd_schedules_accept,
cmd_schedules_decline, cmd_schedules_schedule, setup_schedules_parser,
cmd_schedules_decline, cmd_schedules_remove, cmd_schedules_schedule, setup_schedules_parser,
# Service commands
cmd_services_list, setup_services_parser,
)
@@ -44,16 +44,30 @@ def main():
if not args.command:
# Launch interactive mode when no command is provided
try:
cli = NimbusFlowCLI()
cli = NimbusFlowCLI(create_version=True) # Always create versioned DB for interactive mode
# Show versioning info with colors
from .base import Colors
versions = cli.list_database_versions()
if len(versions) > 1:
print(f"{Colors.CYAN}Database versions available:{Colors.RESET} {Colors.SUCCESS}{len(versions)}{Colors.RESET}")
print(f"{Colors.CYAN}Using:{Colors.RESET} {Colors.CYAN}{cli.db_path.name}{Colors.RESET}")
# Auto-cleanup if too many versions
if len(versions) > 10:
deleted = cli.cleanup_old_versions(keep_latest=5)
if deleted > 0:
print(f"{Colors.WARNING}Cleaned up {deleted} old database versions{Colors.RESET}")
run_interactive_mode(cli)
except CLIError as e:
print(f"❌ Error: {e}")
print(f"{Colors.ERROR}❌ Error: {e}{Colors.RESET}")
return 1
except KeyboardInterrupt:
print("\n🛑 Interrupted by user")
print(f"\n{Colors.WARNING}🛑 Interrupted by user{Colors.RESET}")
return 1
except Exception as e:
print(f"❌ Unexpected error: {e}")
print(f"{Colors.ERROR}❌ Unexpected error: {e}{Colors.RESET}")
return 1
finally:
if 'cli' in locals():
@@ -61,7 +75,11 @@ def main():
return
try:
cli = NimbusFlowCLI()
cli = NimbusFlowCLI(create_version=False) # Don't version for regular CLI commands
# Show which database is being used for regular commands
from .base import Colors
print(f"{Colors.CYAN}Using database:{Colors.RESET} {Colors.CYAN}{cli.db_path.name}{Colors.RESET}")
# Route commands to their respective handlers
if args.command == "members":
@@ -70,7 +88,7 @@ def main():
elif args.members_action == "show":
cmd_members_show(cli, args)
else:
print("❌ Unknown members action. Use 'list' or 'show'")
print(f"{Colors.ERROR}❌ Unknown members action. Use 'list' or 'show'{Colors.RESET}")
elif args.command == "schedules":
if args.schedules_action == "list":
@@ -81,28 +99,30 @@ def main():
cmd_schedules_accept(cli, args)
elif args.schedules_action == "decline":
cmd_schedules_decline(cli, args)
elif args.schedules_action == "remove":
cmd_schedules_remove(cli, args)
elif args.schedules_action == "schedule":
cmd_schedules_schedule(cli, args)
else:
print("❌ Unknown schedules action. Use 'list', 'show', 'accept', 'decline', or 'schedule'")
print(f"{Colors.ERROR}❌ Unknown schedules action. Use 'list', 'show', 'accept', 'decline', 'remove', or 'schedule'{Colors.RESET}")
elif args.command == "services":
if args.services_action == "list":
cmd_services_list(cli, args)
else:
print("❌ Unknown services action. Use 'list'")
print(f"{Colors.ERROR}❌ Unknown services action. Use 'list'{Colors.RESET}")
else:
print(f"❌ Unknown command: {args.command}")
print(f"{Colors.ERROR}❌ Unknown command: {args.command}{Colors.RESET}")
except CLIError as e:
print(f"❌ Error: {e}")
print(f"{Colors.ERROR}❌ Error: {e}{Colors.RESET}")
return 1
except KeyboardInterrupt:
print("\n🛑 Interrupted by user")
print(f"\n{Colors.WARNING}🛑 Interrupted by user{Colors.RESET}")
return 1
except Exception as e:
print(f"❌ Unexpected error: {e}")
print(f"{Colors.ERROR}❌ Unexpected error: {e}{Colors.RESET}")
return 1
finally:
if 'cli' in locals():

View File

@@ -25,6 +25,7 @@ class TableColors:
ERROR = '\033[1m\033[91m' # Bold Red
WARNING = '\033[1m\033[93m' # Bold Yellow
BORDER = '\033[90m' # Grey
INPUT_BOX = '\033[90m' # Grey (for input styling)
def format_member_row(member, classification_name: Optional[str] = None) -> str:

View File

@@ -1,524 +0,0 @@
import datetime as dt
from typing import Optional, Tuple, List
from backend.db.connection import DatabaseConnection
from backend.models import (
Classification,
Member,
ServiceType,
Service,
ServiceAvailability,
Schedule,
AcceptedLog,
DeclineLog,
ScheduledLog,
)
class Repository:
"""
Highlevel dataaccess layer.
Responsibilities
----------------
* CRUD helpers for the core tables.
* Roundrobin queue that respects:
- Members.LastAcceptedAt (fair order)
- Members.LastDeclinedAt (oneday cooloff)
* “Reservation” handling using the **Schedules** table
(pending → accepted → declined).
* Audit logging (AcceptedLog, DeclineLog, ScheduledLog).
"""
def __init__(self, db: DatabaseConnection):
self.db = db
# -----------------------------------------------------------------
# CRUD helpers they now return model objects (or IDs)
# -----------------------------------------------------------------
# -----------------------------------------------------------------
# CREATE
# -----------------------------------------------------------------
def create_classification(self, classification_name: str) -> Classification:
"""Insert a new classification and return the saved model."""
classification = Classification(
ClassificationId=-1, # placeholder will be replaced by DB
ClassificationName=classification_name,
)
# Build INSERT statement from the dataclass dict (skip PK)
data = classification.to_dict()
data.pop("ClassificationId") # AUTOINCREMENT column
cols = ", ".join(data.keys())
placeholders = ", ".join("?" for _ in data)
sql = f"INSERT INTO Classifications ({cols}) VALUES ({placeholders})"
self.db.execute(sql, tuple(data.values()))
classification.ClassificationId = self.db.lastrowid
return classification
def create_member(
self,
first_name: str,
last_name: str,
email: Optional[str] = None,
phone_number: Optional[str] = None,
classification_id: Optional[int] = None,
notes: Optional[str] = None,
is_active: int = 1,
) -> Member:
"""Insert a new member and return the saved model."""
member = Member(
MemberId=-1,
FirstName=first_name,
LastName=last_name,
Email=email,
PhoneNumber=phone_number,
ClassificationId=classification_id,
Notes=notes,
IsActive=is_active,
LastAcceptedAt=None,
LastDeclinedAt=None,
)
data = member.to_dict()
data.pop("MemberId") # let SQLite fill the PK
cols = ", ".join(data.keys())
placeholders = ", ".join("?" for _ in data)
sql = f"INSERT INTO Members ({cols}) VALUES ({placeholders})"
self.db.execute(sql, tuple(data.values()))
member.MemberId = self.db.lastrowid
return member
def create_service_type(self, type_name: str) -> ServiceType:
"""Insert a new service type."""
st = ServiceType(ServiceTypeId=-1, TypeName=type_name)
data = st.to_dict()
data.pop("ServiceTypeId")
cols = ", ".join(data.keys())
placeholders = ", ".join("?" for _ in data)
sql = f"INSERT INTO ServiceTypes ({cols}) VALUES ({placeholders})"
self.db.execute(sql, tuple(data.values()))
st.ServiceTypeId = self.db.lastrowid
return st
def create_service(self, service_type_id: int, service_date: dt.date) -> Service:
"""Insert a new service row (date + type)."""
sv = Service(ServiceId=-1, ServiceTypeId=service_type_id, ServiceDate=service_date)
data = sv.to_dict()
data.pop("ServiceId")
cols = ", ".join(data.keys())
placeholders = ", ".join("?" for _ in data)
sql = f"INSERT INTO Services ({cols}) VALUES ({placeholders})"
self.db.execute(sql, tuple(data.values()))
sv.ServiceId = self.db.lastrowid
return sv
def create_service_availability(self, member_id: int, service_type_id: int) -> ServiceAvailability:
"""Link a member to a service type (availability matrix)."""
sa = ServiceAvailability(
ServiceAvailabilityId=-1,
MemberId=member_id,
ServiceTypeId=service_type_id,
)
data = sa.to_dict()
data.pop("ServiceAvailabilityId")
cols = ", ".join(data.keys())
placeholders = ", ".join("?" for _ in data)
sql = f"INSERT INTO ServiceAvailability ({cols}) VALUES ({placeholders})"
self.db.execute(sql, tuple(data.values()))
sa.ServiceAvailabilityId = self.db.lastrowid
return sa
# -----------------------------------------------------------------
# READ return **lists of models**
# -----------------------------------------------------------------
def get_all_classifications(self) -> List[Classification]:
rows = self.db.fetchall("SELECT * FROM Classifications")
return [Classification.from_row(r) for r in rows]
def get_all_members(self) -> List[Member]:
rows = self.db.fetchall("SELECT * FROM Members")
return [Member.from_row(r) for r in rows]
def get_all_service_types(self) -> List[ServiceType]:
rows = self.db.fetchall("SELECT * FROM ServiceTypes")
return [ServiceType.from_row(r) for r in rows]
def get_all_services(self) -> List[Service]:
rows = self.db.fetchall("SELECT * FROM Services")
return [Service.from_row(r) for r in rows]
def get_all_service_availability(self) -> List[ServiceAvailability]:
rows = self.db.fetchall("SELECT * FROM ServiceAvailability")
return [ServiceAvailability.from_row(r) for r in rows]
# -----------------------------------------------------------------
# INTERNAL helpers used by the queue logic
# -----------------------------------------------------------------
def _lookup_classification(self, name: str) -> int:
"""Return ClassificationId for a given name; raise if missing."""
row = self.db.fetchone(
"SELECT ClassificationId FROM Classifications WHERE ClassificationName = ?",
(name,),
)
if row is None:
raise ValueError(f'Classification "{name}" does not exist')
return row["ClassificationId"]
def _ensure_service(self, service_date: dt.date) -> int:
"""
Return a ServiceId for ``service_date``.
If the row does not exist we create a generic Service row
(using the first ServiceType as a default).
"""
row = self.db.fetchone(
"SELECT ServiceId FROM Services WHERE ServiceDate = ?", (service_date,)
)
if row:
return row["ServiceId"]
default_type = self.db.fetchone(
"SELECT ServiceTypeId FROM ServiceTypes LIMIT 1"
)
if not default_type:
raise RuntimeError(
"No ServiceTypes defined cannot create a Service row"
)
self.db.execute(
"INSERT INTO Services (ServiceTypeId, ServiceDate) VALUES (?,?)",
(default_type["ServiceTypeId"], service_date),
)
return self.db.lastrowid
def has_schedule_for_service(
self,
member_id: int,
service_id: int,
status: str,
include_expired: bool = False,
) -> bool:
"""
Return True if the member has a schedule row for the given ``service_id``
with the specified ``status``.
For ``status='pending'`` the default behaviour is to ignore rows whose
``ExpiresAt`` timestamp is already in the past (they are not actionable).
Set ``include_expired=True`` if you deliberately want to see *any* pending
row regardless of its expiration.
Parameters
----------
member_id : int
The member we are inspecting.
service_id : int
The service we are interested in.
status : str
One of the schedule statuses (e.g. ``'accepted'`` or ``'pending'``).
include_expired : bool, optional
When checking for pending rows, ignore the expiration guard if set to
``True``. Defaults to ``False`` (i.e. only nonexpired pending rows
count).
Returns
-------
bool
True if a matching row exists, otherwise False.
"""
sql = """
SELECT 1
FROM Schedules
WHERE MemberId = ?
AND ServiceId = ?
AND Status = ?
"""
args = [member_id, service_id, status]
# Guard against expired pending rows unless the caller explicitly wants them.
if not include_expired and status == "pending":
sql += " AND ExpiresAt > CURRENT_TIMESTAMP"
sql += " LIMIT 1"
row = self.db.fetchone(sql, tuple(args))
return row is not None
def schedule_next_member(
self,
classification_id: int,
service_id: int,
only_active: bool = True,
) -> Optional[Tuple[int, str, str, int]]:
"""
Choose the next member for ``service_id`` while respecting ServiceAvailability.
Ordering (highlevel):
1⃣ 5day decline boost only if DeclineStreak < 2.
2⃣ Oldest LastAcceptedAt (roundrobin).
3⃣ Oldest LastScheduledAt (tiebreaker).
Skipped if any of the following is true:
• Member lacks a ServiceAvailability row for the ServiceType of ``service_id``.
• Member already has an *accepted* schedule for this service.
• Member already has a *pending* schedule for this service.
• Member already has a *declined* schedule for this service.
"""
# -----------------------------------------------------------------
# 0⃣ Resolve ServiceTypeId (and ServiceDate) from the Services table.
# -----------------------------------------------------------------
svc_row = self.db.fetchone(
"SELECT ServiceTypeId, ServiceDate FROM Services WHERE ServiceId = ?",
(service_id,),
)
if not svc_row:
# No such service nothing to schedule.
return None
service_type_id = svc_row["ServiceTypeId"]
# If you need the actual calendar date later you can use:
# service_date = dt.datetime.strptime(svc_row["ServiceDate"], "%Y-%m-%d").date()
# -----------------------------------------------------------------
# 1⃣ Pull the candidate queue, ordered per the existing rules.
# -----------------------------------------------------------------
BOOST_SECONDS = 5 * 24 * 60 * 60 # 5 days
now_iso = dt.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
sql = f"""
SELECT
MemberId,
FirstName,
LastName,
LastAcceptedAt,
LastScheduledAt,
LastDeclinedAt,
DeclineStreak
FROM Members
WHERE ClassificationId = ?
{"AND IsActive = 1" if only_active else ""}
ORDER BY
/* ① 5day boost (only when streak < 2) */
CASE
WHEN DeclineStreak < 2
AND LastDeclinedAt IS NOT NULL
AND julianday(?) - julianday(LastDeclinedAt) <= (? / 86400.0)
THEN 0 -- boosted to the front
ELSE 1
END,
/* ② Roundrobin: oldest acceptance first */
COALESCE(LastAcceptedAt, '1970-01-01') ASC,
/* ③ Tiebreaker: oldest offer first */
COALESCE(LastScheduledAt, '1970-01-01') ASC
"""
queue = self.db.fetchall(sql, (classification_id, now_iso, BOOST_SECONDS))
# -----------------------------------------------------------------
# 2⃣ Walk the ordered queue and apply availability + status constraints.
# -----------------------------------------------------------------
for member in queue:
member_id = member["MemberId"]
# ----- Availability check -------------------------------------------------
# Skip members that do NOT have a row in ServiceAvailability for this
# ServiceType.
avail_ok = self.db.fetchone(
"""
SELECT 1
FROM ServiceAvailability
WHERE MemberId = ?
AND ServiceTypeId = ?
LIMIT 1
""",
(member_id, service_type_id),
)
if not avail_ok:
continue # Not eligible for this service type.
# ----- Status constraints (all by service_id) ----------------------------
# a) Already *accepted* for this service?
if self.has_schedule_for_service(member_id, service_id, status="accepted"):
continue
# b) Existing *pending* reservation for this service?
if self.has_schedule_for_service(member_id, service_id, status="pending"):
continue
# c) Already *declined* this service?
if self.has_schedule_for_service(member_id, service_id, status="declined"):
continue
# -------------------------------------------------------------
# SUCCESS create a pending schedule (minimal columns).
# -------------------------------------------------------------
self.db.execute(
"""
INSERT INTO Schedules
(ServiceId, MemberId, Status)
VALUES
(?,?,?)
""",
(service_id, member_id, "pending"),
)
schedule_id = self.db.lastrowid
# -------------------------------------------------------------
# Update the member's LastScheduledAt so the roundrobin stays fair.
# -------------------------------------------------------------
self.db.execute(
"""
UPDATE Members
SET LastScheduledAt = CURRENT_TIMESTAMP
WHERE MemberId = ?
""",
(member_id,),
)
# -------------------------------------------------------------
# Audit log historic record (no ScheduleId column any more).
# -------------------------------------------------------------
self.db.execute(
"""
INSERT INTO ScheduledLog (MemberId, ServiceId)
VALUES (?,?)
""",
(member_id, service_id),
)
# -------------------------------------------------------------
# Return the useful bits to the caller.
# -------------------------------------------------------------
return (
member_id,
member["FirstName"],
member["LastName"],
schedule_id,
)
# -----------------------------------------------------------------
# No eligible member found.
# -----------------------------------------------------------------
return None
# -----------------------------------------------------------------
# ACCEPT / DECLINE workflow (operates on the schedule row)
# -----------------------------------------------------------------
def accept_schedule(self, schedule_id: int) -> None:
"""
Convert a *pending* schedule into a real assignment.
- Updates the schedule row (status → accepted, timestamp).
- Writes an entry into ``AcceptedLog``.
- Updates ``Members.LastAcceptedAt`` (advances roundrobin) and clears any cooloff.
"""
# Load the pending schedule raise if it does not exist or is not pending
sched = self.db.fetchone(
"""
SELECT ScheduleId, ServiceId, MemberId
FROM Schedules
WHERE ScheduleId = ?
AND Status = 'pending'
""",
(schedule_id,),
)
if not sched:
raise ValueError("Schedule not found or not pending")
service_id = sched["ServiceId"]
member_id = sched["MemberId"]
# 1⃣ Mark the schedule as accepted
self.db.execute(
"""
UPDATE Schedules
SET Status = 'accepted',
AcceptedAt = CURRENT_TIMESTAMP,
ExpiresAt = CURRENT_TIMESTAMP -- no longer expires
WHERE ScheduleId = ?
""",
(schedule_id,),
)
# 2⃣ Audit log
self.db.execute(
"""
INSERT INTO AcceptedLog (MemberId, ServiceId)
VALUES (?,?)
""",
(member_id, service_id),
)
# 3⃣ Advance roundrobin for the member
self.db.execute(
"""
UPDATE Members
SET LastAcceptedAt = CURRENT_TIMESTAMP,
LastDeclinedAt = NULL -- a successful accept clears any cooloff
WHERE MemberId = ?
""",
(member_id,),
)
def decline_schedule(
self, schedule_id: int, reason: Optional[str] = None
) -> None:
"""
Record that the member declined the offered slot.
Effects
-------
* Inserts a row into ``DeclineLog`` (with the service day).
* Updates ``Members.LastDeclinedAt`` this implements the oneday cooloff.
* Marks the schedule row as ``declined`` (so it can be offered to someone else).
"""
# Load the pending schedule raise if not found / not pending
sched = self.db.fetchone(
"""
SELECT ScheduleId, ServiceId, MemberId
FROM Schedules
WHERE ScheduleId = ?
AND Status = 'pending'
""",
(schedule_id,),
)
if not sched:
raise ValueError("Schedule not found or not pending")
service_id = sched["ServiceId"]
member_id = sched["MemberId"]
# Need the service *day* for the oneday cooloff
svc = self.db.fetchone(
"SELECT ServiceDate FROM Services WHERE ServiceId = ?", (service_id,)
)
if not svc:
raise RuntimeError("Service row vanished while processing decline")
service_day = svc["ServiceDate"] # stored as TEXT 'YYYYMMDD'
# 1⃣ Insert into DeclineLog
self.db.execute(
"""
INSERT INTO DeclineLog (MemberId, ServiceId, DeclineDate, Reason)
VALUES (?,?,?,?)
""",
(member_id, service_id, service_day, reason),
)
# 2⃣ Update the member's cooloff day
self.db.execute(
"""
UPDATE Members
SET LastDeclinedAt = ?
WHERE MemberId = ?
""",
(service_day, member_id),
)
# 3⃣ Mark the schedule row as declined
self.db.execute(
"""
UPDATE Schedules
SET Status = 'declined',
DeclinedAt = CURRENT_TIMESTAMP,
DeclineReason = ?
WHERE ScheduleId = ?
""",
(reason, schedule_id),
)

View File

@@ -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_declines3.db"
DB_PATH = Path(__file__).parent / "db" / "sqlite" / "database.db"
# Initialise DB connection (adjust DSN as needed)
db = DatabaseConnection(DB_PATH)

View File

@@ -234,4 +234,20 @@ class MemberRepository(BaseRepository[MemberModel]):
DeclineStreak = COALESCE(DeclineStreak, 0) + 1
WHERE {self._PK} = ?
"""
self.db.execute(sql, (decline_date, member_id))
self.db.execute(sql, (decline_date, member_id))
def reset_to_queue_front(self, member_id: int) -> None:
"""
Reset member timestamps to move them to the front of the round robin queue.
This sets LastScheduledAt and LastAcceptedAt to far past values, effectively
making them the highest priority for scheduling.
"""
sql = f"""
UPDATE {self._TABLE}
SET LastScheduledAt = '1970-01-01 00:00:00',
LastAcceptedAt = '1970-01-01 00:00:00',
LastDeclinedAt = NULL,
DeclineStreak = 0
WHERE {self._PK} = ?
"""
self.db.execute(sql, (member_id,))

View File

@@ -48,12 +48,17 @@ class ScheduleRepository(BaseRepository[ScheduleModel]):
scheduled_at, expires_at : datetimecompatible | None
``scheduled_at`` defaults to SQLites ``CURRENT_TIMESTAMP``.
"""
# Handle timestamp - use actual datetime if not provided
import datetime
if scheduled_at is None:
scheduled_at = datetime.datetime.now(datetime.UTC).isoformat()
schedule = ScheduleModel(
ScheduleId=-1, # placeholder will be replaced
ServiceId=service_id,
MemberId=member_id,
Status=status.value,
ScheduledAt=scheduled_at or "CURRENT_TIMESTAMP",
ScheduledAt=scheduled_at,
AcceptedAt=None,
DeclinedAt=None,
ExpiresAt=expires_at,
@@ -110,8 +115,8 @@ class ScheduleRepository(BaseRepository[ScheduleModel]):
params: list[Any] = [new_status.value]
if new_status == ScheduleStatus.DECLINED:
set_clause = "Status = ?, DeclinedAt = ?, DeclineReason = ?"
params.extend(["CURRENT_TIMESTAMP", reason])
set_clause = "Status = ?, DeclinedAt = datetime('now'), DeclineReason = ?"
params.extend([reason])
params.append(schedule_id) # WHERE clause param
@@ -148,43 +153,6 @@ class ScheduleRepository(BaseRepository[ScheduleModel]):
row = self.db.fetchone(sql, params)
return row is not None
def is_available(self, member_id: int, service_id: int) -> bool:
"""
Cooldown rule: a member is unavailable if they have accepted a
schedule for the same service within the last ``COOLDOWN_DAYS``.
"""
# Latest acceptance timestamp (if any)
sql_latest = f"""
SELECT MAX(AcceptedAt) AS last_accept
FROM {self._TABLE}
WHERE MemberId = ?
AND ServiceId = ?
AND Status = ?
"""
row = self.db.fetchone(
sql_latest,
(member_id, service_id, ScheduleStatus.ACCEPTED.value),
)
last_accept: Optional[str] = row["last_accept"] if row else None
if not last_accept:
return True # never accepted → free to schedule
COOLDOWN_DAYS = 1
sql_cooldown = f"""
SELECT 1
FROM {self._TABLE}
WHERE MemberId = ?
AND ServiceId = ?
AND Status = ?
AND DATE(AcceptedAt) >= DATE('now', '-{COOLDOWN_DAYS} day')
LIMIT 1
"""
row = self.db.fetchone(
sql_cooldown,
(member_id, service_id, ScheduleStatus.ACCEPTED.value),
)
return row is None # None → outside the cooldown window
# ------------------------------------------------------------------
# Statustransition helpers (accept / decline) kept for completeness.
@@ -194,16 +162,26 @@ class ScheduleRepository(BaseRepository[ScheduleModel]):
schedule_id: int,
accepted_at: Optional[Any] = None,
) -> None:
sql = f"""
UPDATE {self._TABLE}
SET Status = ?,
AcceptedAt = ?,
DeclinedAt = NULL,
DeclineReason = NULL
WHERE {self._PK} = ?
"""
ts = accepted_at or "CURRENT_TIMESTAMP"
self.db.execute(sql, (ScheduleStatus.ACCEPTED.value, ts, schedule_id))
if accepted_at is None:
sql = f"""
UPDATE {self._TABLE}
SET Status = ?,
AcceptedAt = datetime('now'),
DeclinedAt = NULL,
DeclineReason = NULL
WHERE {self._PK} = ?
"""
self.db.execute(sql, (ScheduleStatus.ACCEPTED.value, schedule_id))
else:
sql = f"""
UPDATE {self._TABLE}
SET Status = ?,
AcceptedAt = ?,
DeclinedAt = NULL,
DeclineReason = NULL
WHERE {self._PK} = ?
"""
self.db.execute(sql, (ScheduleStatus.ACCEPTED.value, accepted_at, schedule_id))
def mark_declined(
self,
@@ -211,27 +189,36 @@ class ScheduleRepository(BaseRepository[ScheduleModel]):
declined_at: Optional[Any] = None,
decline_reason: Optional[str] = None,
) -> None:
sql = f"""
UPDATE {self._TABLE}
SET Status = ?,
DeclinedAt = ?,
DeclineReason = ?
WHERE {self._PK} = ?
"""
ts = declined_at or "CURRENT_TIMESTAMP"
self.db.execute(sql, (ScheduleStatus.DECLINED.value, ts, decline_reason, schedule_id))
if declined_at is None:
sql = f"""
UPDATE {self._TABLE}
SET Status = ?,
DeclinedAt = datetime('now'),
DeclineReason = ?
WHERE {self._PK} = ?
"""
self.db.execute(sql, (ScheduleStatus.DECLINED.value, decline_reason, schedule_id))
else:
sql = f"""
UPDATE {self._TABLE}
SET Status = ?,
DeclinedAt = ?,
DeclineReason = ?
WHERE {self._PK} = ?
"""
self.db.execute(sql, (ScheduleStatus.DECLINED.value, declined_at, decline_reason, schedule_id))
# ------------------------------------------------------------------
# Sameday helper used by the scheduling service
# ------------------------------------------------------------------
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
@@ -239,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
# ------------------------------------------------------------------
@@ -257,8 +245,14 @@ class ScheduleRepository(BaseRepository[ScheduleModel]):
"""
rows = self.db.fetchall(sql, (service_id, ScheduleStatus.PENDING.value))
return [ScheduleModel.from_row(r) for r in rows]
def delete(self, schedule_id: int) -> None:
"""Harddelete a schedule row (use with caution)."""
def delete_schedule(self, schedule_id: int) -> bool:
"""
Delete a schedule by ID.
Returns:
bool: True if a schedule was deleted, False if not found
"""
sql = f"DELETE FROM {self._TABLE} WHERE {self._PK} = ?"
self.db.execute(sql, (schedule_id,))
cursor = self.db.execute(sql, (schedule_id,))
return cursor.rowcount > 0

19
backend/requirements.txt Normal file
View File

@@ -0,0 +1,19 @@
annotated-types==0.7.0
anyio==4.10.0
click==8.2.1
fastapi==0.116.1
h11==0.16.0
httptools==0.6.4
idna==3.10
pydantic==2.11.7
pydantic_core==2.33.2
python-dotenv==1.1.1
PyYAML==6.0.2
sniffio==1.3.1
starlette==0.47.3
typing-inspection==0.4.1
typing_extensions==4.15.0
uvicorn==0.35.0
uvloop==0.21.0
watchfiles==1.1.0
websockets==15.0.1

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

@@ -10,6 +10,7 @@ from backend.db import DatabaseConnection
from backend.repositories import (
MemberRepository,
ClassificationRepository,
ServiceRepository,
ServiceTypeRepository,
ServiceAvailabilityRepository,
)
@@ -156,6 +157,14 @@ def service_type_repo(
return ServiceTypeRepository(db_connection)
@pytest.fixture
def service_repo(
db_connection: DatabaseConnection,
seed_lookup_tables,
) -> ServiceRepository:
return ServiceRepository(db_connection)
@pytest.fixture
def service_availability_repo(
db_connection: DatabaseConnection,

View File

@@ -1,9 +1,10 @@
# backend/tests/repositories/test_classification.py
# ------------------------------------------------------------
# Pytest suite for the ClassificationRepository.
# Comprehensive pytest suite for the ClassificationRepository.
# ------------------------------------------------------------
import pytest
from typing import List
from backend.models import Classification as ClassificationModel
from backend.repositories import ClassificationRepository
@@ -90,4 +91,383 @@ def test_delete(classification_repo):
remaining_names = {r.ClassificationName for r in remaining}
assert "TempVoice" not in remaining_names
# the original four seeded names must still be present
assert {"Soprano", "Alto / Mezzo", "Tenor", "Baritone"} <= remaining_names
assert {"Soprano", "Alto / Mezzo", "Tenor", "Baritone"} <= remaining_names
# ----------------------------------------------------------------------
# 6⃣ Edge cases and error conditions
# ----------------------------------------------------------------------
def test_get_by_id_returns_none_when_missing(classification_repo: ClassificationRepository):
"""Test that get_by_id returns None for nonexistent IDs."""
result = classification_repo.get_by_id(99999)
assert result is None
def test_get_by_id_with_negative_id(classification_repo: ClassificationRepository):
"""Test get_by_id with negative ID (should return None)."""
result = classification_repo.get_by_id(-1)
assert result is None
def test_get_by_id_with_zero_id(classification_repo: ClassificationRepository):
"""Test get_by_id with zero ID (should return None)."""
result = classification_repo.get_by_id(0)
assert result is None
def test_find_by_name_case_sensitivity(classification_repo: ClassificationRepository):
"""Test that find_by_name is case-sensitive."""
# Exact case should work
exact = classification_repo.find_by_name("Soprano")
assert exact is not None
assert exact.ClassificationName == "Soprano"
# Different cases should return None
assert classification_repo.find_by_name("soprano") is None
assert classification_repo.find_by_name("SOPRANO") is None
assert classification_repo.find_by_name("SoPrAnO") is None
def test_find_by_name_with_whitespace(classification_repo: ClassificationRepository):
"""Test find_by_name behavior with whitespace variations."""
# Exact name with spaces should work
exact = classification_repo.find_by_name("Alto / Mezzo")
assert exact is not None
# Names with extra whitespace should return None (no trimming)
assert classification_repo.find_by_name(" Alto / Mezzo") is None
assert classification_repo.find_by_name("Alto / Mezzo ") is None
assert classification_repo.find_by_name(" Alto / Mezzo ") is None
def test_find_by_name_with_empty_string(classification_repo: ClassificationRepository):
"""Test find_by_name with empty string."""
result = classification_repo.find_by_name("")
assert result is None
def test_create_with_empty_string_name(classification_repo: ClassificationRepository):
"""Test creating a classification with empty string name."""
# This should work - empty string is a valid name
empty_name = classification_repo.create("")
assert empty_name.ClassificationName == ""
assert isinstance(empty_name.ClassificationId, int)
assert empty_name.ClassificationId > 0
# Should be able to find it back
found = classification_repo.find_by_name("")
assert found is not None
assert found.ClassificationId == empty_name.ClassificationId
def test_create_with_whitespace_only_name(classification_repo: ClassificationRepository):
"""Test creating a classification with whitespace-only name."""
whitespace_name = classification_repo.create(" ")
assert whitespace_name.ClassificationName == " "
assert isinstance(whitespace_name.ClassificationId, int)
# Should be findable
found = classification_repo.find_by_name(" ")
assert found is not None
assert found.ClassificationId == whitespace_name.ClassificationId
def test_create_with_very_long_name(classification_repo: ClassificationRepository):
"""Test creating a classification with a very long name."""
long_name = "A" * 1000 # 1000 character name
long_classification = classification_repo.create(long_name)
assert long_classification.ClassificationName == long_name
assert isinstance(long_classification.ClassificationId, int)
# Should be findable
found = classification_repo.find_by_name(long_name)
assert found is not None
assert found.ClassificationId == long_classification.ClassificationId
def test_create_with_special_characters(classification_repo: ClassificationRepository):
"""Test creating classifications with special characters."""
special_names = [
"Alto/Soprano",
"Bass-Baritone",
"Counter-tenor (High)",
"Mezzo@Soprano",
"Coloratura Soprano (1st)",
"Basso Profondo & Cantante",
"Soprano (🎵)",
"Tenor - Lyric/Dramatic",
]
created_ids = []
for name in special_names:
classification = classification_repo.create(name)
assert classification.ClassificationName == name
assert isinstance(classification.ClassificationId, int)
created_ids.append(classification.ClassificationId)
# Should be findable
found = classification_repo.find_by_name(name)
assert found is not None
assert found.ClassificationId == classification.ClassificationId
# All IDs should be unique
assert len(set(created_ids)) == len(created_ids)
def test_delete_nonexistent_classification(classification_repo: ClassificationRepository):
"""Test deleting a classification that doesn't exist (should not raise error)."""
initial_count = len(classification_repo.list_all())
# This should not raise an exception
classification_repo.delete(99999)
# Count should remain the same
final_count = len(classification_repo.list_all())
assert final_count == initial_count
def test_delete_with_negative_id(classification_repo: ClassificationRepository):
"""Test delete with negative ID (should not raise error)."""
initial_count = len(classification_repo.list_all())
# This should not raise an exception
classification_repo.delete(-1)
# Count should remain the same
final_count = len(classification_repo.list_all())
assert final_count == initial_count
# ----------------------------------------------------------------------
# 7⃣ Data integrity and consistency tests
# ----------------------------------------------------------------------
def test_list_all_ordering_consistency(classification_repo: ClassificationRepository):
"""Test that list_all always returns results in consistent alphabetical order."""
# Add some classifications with names that test alphabetical ordering
test_names = ["Zebra", "Alpha", "Beta", "Zulu", "Apple", "Banana"]
created = []
for name in test_names:
created.append(classification_repo.create(name))
# Get all classifications multiple times
for _ in range(3):
all_classifications = classification_repo.list_all()
names = [c.ClassificationName for c in all_classifications]
# Should be in alphabetical order
assert names == sorted(names)
# Should contain our test names
for name in test_names:
assert name in names
def test_ensure_exists_idempotency_stress_test(classification_repo: ClassificationRepository):
"""Test that ensure_exists is truly idempotent under multiple calls."""
name = "StressTestClassification"
# Call ensure_exists multiple times
results = []
for _ in range(10):
result = classification_repo.ensure_exists(name)
results.append(result)
# All results should be the same object (same ID)
first_id = results[0].ClassificationId
for result in results:
assert result.ClassificationId == first_id
assert result.ClassificationName == name
# Should only exist once in the database
all_classifications = classification_repo.list_all()
matching = [c for c in all_classifications if c.ClassificationName == name]
assert len(matching) == 1
def test_classification_model_data_integrity(classification_repo: ClassificationRepository):
"""Test that Classification model preserves data integrity."""
original_name = "DataIntegrityTest"
classification = classification_repo.create(original_name)
# Verify original data
assert classification.ClassificationName == original_name
original_id = classification.ClassificationId
# Retrieve and verify data is preserved
retrieved = classification_repo.get_by_id(original_id)
assert retrieved is not None
assert retrieved.ClassificationId == original_id
assert retrieved.ClassificationName == original_name
# Verify through find_by_name as well
found_by_name = classification_repo.find_by_name(original_name)
assert found_by_name is not None
assert found_by_name.ClassificationId == original_id
assert found_by_name.ClassificationName == original_name
# ----------------------------------------------------------------------
# 8⃣ Parameterized tests for comprehensive coverage
# ----------------------------------------------------------------------
@pytest.mark.parametrize(
"test_name,expected_found",
[
("Soprano", True),
("Alto / Mezzo", True),
("Tenor", True),
("Baritone", True),
("Bass", False),
("Countertenor", False),
("Mezzo-Soprano", False),
("", False),
("soprano", False), # case sensitivity
("SOPRANO", False), # case sensitivity
]
)
def test_find_by_name_comprehensive(
classification_repo: ClassificationRepository,
test_name: str,
expected_found: bool
):
"""Comprehensive test of find_by_name with various inputs."""
result = classification_repo.find_by_name(test_name)
if expected_found:
assert result is not None
assert result.ClassificationName == test_name
assert isinstance(result.ClassificationId, int)
assert result.ClassificationId > 0
else:
assert result is None
@pytest.mark.parametrize(
"test_name",
[
"NewClassification1",
"Test With Spaces",
"Special-Characters!@#",
"123NumbersFirst",
"Mixed123Characters",
"Très_French_Ñame",
"Multi\nLine\nName",
"Tab\tSeparated",
"Quote'Name",
'Double"Quote"Name',
]
)
def test_create_and_retrieve_various_names(classification_repo: ClassificationRepository, test_name: str):
"""Test creating and retrieving classifications with various name formats."""
# Create
created = classification_repo.create(test_name)
assert created.ClassificationName == test_name
assert isinstance(created.ClassificationId, int)
assert created.ClassificationId > 0
# Retrieve by ID
by_id = classification_repo.get_by_id(created.ClassificationId)
assert by_id is not None
assert by_id.ClassificationName == test_name
assert by_id.ClassificationId == created.ClassificationId
# Retrieve by name
by_name = classification_repo.find_by_name(test_name)
assert by_name is not None
assert by_name.ClassificationName == test_name
assert by_name.ClassificationId == created.ClassificationId
# ----------------------------------------------------------------------
# 9⃣ Integration and workflow tests
# ----------------------------------------------------------------------
def test_complete_classification_workflow(classification_repo: ClassificationRepository):
"""Test a complete workflow with multiple operations."""
initial_count = len(classification_repo.list_all())
# Step 1: Create a new classification
new_name = "WorkflowTest"
created = classification_repo.create(new_name)
assert created.ClassificationName == new_name
# Step 2: Verify it exists in list_all
all_classifications = classification_repo.list_all()
assert len(all_classifications) == initial_count + 1
assert new_name in [c.ClassificationName for c in all_classifications]
# Step 3: Find by name
found = classification_repo.find_by_name(new_name)
assert found is not None
assert found.ClassificationId == created.ClassificationId
# Step 4: Get by ID
by_id = classification_repo.get_by_id(created.ClassificationId)
assert by_id is not None
assert by_id.ClassificationName == new_name
# Step 5: Use ensure_exists (should return existing)
ensured = classification_repo.ensure_exists(new_name)
assert ensured.ClassificationId == created.ClassificationId
# Step 6: Delete it
classification_repo.delete(created.ClassificationId)
# Step 7: Verify it's gone
assert classification_repo.get_by_id(created.ClassificationId) is None
assert classification_repo.find_by_name(new_name) is None
final_all = classification_repo.list_all()
assert len(final_all) == initial_count
assert new_name not in [c.ClassificationName for c in final_all]
def test_multiple_classifications_with_similar_names(classification_repo: ClassificationRepository):
"""Test handling of classifications with similar but distinct names."""
base_name = "TestSimilar"
similar_names = [
base_name,
base_name + " ", # with trailing space
" " + base_name, # with leading space
base_name.upper(), # different case
base_name.lower(), # different case
base_name + "2", # with number
base_name + "_Alt", # with suffix
]
created_classifications = []
for name in similar_names:
classification = classification_repo.create(name)
created_classifications.append(classification)
assert classification.ClassificationName == name
# All should have unique IDs
ids = [c.ClassificationId for c in created_classifications]
assert len(set(ids)) == len(ids)
# All should be findable by their exact names
for i, name in enumerate(similar_names):
found = classification_repo.find_by_name(name)
assert found is not None
assert found.ClassificationId == created_classifications[i].ClassificationId
assert found.ClassificationName == name
def test_classification_repository_thread_safety_simulation(classification_repo: ClassificationRepository):
"""Simulate concurrent operations to test repository consistency."""
# This simulates what might happen if multiple threads/processes were accessing the repo
base_name = "ConcurrencyTest"
# Simulate multiple "threads" trying to ensure the same classification exists
results = []
for i in range(5):
result = classification_repo.ensure_exists(base_name)
results.append(result)
# All should return the same classification
first_id = results[0].ClassificationId
for result in results:
assert result.ClassificationId == first_id
assert result.ClassificationName == base_name
# Should only exist once in the database
all_matches = [c for c in classification_repo.list_all() if c.ClassificationName == base_name]
assert len(all_matches) == 1

View File

@@ -1,6 +1,6 @@
# tests/repositories/test_member.py
import datetime as dt
from typing import List
from typing import List, Any
import pytest
@@ -380,4 +380,313 @@ def test_set_last_declined_resets_streak_and_records_date(member_repo: MemberRep
refreshed2 = member_repo.get_by_id(member.MemberId)
assert refreshed2.DeclineStreak == 2
assert refreshed2.LastDeclinedAt == tomorrow_iso
assert refreshed2.LastDeclinedAt == tomorrow_iso
# ----------------------------------------------------------------------
# 7⃣ get_active filter active members only
# ----------------------------------------------------------------------
def test_get_active_filters_correctly(member_repo: MemberRepository, clean_members):
"""Test that get_active returns only active members."""
# Create active member
active_member = member_repo.create(
first_name="Active",
last_name="User",
email="active@example.com",
phone_number="555-1234",
classification_id=1,
is_active=1,
)
# Create inactive member
inactive_member = member_repo.create(
first_name="Inactive",
last_name="User",
email="inactive@example.com",
phone_number="555-5678",
classification_id=1,
is_active=0,
)
active_members = member_repo.get_active()
# Should only return the active member
assert len(active_members) == 1
assert active_members[0].MemberId == active_member.MemberId
assert active_members[0].FirstName == "Active"
assert active_members[0].IsActive == 1
# ----------------------------------------------------------------------
# 8⃣ set_last_accepted resets decline data and sets acceptance date
# ----------------------------------------------------------------------
def test_set_last_accepted_resets_decline_data(member_repo: MemberRepository):
"""Test that set_last_accepted clears decline data and sets acceptance timestamp."""
member = member_repo.create(
first_name="Test",
last_name="Member",
email="test@example.com",
phone_number=None,
classification_id=1,
is_active=1,
)
# First decline the member to set up decline data
yesterday_iso = (dt.date.today() - dt.timedelta(days=1)).isoformat()
member_repo.set_last_declined(member.MemberId, yesterday_iso)
# Verify decline data is set
declined_member = member_repo.get_by_id(member.MemberId)
assert declined_member.DeclineStreak == 1
assert declined_member.LastDeclinedAt == yesterday_iso
assert declined_member.LastAcceptedAt is None
# Now accept
member_repo.set_last_accepted(member.MemberId)
# Verify acceptance resets decline data
accepted_member = member_repo.get_by_id(member.MemberId)
assert accepted_member.DeclineStreak == 0
assert accepted_member.LastDeclinedAt is None
assert accepted_member.LastAcceptedAt is not None
# Verify timestamp is recent (within last 5 seconds)
accepted_time = dt.datetime.fromisoformat(accepted_member.LastAcceptedAt.replace('f', '000'))
time_diff = dt.datetime.utcnow() - accepted_time
assert time_diff.total_seconds() < 5
# ----------------------------------------------------------------------
# 9⃣ reset_to_queue_front moves member to front of scheduling queue
# ----------------------------------------------------------------------
def test_reset_to_queue_front_moves_member_to_front(member_repo: MemberRepository, clean_members):
"""Test that reset_to_queue_front properly resets timestamps to move member to front."""
# Create two members with different timestamps
older_member = make_member(
member_repo,
"Older",
"Member",
accepted_at="2025-01-01 10:00:00",
scheduled_at="2025-01-01 10:00:00",
declined_at="2025-01-01 10:00:00",
decline_streak=2,
)
newer_member = make_member(
member_repo,
"Newer",
"Member",
accepted_at="2025-08-01 10:00:00",
scheduled_at="2025-08-01 10:00:00",
)
# Verify initial queue order (newer should come first due to older accepted date)
initial_queue = member_repo.candidate_queue([1])
assert len(initial_queue) == 2
assert initial_queue[0].FirstName == "Older" # older accepted date comes first
assert initial_queue[1].FirstName == "Newer"
# Reset newer member to queue front
member_repo.reset_to_queue_front(newer_member.MemberId)
# Verify newer member is now at front
updated_queue = member_repo.candidate_queue([1])
assert len(updated_queue) == 2
assert updated_queue[0].FirstName == "Newer" # should now be first
assert updated_queue[1].FirstName == "Older"
# Verify the reset member has expected timestamp values
reset_member = member_repo.get_by_id(newer_member.MemberId)
assert reset_member.LastAcceptedAt == '1970-01-01 00:00:00'
assert reset_member.LastScheduledAt == '1970-01-01 00:00:00'
assert reset_member.LastDeclinedAt is None
assert reset_member.DeclineStreak == 0
# ----------------------------------------------------------------------
# 🔟 Edge cases and error conditions
# ----------------------------------------------------------------------
def test_create_with_minimal_data(member_repo: MemberRepository):
"""Test creating a member with only required fields."""
member = member_repo.create(
first_name="Min",
last_name="Member"
)
assert member.FirstName == "Min"
assert member.LastName == "Member"
assert member.Email is None
assert member.PhoneNumber is None
assert member.ClassificationId is None
assert member.Notes is None
assert member.IsActive == 1 # default value
assert member.DeclineStreak == 0 # default value
def test_get_by_classification_ids_empty_list(member_repo: MemberRepository):
"""Test that empty classification list returns empty result without DB query."""
result = member_repo.get_by_classification_ids([])
assert result == []
def test_get_by_classification_ids_nonexistent_classification(member_repo: MemberRepository):
"""Test querying for nonexistent classification IDs."""
result = member_repo.get_by_classification_ids([999, 1000])
assert result == []
def test_candidate_queue_with_inactive_members(member_repo: MemberRepository, clean_members):
"""Test that candidate_queue respects only_active parameter."""
# Create active and inactive members
active = make_member(member_repo, "Active", "Member", is_active=1)
inactive = make_member(member_repo, "Inactive", "Member", is_active=0)
# Test with only_active=True (default)
queue_active_only = member_repo.candidate_queue([1], only_active=True)
assert len(queue_active_only) == 1
assert queue_active_only[0].FirstName == "Active"
# Test with only_active=False
queue_all = member_repo.candidate_queue([1], only_active=False)
assert len(queue_all) == 2
names = {m.FirstName for m in queue_all}
assert names == {"Active", "Inactive"}
def test_candidate_queue_boost_window_edge_cases(member_repo: MemberRepository, clean_members):
"""Test boost logic with edge cases around the boost window."""
now = dt.datetime.utcnow()
# Member declined well outside boost window (3 days ago)
outside_window = (now - dt.timedelta(days=3)).strftime("%Y-%m-%d %H:%M:%S")
outside_member = make_member(
member_repo,
"Outside",
"Member",
declined_at=outside_window,
decline_streak=1,
)
# Member declined well inside boost window (6 hours ago)
well_inside = (now - dt.timedelta(hours=6)).strftime("%Y-%m-%d %H:%M:%S")
inside_member = make_member(
member_repo,
"Inside",
"Member",
declined_at=well_inside,
decline_streak=1,
)
# Member with high decline streak (should not get boost)
high_streak_member = make_member(
member_repo,
"HighStreak",
"Member",
declined_at=well_inside,
decline_streak=5, # >= 2, so no boost
)
queue = member_repo.candidate_queue([1])
# Inside member should be boosted to front
first_names = [m.FirstName for m in queue]
assert "Inside" == first_names[0] # should be boosted
assert "HighStreak" in first_names[1:] # should not be boosted
assert "Outside" in first_names[1:] # should not be boosted
@pytest.mark.parametrize(
"decline_streak,should_boost",
[
(0, True), # streak < 2
(1, True), # streak < 2
(2, False), # streak >= 2
(5, False), # streak >= 2
]
)
def test_candidate_queue_decline_streak_boost_logic(
member_repo: MemberRepository,
clean_members,
decline_streak: int,
should_boost: bool
):
"""Test boost logic for different decline streak values."""
now = dt.datetime.utcnow()
recent_decline = (now - dt.timedelta(hours=12)).strftime("%Y-%m-%d %H:%M:%S")
# Create a baseline member (no recent declines)
baseline = make_member(member_repo, "Baseline", "Member")
# Create test member with specific decline streak
test_member = make_member(
member_repo,
"Test",
"Member",
declined_at=recent_decline,
decline_streak=decline_streak,
)
queue = member_repo.candidate_queue([1])
first_names = [m.FirstName for m in queue]
if should_boost:
assert first_names[0] == "Test" # boosted to front
assert first_names[1] == "Baseline"
else:
# Order depends on other factors, but Test should not be boosted
# Both have NULL LastAcceptedAt, so order by LastScheduledAt (both NULL)
# then likely by primary key order
assert "Test" in first_names
assert "Baseline" in first_names
def test_touch_last_scheduled_with_nonexistent_member(member_repo: MemberRepository):
"""Test touch_last_scheduled with nonexistent member ID (should not raise error)."""
# This should not raise an exception, just silently do nothing
member_repo.touch_last_scheduled(99999)
def test_set_operations_with_nonexistent_member(member_repo: MemberRepository):
"""Test set operations with nonexistent member IDs."""
# These should not raise exceptions
member_repo.set_last_accepted(99999)
member_repo.set_last_declined(99999, "2025-08-29")
member_repo.reset_to_queue_front(99999)
def test_member_data_integrity_after_operations(member_repo: MemberRepository):
"""Test that member data remains consistent after various operations."""
member = member_repo.create(
first_name="Integrity",
last_name="Test",
email="integrity@example.com",
phone_number="555-0000",
classification_id=2,
notes="Test member",
is_active=1,
)
original_id = member.MemberId
original_email = member.Email
original_classification = member.ClassificationId
# Perform various timestamp operations
member_repo.touch_last_scheduled(member.MemberId)
member_repo.set_last_declined(member.MemberId, "2025-08-29")
member_repo.set_last_accepted(member.MemberId)
member_repo.reset_to_queue_front(member.MemberId)
# Verify core data is unchanged
final_member = member_repo.get_by_id(member.MemberId)
assert final_member.MemberId == original_id
assert final_member.FirstName == "Integrity"
assert final_member.LastName == "Test"
assert final_member.Email == original_email
assert final_member.ClassificationId == original_classification
assert final_member.IsActive == 1
# Verify reset operation results
assert final_member.LastAcceptedAt == '1970-01-01 00:00:00'
assert final_member.LastScheduledAt == '1970-01-01 00:00:00'
assert final_member.LastDeclinedAt is None
assert final_member.DeclineStreak == 0

View File

@@ -0,0 +1,444 @@
# tests/repositories/test_schedule.py
import datetime as dt
from typing import List
import pytest
from backend.models import Schedule as ScheduleModel, ScheduleStatus
from backend.repositories import ScheduleRepository, ServiceRepository
from backend.db import DatabaseConnection
# ----------------------------------------------------------------------
# Additional fixtures for Schedule repository testing
# ----------------------------------------------------------------------
@pytest.fixture
def schedule_repo(
db_connection: DatabaseConnection,
seed_lookup_tables,
) -> ScheduleRepository:
return ScheduleRepository(db_connection)
@pytest.fixture
def service_repo(
db_connection: DatabaseConnection,
seed_lookup_tables,
) -> ServiceRepository:
return ServiceRepository(db_connection)
@pytest.fixture
def sample_service(service_repo: ServiceRepository) -> int:
"""Create a sample service and return its ID."""
service = service_repo.create(
service_type_id=1, # 9AM from seeded data
service_date=dt.date(2025, 9, 15)
)
return service.ServiceId
@pytest.fixture
def clean_schedules(schedule_repo: ScheduleRepository):
"""Clean the Schedules table before tests."""
schedule_repo.db.execute("DELETE FROM Schedules")
schedule_repo.db._conn.commit()
# ----------------------------------------------------------------------
# Basic CRUD Operations
# ----------------------------------------------------------------------
def test_create_and_get_by_id(
schedule_repo: ScheduleRepository,
sample_service: int,
clean_schedules
):
"""Test basic schedule creation and retrieval."""
# Create a schedule
schedule = schedule_repo.create(
service_id=sample_service,
member_id=1, # Alice from seeded data
status=ScheduleStatus.PENDING
)
# Verify the created schedule
assert isinstance(schedule.ScheduleId, int) and schedule.ScheduleId > 0
assert schedule.ServiceId == sample_service
assert schedule.MemberId == 1
assert schedule.Status == ScheduleStatus.PENDING.value
assert schedule.ScheduledAt is not None
assert schedule.AcceptedAt is None
assert schedule.DeclinedAt is None
# Retrieve the schedule
fetched = schedule_repo.get_by_id(schedule.ScheduleId)
assert fetched is not None
assert fetched.ServiceId == sample_service
assert fetched.MemberId == 1
assert fetched.Status == ScheduleStatus.PENDING.value
def test_get_by_id_returns_none_when_missing(schedule_repo: ScheduleRepository):
"""Test that get_by_id returns None for non-existent schedules."""
assert schedule_repo.get_by_id(9999) is None
def test_create_with_decline_reason(
schedule_repo: ScheduleRepository,
sample_service: int,
clean_schedules
):
"""Test creating a schedule with DECLINED status and reason."""
schedule = schedule_repo.create(
service_id=sample_service,
member_id=1,
status=ScheduleStatus.DECLINED,
reason="Already committed elsewhere"
)
assert schedule.Status == ScheduleStatus.DECLINED.value
assert schedule.DeclineReason == "Already committed elsewhere"
# ----------------------------------------------------------------------
# List Operations
# ----------------------------------------------------------------------
def test_list_all(
schedule_repo: ScheduleRepository,
sample_service: int,
clean_schedules
):
"""Test listing all schedules."""
# Create multiple schedules
schedule1 = schedule_repo.create(
service_id=sample_service,
member_id=1,
status=ScheduleStatus.PENDING
)
schedule2 = schedule_repo.create(
service_id=sample_service,
member_id=2,
status=ScheduleStatus.ACCEPTED
)
schedules = schedule_repo.list_all()
assert len(schedules) == 2
schedule_ids = {s.ScheduleId for s in schedules}
assert schedule1.ScheduleId in schedule_ids
assert schedule2.ScheduleId in schedule_ids
def test_get_pending_for_service(
schedule_repo: ScheduleRepository,
service_repo: ServiceRepository,
clean_schedules
):
"""Test getting pending schedules for a specific service."""
# Create two services
service1 = service_repo.create(service_type_id=1, service_date=dt.date(2025, 9, 15))
service2 = service_repo.create(service_type_id=2, service_date=dt.date(2025, 9, 15))
# Create schedules with different statuses
pending1 = schedule_repo.create(
service_id=service1.ServiceId,
member_id=1,
status=ScheduleStatus.PENDING
)
accepted1 = schedule_repo.create(
service_id=service1.ServiceId,
member_id=2,
status=ScheduleStatus.ACCEPTED
)
pending2 = schedule_repo.create(
service_id=service2.ServiceId,
member_id=1,
status=ScheduleStatus.PENDING
)
# Get pending schedules for service1
pending_schedules = schedule_repo.get_pending_for_service(service1.ServiceId)
assert len(pending_schedules) == 1
assert pending_schedules[0].ScheduleId == pending1.ScheduleId
assert pending_schedules[0].Status == ScheduleStatus.PENDING.value
# ----------------------------------------------------------------------
# Query Operations
# ----------------------------------------------------------------------
def test_get_one(
schedule_repo: ScheduleRepository,
sample_service: int,
clean_schedules
):
"""Test getting one schedule by member and service ID."""
# Create a schedule
schedule = schedule_repo.create(
service_id=sample_service,
member_id=1,
status=ScheduleStatus.PENDING
)
# Find it
found = schedule_repo.get_one(member_id=1, service_id=sample_service)
assert found is not None
assert found.ScheduleId == schedule.ScheduleId
# Try to find non-existent
not_found = schedule_repo.get_one(member_id=999, service_id=sample_service)
assert not_found is None
def test_has_any(
schedule_repo: ScheduleRepository,
sample_service: int,
clean_schedules
):
"""Test checking if member has schedules with specific statuses."""
# Create schedules with different statuses
schedule_repo.create(
service_id=sample_service,
member_id=1,
status=ScheduleStatus.PENDING
)
schedule_repo.create(
service_id=sample_service,
member_id=2,
status=ScheduleStatus.ACCEPTED
)
# Test has_any
assert schedule_repo.has_any(1, sample_service, [ScheduleStatus.PENDING])
assert schedule_repo.has_any(2, sample_service, [ScheduleStatus.ACCEPTED])
assert schedule_repo.has_any(1, sample_service, [ScheduleStatus.PENDING, ScheduleStatus.ACCEPTED])
assert not schedule_repo.has_any(1, sample_service, [ScheduleStatus.DECLINED])
assert not schedule_repo.has_any(999, sample_service, [ScheduleStatus.PENDING])
# Test empty statuses list
assert not schedule_repo.has_any(1, sample_service, [])
def test_has_schedule_on_date(
schedule_repo: ScheduleRepository,
service_repo: ServiceRepository,
clean_schedules
):
"""Test checking if member has any active schedule on a specific date."""
# Create services on different dates
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 pending schedule for today
schedule_repo.create(
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") # 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
# ----------------------------------------------------------------------
# Status Update Operations
# ----------------------------------------------------------------------
def test_update_status_to_accepted(
schedule_repo: ScheduleRepository,
sample_service: int,
clean_schedules
):
"""Test updating schedule status to accepted."""
schedule = schedule_repo.create(
service_id=sample_service,
member_id=1,
status=ScheduleStatus.PENDING
)
# Update to accepted
schedule_repo.update_status(
schedule_id=schedule.ScheduleId,
new_status=ScheduleStatus.ACCEPTED
)
# Verify the update
updated = schedule_repo.get_by_id(schedule.ScheduleId)
assert updated is not None
assert updated.Status == ScheduleStatus.ACCEPTED.value
assert updated.DeclinedAt is None
assert updated.DeclineReason is None
def test_update_status_to_declined(
schedule_repo: ScheduleRepository,
sample_service: int,
clean_schedules
):
"""Test updating schedule status to declined with reason."""
schedule = schedule_repo.create(
service_id=sample_service,
member_id=1,
status=ScheduleStatus.PENDING
)
# Update to declined
schedule_repo.update_status(
schedule_id=schedule.ScheduleId,
new_status=ScheduleStatus.DECLINED,
reason="Family emergency"
)
# Verify the update
updated = schedule_repo.get_by_id(schedule.ScheduleId)
assert updated is not None
assert updated.Status == ScheduleStatus.DECLINED.value
assert updated.DeclinedAt is not None
assert updated.DeclineReason == "Family emergency"
def test_mark_accepted(
schedule_repo: ScheduleRepository,
sample_service: int,
clean_schedules
):
"""Test marking a schedule as accepted."""
schedule = schedule_repo.create(
service_id=sample_service,
member_id=1,
status=ScheduleStatus.PENDING
)
# Mark as accepted
schedule_repo.mark_accepted(schedule.ScheduleId)
# Verify
updated = schedule_repo.get_by_id(schedule.ScheduleId)
assert updated is not None
assert updated.Status == ScheduleStatus.ACCEPTED.value
assert updated.AcceptedAt is not None
assert updated.DeclinedAt is None
assert updated.DeclineReason is None
def test_mark_declined(
schedule_repo: ScheduleRepository,
sample_service: int,
clean_schedules
):
"""Test marking a schedule as declined."""
schedule = schedule_repo.create(
service_id=sample_service,
member_id=1,
status=ScheduleStatus.PENDING
)
# Mark as declined
schedule_repo.mark_declined(
schedule.ScheduleId,
decline_reason="Unable to attend"
)
# Verify
updated = schedule_repo.get_by_id(schedule.ScheduleId)
assert updated is not None
assert updated.Status == ScheduleStatus.DECLINED.value
assert updated.DeclinedAt is not None
assert updated.DeclineReason == "Unable to attend"
# ----------------------------------------------------------------------
# Delete Operations (Added for CLI functionality)
# ----------------------------------------------------------------------
def test_delete_schedule(
schedule_repo: ScheduleRepository,
sample_service: int,
clean_schedules
):
"""Test deleting a schedule."""
# Create a schedule
schedule = schedule_repo.create(
service_id=sample_service,
member_id=1,
status=ScheduleStatus.PENDING
)
# Verify it exists
assert schedule_repo.get_by_id(schedule.ScheduleId) is not None
# Delete it
result = schedule_repo.delete_schedule(schedule.ScheduleId)
assert result is True
# Verify it's gone
assert schedule_repo.get_by_id(schedule.ScheduleId) is None
# Try to delete again (should return False)
result2 = schedule_repo.delete_schedule(schedule.ScheduleId)
assert result2 is False
def test_delete_nonexistent_schedule(schedule_repo: ScheduleRepository):
"""Test deleting a non-existent schedule returns False."""
result = schedule_repo.delete_schedule(9999)
assert result is False
# ----------------------------------------------------------------------
# Edge Cases and Error Conditions
# ----------------------------------------------------------------------
def test_create_with_invalid_foreign_keys(
schedule_repo: ScheduleRepository,
clean_schedules
):
"""Test that creating schedule with invalid FKs raises appropriate errors."""
# This should fail due to FK constraint (assuming constraints are enforced)
with pytest.raises(Exception): # SQLite foreign key constraint error
schedule_repo.create(
service_id=9999, # Non-existent service
member_id=1,
status=ScheduleStatus.PENDING
)
def test_unique_constraint_member_service(
schedule_repo: ScheduleRepository,
sample_service: int,
clean_schedules
):
"""Test that UNIQUE constraint prevents duplicate member/service schedules."""
# Create first schedule
schedule1 = schedule_repo.create(
service_id=sample_service,
member_id=1,
status=ScheduleStatus.PENDING
)
assert schedule1 is not None
# Attempting to create second schedule for same member/service should fail
with pytest.raises(Exception): # SQLite UNIQUE constraint error
schedule_repo.create(
service_id=sample_service,
member_id=1,
status=ScheduleStatus.DECLINED
)

View File

@@ -0,0 +1,566 @@
# backend/tests/repositories/test_service.py
# ------------------------------------------------------------
# Comprehensive pytest suite for the ServiceRepository.
# ------------------------------------------------------------
import pytest
from datetime import date, datetime, timedelta
from typing import List
from backend.models import Service as ServiceModel
from backend.repositories import ServiceRepository
# ----------------------------------------------------------------------
# Helper fixtures for test data
# ----------------------------------------------------------------------
@pytest.fixture
def sample_dates():
"""Return a set of dates for testing."""
today = date.today()
return {
'past': today - timedelta(days=30),
'yesterday': today - timedelta(days=1),
'today': today,
'tomorrow': today + timedelta(days=1),
'next_week': today + timedelta(days=7),
'future': today + timedelta(days=30),
}
@pytest.fixture
def clean_services(service_repo: ServiceRepository):
"""Clean the Services table for tests that need isolation."""
# Clear any existing services to start fresh
service_repo.db.execute(f"DELETE FROM {service_repo._TABLE}")
service_repo.db._conn.commit()
# ----------------------------------------------------------------------
# 1<0F> Basic CRUD  create & get_by_id
# ----------------------------------------------------------------------
def test_create_and_get_by_id(service_repo: ServiceRepository, sample_dates):
"""Test basic service creation and retrieval by ID."""
service = service_repo.create(
service_type_id=1,
service_date=sample_dates['tomorrow']
)
# Verify creation
assert isinstance(service.ServiceId, int) and service.ServiceId > 0
assert service.ServiceTypeId == 1
assert service.ServiceDate == sample_dates['tomorrow']
# Retrieve the same service
fetched = service_repo.get_by_id(service.ServiceId)
assert fetched is not None
assert fetched.ServiceId == service.ServiceId
assert fetched.ServiceTypeId == 1
assert fetched.ServiceDate == sample_dates['tomorrow']
def test_get_by_id_returns_none_when_missing(service_repo: ServiceRepository):
"""Test that get_by_id returns None for nonexistent IDs."""
result = service_repo.get_by_id(99999)
assert result is None
def test_create_with_date_object(service_repo: ServiceRepository):
"""Test creating service with a date object."""
test_date = date(2025, 12, 25)
service = service_repo.create(service_type_id=2, service_date=test_date)
assert service.ServiceDate == test_date
assert service.ServiceTypeId == 2
# ----------------------------------------------------------------------
# 2<0F> list_all  bulk operations
# ----------------------------------------------------------------------
def test_list_all(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test listing all services."""
# Create multiple services
services_data = [
(1, sample_dates['today']),
(2, sample_dates['tomorrow']),
(1, sample_dates['next_week']),
]
created_services = []
for service_type_id, service_date in services_data:
service = service_repo.create(service_type_id, service_date)
created_services.append(service)
all_services = service_repo.list_all()
assert len(all_services) == 3
# Verify all created services are in the list
service_ids = {s.ServiceId for s in all_services}
expected_ids = {s.ServiceId for s in created_services}
assert service_ids == expected_ids
def test_list_all_empty_table(service_repo: ServiceRepository, clean_services):
"""Test list_all when table is empty."""
all_services = service_repo.list_all()
assert all_services == []
# ----------------------------------------------------------------------
# 3<0F> upcoming  date-based filtering
# ----------------------------------------------------------------------
def test_upcoming_default_behavior(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test upcoming() with default parameters (from today, limit 100)."""
# Create services on various dates
past_service = service_repo.create(1, sample_dates['past'])
today_service = service_repo.create(1, sample_dates['today'])
future_service = service_repo.create(1, sample_dates['future'])
upcoming = service_repo.upcoming()
# Should include today and future, but not past
upcoming_ids = {s.ServiceId for s in upcoming}
assert today_service.ServiceId in upcoming_ids
assert future_service.ServiceId in upcoming_ids
assert past_service.ServiceId not in upcoming_ids
def test_upcoming_with_specific_after_date(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test upcoming() with a specific after date."""
# Create services
service_repo.create(1, sample_dates['yesterday'])
tomorrow_service = service_repo.create(1, sample_dates['tomorrow'])
future_service = service_repo.create(1, sample_dates['future'])
# Get services from tomorrow onwards
upcoming = service_repo.upcoming(after=sample_dates['tomorrow'])
upcoming_ids = {s.ServiceId for s in upcoming}
assert tomorrow_service.ServiceId in upcoming_ids
assert future_service.ServiceId in upcoming_ids
assert len(upcoming) == 2
def test_upcoming_with_limit(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test upcoming() with a limit parameter."""
# Create multiple future services
for i in range(5):
service_repo.create(1, sample_dates['today'] + timedelta(days=i))
upcoming = service_repo.upcoming(limit=3)
assert len(upcoming) == 3
def test_upcoming_chronological_order(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test that upcoming() returns services in chronological order."""
# Create services in non-chronological order
service_dates = [
sample_dates['future'],
sample_dates['tomorrow'],
sample_dates['next_week'],
sample_dates['today'],
]
for service_date in service_dates:
service_repo.create(1, service_date)
upcoming = service_repo.upcoming()
# Verify chronological order
for i in range(len(upcoming) - 1):
assert upcoming[i].ServiceDate <= upcoming[i + 1].ServiceDate
def test_upcoming_no_results(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test upcoming() when no future services exist."""
# Create only past services
service_repo.create(1, sample_dates['past'])
service_repo.create(1, sample_dates['yesterday'])
upcoming = service_repo.upcoming()
assert upcoming == []
# ----------------------------------------------------------------------
# 4<0F> by_type  service type filtering
# ----------------------------------------------------------------------
def test_by_type_single_type(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test filtering by a single service type."""
# Create services of different types
type1_service = service_repo.create(1, sample_dates['today'])
type2_service = service_repo.create(2, sample_dates['tomorrow'])
type1_service2 = service_repo.create(1, sample_dates['future'])
type1_services = service_repo.by_type([1])
# Should only include type 1 services
type1_ids = {s.ServiceId for s in type1_services}
assert type1_service.ServiceId in type1_ids
assert type1_service2.ServiceId in type1_ids
assert type2_service.ServiceId not in type1_ids
assert len(type1_services) == 2
def test_by_type_multiple_types(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test filtering by multiple service types."""
# Create services of different types
type1_service = service_repo.create(1, sample_dates['today'])
type2_service = service_repo.create(2, sample_dates['tomorrow'])
type3_service = service_repo.create(3, sample_dates['future'])
multi_type_services = service_repo.by_type([1, 3])
# Should include type 1 and 3, but not type 2
multi_ids = {s.ServiceId for s in multi_type_services}
assert type1_service.ServiceId in multi_ids
assert type3_service.ServiceId in multi_ids
assert type2_service.ServiceId not in multi_ids
assert len(multi_type_services) == 2
def test_by_type_empty_list(service_repo: ServiceRepository):
"""Test by_type() with empty type list returns empty result without DB query."""
result = service_repo.by_type([])
assert result == []
def test_by_type_nonexistent_types(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test by_type() with nonexistent service types."""
service_repo.create(1, sample_dates['today'])
result = service_repo.by_type([999, 1000])
assert result == []
def test_by_type_chronological_order(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test that by_type() returns services in chronological order."""
# Create services of same type in non-chronological order
service_dates = [
sample_dates['future'],
sample_dates['today'],
sample_dates['tomorrow'],
]
for service_date in service_dates:
service_repo.create(1, service_date)
services = service_repo.by_type([1])
# Verify chronological order
for i in range(len(services) - 1):
assert services[i].ServiceDate <= services[i + 1].ServiceDate
# ----------------------------------------------------------------------
# 5<0F> reschedule  update operations
# ----------------------------------------------------------------------
def test_reschedule_service(service_repo: ServiceRepository, sample_dates):
"""Test rescheduling a service to a new date."""
original_date = sample_dates['today']
new_date = sample_dates['future']
service = service_repo.create(1, original_date)
assert service.ServiceDate == original_date
# Reschedule the service
service_repo.reschedule(service.ServiceId, new_date)
# Verify the change
updated_service = service_repo.get_by_id(service.ServiceId)
assert updated_service is not None
assert updated_service.ServiceDate == new_date
assert updated_service.ServiceTypeId == 1 # Should remain unchanged
def test_reschedule_nonexistent_service(service_repo: ServiceRepository, sample_dates):
"""Test rescheduling a nonexistent service (should not raise error)."""
# This should not raise an exception
service_repo.reschedule(99999, sample_dates['tomorrow'])
# ----------------------------------------------------------------------
# 6<0F> Edge cases and error conditions
# ----------------------------------------------------------------------
def test_get_by_id_with_negative_id(service_repo: ServiceRepository):
"""Test get_by_id with negative ID (should return None)."""
result = service_repo.get_by_id(-1)
assert result is None
def test_get_by_id_with_zero_id(service_repo: ServiceRepository):
"""Test get_by_id with zero ID (should return None)."""
result = service_repo.get_by_id(0)
assert result is None
def test_create_with_invalid_service_type_id_raises_error(service_repo: ServiceRepository, sample_dates):
"""Test creating service with invalid service type ID raises foreign key error."""
with pytest.raises(Exception): # SQLite IntegrityError for FK constraint
service_repo.create(999, sample_dates['today'])
def test_create_with_very_old_date(service_repo: ServiceRepository):
"""Test creating service with a very old date."""
very_old_date = date(1900, 1, 1)
service = service_repo.create(1, very_old_date)
assert service.ServiceDate == very_old_date
def test_create_with_far_future_date(service_repo: ServiceRepository):
"""Test creating service with a far future date."""
far_future_date = date(2100, 12, 31)
service = service_repo.create(1, far_future_date)
assert service.ServiceDate == far_future_date
def test_upcoming_with_zero_limit(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test upcoming() with zero limit."""
service_repo.create(1, sample_dates['tomorrow'])
upcoming = service_repo.upcoming(limit=0)
assert upcoming == []
def test_upcoming_with_negative_limit(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test upcoming() with negative limit (SQLite behavior)."""
service_repo.create(1, sample_dates['tomorrow'])
# SQLite treats negative LIMIT as unlimited
upcoming = service_repo.upcoming(limit=-1)
assert len(upcoming) >= 0 # Should not crash
def test_by_type_with_duplicate_type_ids(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test by_type() with duplicate type IDs in the list."""
service1 = service_repo.create(1, sample_dates['today'])
service2 = service_repo.create(2, sample_dates['tomorrow'])
# Pass duplicate type IDs
services = service_repo.by_type([1, 1, 2, 1])
# Should return services of both types (no duplicates in result)
service_ids = {s.ServiceId for s in services}
assert service1.ServiceId in service_ids
assert service2.ServiceId in service_ids
assert len(services) == 2
# ----------------------------------------------------------------------
# 7<0F> Data integrity and consistency tests
# ----------------------------------------------------------------------
def test_service_model_data_integrity(service_repo: ServiceRepository, sample_dates):
"""Test that Service model preserves data integrity."""
original_type_id = 2
original_date = sample_dates['tomorrow']
service = service_repo.create(original_type_id, original_date)
original_id = service.ServiceId
# Retrieve and verify data is preserved
retrieved = service_repo.get_by_id(original_id)
assert retrieved is not None
assert retrieved.ServiceId == original_id
assert retrieved.ServiceTypeId == original_type_id
assert retrieved.ServiceDate == original_date
# Verify through list_all as well
all_services = service_repo.list_all()
matching_services = [s for s in all_services if s.ServiceId == original_id]
assert len(matching_services) == 1
assert matching_services[0].ServiceTypeId == original_type_id
assert matching_services[0].ServiceDate == original_date
def test_multiple_services_same_date_and_type(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test creating multiple services on same date and type."""
# Should be allowed - multiple services can exist for same date/type
service1 = service_repo.create(1, sample_dates['today'])
service2 = service_repo.create(1, sample_dates['today'])
assert service1.ServiceId != service2.ServiceId
assert service1.ServiceTypeId == service2.ServiceTypeId
assert service1.ServiceDate == service2.ServiceDate
# Both should be findable
assert service_repo.get_by_id(service1.ServiceId) is not None
assert service_repo.get_by_id(service2.ServiceId) is not None
# ----------------------------------------------------------------------
# 8<0F> Parameterized tests for comprehensive coverage
# ----------------------------------------------------------------------
@pytest.mark.parametrize("service_type_id", [1, 2, 3])
def test_create_with_valid_service_type_ids(service_repo: ServiceRepository, service_type_id):
"""Test creating services with valid service type IDs."""
test_date = date.today()
service = service_repo.create(service_type_id, test_date)
assert service.ServiceTypeId == service_type_id
assert service.ServiceDate == test_date
assert isinstance(service.ServiceId, int)
assert service.ServiceId > 0
@pytest.mark.parametrize("invalid_service_type_id", [999, -1, 0])
def test_create_with_invalid_service_type_ids_raises_error(service_repo: ServiceRepository, invalid_service_type_id):
"""Test creating services with invalid service type IDs raises foreign key errors."""
test_date = date.today()
with pytest.raises(Exception): # SQLite IntegrityError for FK constraint
service_repo.create(invalid_service_type_id, test_date)
@pytest.mark.parametrize(
"days_offset,should_be_included",
[
(-30, False), # Past
(-1, False), # Yesterday
(0, True), # Today
(1, True), # Tomorrow
(7, True), # Next week
(30, True), # Future
]
)
def test_upcoming_date_filtering(
service_repo: ServiceRepository,
clean_services,
days_offset: int,
should_be_included: bool
):
"""Test upcoming() date filtering logic."""
test_date = date.today() + timedelta(days=days_offset)
service = service_repo.create(1, test_date)
upcoming = service_repo.upcoming()
upcoming_ids = {s.ServiceId for s in upcoming}
if should_be_included:
assert service.ServiceId in upcoming_ids
else:
assert service.ServiceId not in upcoming_ids
@pytest.mark.parametrize("limit", [1, 5, 10, 50, 100])
def test_upcoming_limit_parameter(service_repo: ServiceRepository, clean_services, limit: int):
"""Test upcoming() with various limit values."""
# Create more services than the limit
for i in range(limit + 5):
service_repo.create(1, date.today() + timedelta(days=i))
upcoming = service_repo.upcoming(limit=limit)
assert len(upcoming) == limit
# ----------------------------------------------------------------------
# 9<0F> Integration and workflow tests
# ----------------------------------------------------------------------
def test_complete_service_workflow(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test a complete workflow with multiple operations."""
initial_count = len(service_repo.list_all())
# Step 1: Create a new service
original_date = sample_dates['tomorrow']
service = service_repo.create(2, original_date)
assert service.ServiceTypeId == 2
assert service.ServiceDate == original_date
# Step 2: Verify it exists in list_all
all_services = service_repo.list_all()
assert len(all_services) == initial_count + 1
service_ids = {s.ServiceId for s in all_services}
assert service.ServiceId in service_ids
# Step 3: Find it in upcoming services
upcoming = service_repo.upcoming()
upcoming_ids = {s.ServiceId for s in upcoming}
assert service.ServiceId in upcoming_ids
# Step 4: Find it by type
by_type = service_repo.by_type([2])
by_type_ids = {s.ServiceId for s in by_type}
assert service.ServiceId in by_type_ids
# Step 5: Reschedule it
new_date = sample_dates['future']
service_repo.reschedule(service.ServiceId, new_date)
# Step 6: Verify the reschedule
updated_service = service_repo.get_by_id(service.ServiceId)
assert updated_service is not None
assert updated_service.ServiceDate == new_date
assert updated_service.ServiceTypeId == 2
def test_complex_date_filtering_scenario(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test complex scenarios with multiple date filters."""
# Create services across different dates and types
services_data = [
(1, sample_dates['past']), # Should not appear in upcoming
(1, sample_dates['today']), # Should appear in upcoming
(2, sample_dates['tomorrow']), # Should appear in upcoming
(1, sample_dates['future']), # Should appear in upcoming
(3, sample_dates['next_week']), # Should appear in upcoming
]
created_services = {}
for service_type_id, service_date in services_data:
service = service_repo.create(service_type_id, service_date)
created_services[(service_type_id, service_date)] = service
# Test upcoming from tomorrow
upcoming_from_tomorrow = service_repo.upcoming(after=sample_dates['tomorrow'])
upcoming_dates = {s.ServiceDate for s in upcoming_from_tomorrow}
assert sample_dates['tomorrow'] in upcoming_dates
assert sample_dates['future'] in upcoming_dates
assert sample_dates['next_week'] in upcoming_dates
assert sample_dates['past'] not in upcoming_dates
assert sample_dates['today'] not in upcoming_dates
# Test by specific types
type1_services = service_repo.by_type([1])
type1_dates = {s.ServiceDate for s in type1_services}
assert sample_dates['past'] in type1_dates
assert sample_dates['today'] in type1_dates
assert sample_dates['future'] in type1_dates
# Test combination: upcoming type 1 services (filter by date intersection)
upcoming_all = service_repo.upcoming()
upcoming_type1 = [s for s in upcoming_all if s.ServiceTypeId == 1]
upcoming_type1_dates = {s.ServiceDate for s in upcoming_type1}
assert sample_dates['today'] in upcoming_type1_dates
assert sample_dates['future'] in upcoming_type1_dates
assert sample_dates['past'] not in upcoming_type1_dates
def test_service_repository_consistency(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test repository consistency across different query methods."""
# Create a mix of services
test_services = []
for i in range(5):
service = service_repo.create(
service_type_id=(i % 3) + 1,
service_date=sample_dates['today'] + timedelta(days=i)
)
test_services.append(service)
# All services should be found through list_all
all_services = service_repo.list_all()
all_ids = {s.ServiceId for s in all_services}
test_ids = {s.ServiceId for s in test_services}
assert test_ids.issubset(all_ids)
# Each service should be retrievable individually
for service in test_services:
retrieved = service_repo.get_by_id(service.ServiceId)
assert retrieved is not None
assert retrieved.ServiceId == service.ServiceId
assert retrieved.ServiceTypeId == service.ServiceTypeId
assert retrieved.ServiceDate == service.ServiceDate
# Services should appear in appropriate filtered queries
future_services = service_repo.upcoming(after=sample_dates['tomorrow'])
future_ids = {s.ServiceId for s in future_services}
for service in test_services:
if service.ServiceDate >= sample_dates['tomorrow']:
assert service.ServiceId in future_ids
else:
assert service.ServiceId not in future_ids

View File

@@ -1,69 +1,652 @@
# tests/test_service_availability.py
# backend/tests/repositories/test_service_availability.py
# ------------------------------------------------------------
# Comprehensive pytest suite for the ServiceAvailabilityRepository.
# ------------------------------------------------------------
import pytest
from typing import List
from backend.models import ServiceAvailability as ServiceAvailabilityModel
from backend.repositories import ServiceAvailabilityRepository
def test_grant_and_revoke(
service_availability_repo,
member_repo,
service_type_repo,
# ----------------------------------------------------------------------
# Helper fixtures for test data
# ----------------------------------------------------------------------
@pytest.fixture
def clean_service_availability(service_availability_repo: ServiceAvailabilityRepository):
"""Clean the ServiceAvailability table for tests that need isolation."""
# Clear any existing service availability records to start fresh
service_availability_repo.db.execute(f"DELETE FROM {service_availability_repo._TABLE}")
service_availability_repo.db._conn.commit()
# ----------------------------------------------------------------------
# 1⃣ Basic CRUD create, get, delete
# ----------------------------------------------------------------------
def test_create_and_get(service_availability_repo: ServiceAvailabilityRepository):
"""Test basic service availability creation and retrieval."""
# Create a new availability record
availability = service_availability_repo.create(member_id=1, service_type_id=1)
# Verify creation
assert isinstance(availability.ServiceAvailabilityId, int)
assert availability.ServiceAvailabilityId > 0
assert availability.MemberId == 1
assert availability.ServiceTypeId == 1
# Retrieve the same record
fetched = service_availability_repo.get(member_id=1, service_type_id=1)
assert fetched is not None
assert fetched.ServiceAvailabilityId == availability.ServiceAvailabilityId
assert fetched.MemberId == 1
assert fetched.ServiceTypeId == 1
def test_get_returns_none_when_missing(service_availability_repo: ServiceAvailabilityRepository):
"""Test that get returns None for nonexistent member/service type pairs."""
result = service_availability_repo.get(member_id=999, service_type_id=999)
assert result is None
def test_create_is_idempotent(service_availability_repo: ServiceAvailabilityRepository):
"""Test that create returns existing record if pair already exists."""
# Create first record
first = service_availability_repo.create(member_id=1, service_type_id=1)
# Create again with same parameters - should return existing record
second = service_availability_repo.create(member_id=1, service_type_id=1)
# Should be the same record
assert first.ServiceAvailabilityId == second.ServiceAvailabilityId
assert first.MemberId == second.MemberId
assert first.ServiceTypeId == second.ServiceTypeId
def test_delete_by_id(service_availability_repo: ServiceAvailabilityRepository):
"""Test deleting availability record by primary key."""
# Create a record
availability = service_availability_repo.create(member_id=1, service_type_id=1)
original_id = availability.ServiceAvailabilityId
# Verify it exists
assert service_availability_repo.get(member_id=1, service_type_id=1) is not None
# Delete it
service_availability_repo.delete(original_id)
# Verify it's gone
assert service_availability_repo.get(member_id=1, service_type_id=1) is None
def test_delete_nonexistent_record(service_availability_repo: ServiceAvailabilityRepository):
"""Test deleting a nonexistent record (should not raise error)."""
# This should not raise an exception
service_availability_repo.delete(99999)
# ----------------------------------------------------------------------
# 2⃣ Grant and revoke operations
# ----------------------------------------------------------------------
def test_grant_and_revoke(service_availability_repo: ServiceAvailabilityRepository):
"""Test the grant and revoke convenience methods."""
# Grant access
granted = service_availability_repo.grant(member_id=1, service_type_id=1)
assert granted.MemberId == 1
assert granted.ServiceTypeId == 1
# Verify it was granted
fetched = service_availability_repo.get(member_id=1, service_type_id=1)
assert fetched is not None
assert fetched.ServiceAvailabilityId == granted.ServiceAvailabilityId
# Revoke access
service_availability_repo.revoke(member_id=1, service_type_id=1)
# Verify it was revoked
assert service_availability_repo.get(member_id=1, service_type_id=1) is None
def test_grant_is_idempotent(service_availability_repo: ServiceAvailabilityRepository):
"""Test that grant is idempotent (multiple calls return same record)."""
# Grant access twice
first_grant = service_availability_repo.grant(member_id=1, service_type_id=1)
second_grant = service_availability_repo.grant(member_id=1, service_type_id=1)
# Should return the same record
assert first_grant.ServiceAvailabilityId == second_grant.ServiceAvailabilityId
assert first_grant.MemberId == second_grant.MemberId
assert first_grant.ServiceTypeId == second_grant.ServiceTypeId
def test_revoke_nonexistent_record(service_availability_repo: ServiceAvailabilityRepository):
"""Test revoking a nonexistent member/service type pair (should not raise error)."""
# This should not raise an exception
service_availability_repo.revoke(member_id=999, service_type_id=999)
# ----------------------------------------------------------------------
# 3⃣ List operations
# ----------------------------------------------------------------------
def test_list_by_member(service_availability_repo: ServiceAvailabilityRepository, clean_service_availability):
"""Test listing all availabilities for a specific member."""
member_id = 1
service_types = [1, 2, 3]
# Grant access to multiple service types
created_records = []
for service_type_id in service_types:
record = service_availability_repo.grant(member_id, service_type_id)
created_records.append(record)
# List all availabilities for the member
member_availabilities = service_availability_repo.list_by_member(member_id)
# Should have all the records we created
assert len(member_availabilities) == 3
member_service_types = {a.ServiceTypeId for a in member_availabilities}
assert member_service_types == set(service_types)
# All should belong to the same member
for availability in member_availabilities:
assert availability.MemberId == member_id
def test_list_by_member_empty(service_availability_repo: ServiceAvailabilityRepository):
"""Test listing availabilities for a member with no records."""
availabilities = service_availability_repo.list_by_member(member_id=999)
assert availabilities == []
def test_list_by_service_type(service_availability_repo: ServiceAvailabilityRepository, clean_service_availability):
"""Test listing all members available for a specific service type."""
service_type_id = 1
member_ids = [1, 2]
# Grant access to multiple members
created_records = []
for member_id in member_ids:
record = service_availability_repo.grant(member_id, service_type_id)
created_records.append(record)
# List all availabilities for the service type
type_availabilities = service_availability_repo.list_by_service_type(service_type_id)
# Should have all the records we created
assert len(type_availabilities) == 2
available_members = {a.MemberId for a in type_availabilities}
assert available_members == set(member_ids)
# All should be for the same service type
for availability in type_availabilities:
assert availability.ServiceTypeId == service_type_id
def test_list_by_service_type_empty(service_availability_repo: ServiceAvailabilityRepository):
"""Test listing availabilities for a service type with no records."""
availabilities = service_availability_repo.list_by_service_type(service_type_id=999)
assert availabilities == []
def test_list_all(service_availability_repo: ServiceAvailabilityRepository, clean_service_availability):
"""Test listing all service availability records."""
# Create multiple records
test_data = [
(1, 1), (1, 2), (1, 3), # Member 1 available for types 1,2,3
(2, 1), (2, 2), # Member 2 available for types 1,2
]
created_records = []
for member_id, service_type_id in test_data:
record = service_availability_repo.grant(member_id, service_type_id)
created_records.append(record)
# List all records
all_records = service_availability_repo.list_all()
# Should have all the records we created
assert len(all_records) == len(test_data)
# Verify all our records are present
created_ids = {r.ServiceAvailabilityId for r in created_records}
fetched_ids = {r.ServiceAvailabilityId for r in all_records}
assert created_ids.issubset(fetched_ids)
def test_list_all_empty_table(service_availability_repo: ServiceAvailabilityRepository, clean_service_availability):
"""Test list_all when table is empty."""
all_records = service_availability_repo.list_all()
assert all_records == []
# ----------------------------------------------------------------------
# 4⃣ members_for_type helper method
# ----------------------------------------------------------------------
def test_members_for_type(service_availability_repo: ServiceAvailabilityRepository, clean_service_availability):
"""Test the members_for_type helper method."""
service_type_id = 1
member_ids = [1, 2] # Valid member IDs only
# Grant access to multiple members
for member_id in member_ids:
service_availability_repo.grant(member_id, service_type_id)
# Get member IDs for the service type
available_members = service_availability_repo.members_for_type(service_type_id)
# Should return the member IDs we granted access to
assert set(available_members) == set(member_ids)
# Should return integers (member IDs)
for member_id in available_members:
assert isinstance(member_id, int)
def test_members_for_type_empty(service_availability_repo: ServiceAvailabilityRepository):
"""Test members_for_type with no available members."""
member_ids = service_availability_repo.members_for_type(service_type_id=999)
assert member_ids == []
def test_members_for_type_ordering(service_availability_repo: ServiceAvailabilityRepository, clean_service_availability):
"""Test that members_for_type returns consistent ordering."""
service_type_id = 1
member_ids = [2, 1] # Create in non-sequential order
# Grant access in the specified order
for member_id in member_ids:
service_availability_repo.grant(member_id, service_type_id)
# Get member IDs multiple times
results = []
for _ in range(3):
available_members = service_availability_repo.members_for_type(service_type_id)
results.append(available_members)
# All results should be identical (consistent ordering)
for i in range(1, len(results)):
assert results[0] == results[i]
# Should contain all our member IDs
assert set(results[0]) == set(member_ids)
# ----------------------------------------------------------------------
# 5⃣ Edge cases and error conditions
# ----------------------------------------------------------------------
def test_create_with_invalid_member_id(service_availability_repo: ServiceAvailabilityRepository):
"""Test creating availability with invalid member ID raises foreign key error."""
with pytest.raises(Exception): # SQLite IntegrityError for FK constraint
service_availability_repo.create(member_id=999, service_type_id=1)
def test_create_with_invalid_service_type_id(service_availability_repo: ServiceAvailabilityRepository):
"""Test creating availability with invalid service type ID raises foreign key error."""
with pytest.raises(Exception): # SQLite IntegrityError for FK constraint
service_availability_repo.create(member_id=1, service_type_id=999)
def test_create_with_negative_ids(service_availability_repo: ServiceAvailabilityRepository):
"""Test creating availability with negative IDs raises foreign key error."""
with pytest.raises(Exception):
service_availability_repo.create(member_id=-1, service_type_id=1)
with pytest.raises(Exception):
service_availability_repo.create(member_id=1, service_type_id=-1)
def test_create_with_zero_ids(service_availability_repo: ServiceAvailabilityRepository):
"""Test creating availability with zero IDs raises foreign key error."""
with pytest.raises(Exception):
service_availability_repo.create(member_id=0, service_type_id=1)
with pytest.raises(Exception):
service_availability_repo.create(member_id=1, service_type_id=0)
def test_get_with_negative_ids(service_availability_repo: ServiceAvailabilityRepository):
"""Test get with negative IDs returns None."""
assert service_availability_repo.get(member_id=-1, service_type_id=1) is None
assert service_availability_repo.get(member_id=1, service_type_id=-1) is None
def test_get_with_zero_ids(service_availability_repo: ServiceAvailabilityRepository):
"""Test get with zero IDs returns None."""
assert service_availability_repo.get(member_id=0, service_type_id=1) is None
assert service_availability_repo.get(member_id=1, service_type_id=0) is None
def test_delete_with_negative_id(service_availability_repo: ServiceAvailabilityRepository):
"""Test delete with negative ID (should not raise error)."""
service_availability_repo.delete(-1)
def test_delete_with_zero_id(service_availability_repo: ServiceAvailabilityRepository):
"""Test delete with zero ID (should not raise error)."""
service_availability_repo.delete(0)
# ----------------------------------------------------------------------
# 6⃣ Data integrity and consistency tests
# ----------------------------------------------------------------------
def test_unique_constraint_enforcement(service_availability_repo: ServiceAvailabilityRepository):
"""Test that the unique constraint on (MemberId, ServiceTypeId) is enforced."""
# Create first record
first = service_availability_repo.create(member_id=1, service_type_id=1)
# Try to create duplicate - should return existing record due to idempotent behavior
second = service_availability_repo.create(member_id=1, service_type_id=1)
# Should be the same record (idempotent behavior)
assert first.ServiceAvailabilityId == second.ServiceAvailabilityId
# Verify only one record exists
all_records = service_availability_repo.list_all()
matching_records = [r for r in all_records if r.MemberId == 1 and r.ServiceTypeId == 1]
assert len(matching_records) == 1
def test_service_availability_model_data_integrity(service_availability_repo: ServiceAvailabilityRepository):
"""Test that ServiceAvailability model preserves data integrity."""
original_member_id = 1
original_service_type_id = 2
availability = service_availability_repo.create(original_member_id, original_service_type_id)
original_id = availability.ServiceAvailabilityId
# Retrieve and verify data is preserved
retrieved = service_availability_repo.get(original_member_id, original_service_type_id)
assert retrieved is not None
assert retrieved.ServiceAvailabilityId == original_id
assert retrieved.MemberId == original_member_id
assert retrieved.ServiceTypeId == original_service_type_id
# Verify through list operations as well
by_member = service_availability_repo.list_by_member(original_member_id)
matching_by_member = [r for r in by_member if r.ServiceTypeId == original_service_type_id]
assert len(matching_by_member) == 1
assert matching_by_member[0].ServiceAvailabilityId == original_id
by_type = service_availability_repo.list_by_service_type(original_service_type_id)
matching_by_type = [r for r in by_type if r.MemberId == original_member_id]
assert len(matching_by_type) == 1
assert matching_by_type[0].ServiceAvailabilityId == original_id
def test_cross_method_consistency(service_availability_repo: ServiceAvailabilityRepository, clean_service_availability):
"""Test consistency across different query methods."""
# Create a complex availability matrix
test_data = [
(1, 1), (1, 2), # Member 1: types 1,2
(2, 1), (2, 3), # Member 2: types 1,3
]
created_records = []
for member_id, service_type_id in test_data:
record = service_availability_repo.grant(member_id, service_type_id)
created_records.append(record)
# Verify consistency across methods
all_records = service_availability_repo.list_all()
# Check each member's records via list_by_member
for member_id in [1, 2]:
member_records = service_availability_repo.list_by_member(member_id)
member_records_from_all = [r for r in all_records if r.MemberId == member_id]
assert len(member_records) == len(member_records_from_all)
member_ids_direct = {r.ServiceAvailabilityId for r in member_records}
member_ids_from_all = {r.ServiceAvailabilityId for r in member_records_from_all}
assert member_ids_direct == member_ids_from_all
# Check each service type's records via list_by_service_type
for service_type_id in [1, 2, 3]:
type_records = service_availability_repo.list_by_service_type(service_type_id)
type_records_from_all = [r for r in all_records if r.ServiceTypeId == service_type_id]
assert len(type_records) == len(type_records_from_all)
type_ids_direct = {r.ServiceAvailabilityId for r in type_records}
type_ids_from_all = {r.ServiceAvailabilityId for r in type_records_from_all}
assert type_ids_direct == type_ids_from_all
# Verify members_for_type consistency
member_ids = service_availability_repo.members_for_type(service_type_id)
member_ids_from_records = [r.MemberId for r in type_records]
assert set(member_ids) == set(member_ids_from_records)
# ----------------------------------------------------------------------
# 7⃣ Parameterized tests for comprehensive coverage
# ----------------------------------------------------------------------
@pytest.mark.parametrize("member_id,service_type_id", [
(1, 1), (1, 2), (1, 3),
(2, 1), (2, 2), (2, 3),
])
def test_create_and_retrieve_valid_combinations(
service_availability_repo: ServiceAvailabilityRepository,
member_id: int,
service_type_id: int
):
"""
Verify that:
• `grant` adds a new (member, service_type) pair idempotently.
• `revoke` removes the pair.
• The helper `members_for_type` returns the expected IDs.
"""
# ------------------------------------------------------------------
# Arrange fetch the IDs we know exist from the fixture.
# ------------------------------------------------------------------
# Alice is member_id 1, Bob is member_id 2 (AUTOINCREMENT order).
alice_id = 1
bob_id = 2
# Service type IDs correspond to the order we inserted them:
# 9AM → 1, 11AM → 2, 6PM → 3
nine_am_id = 1
eleven_am_id = 2
six_pm_id = 3
# ------------------------------------------------------------------
# Act try granting a *new* availability that wasn't seeded.
# We'll give Alice the 11AM slot (she didn't have it before).
# ------------------------------------------------------------------
new_pair = service_availability_repo.grant(alice_id, eleven_am_id)
# ------------------------------------------------------------------
# Assert the row exists and the helper returns the right member list.
# ------------------------------------------------------------------
assert new_pair.MemberId == alice_id
assert new_pair.ServiceTypeId == eleven_am_id
# `members_for_type` should now contain Alice (1) **and** Bob (2) for 11AM.
members_for_11am = service_availability_repo.members_for_type(eleven_am_id)
assert set(members_for_11am) == {alice_id, bob_id}
# ------------------------------------------------------------------
# Revoke the newly added pair and ensure it disappears.
# ------------------------------------------------------------------
service_availability_repo.revoke(alice_id, eleven_am_id)
# After revocation the 11AM list should contain **only** Bob.
members_after_revoke = service_availability_repo.members_for_type(eleven_am_id)
assert members_after_revoke == [bob_id]
# Also verify that `get` returns None for the removed pair.
assert service_availability_repo.get(alice_id, eleven_am_id) is None
"""Test creating and retrieving various valid member/service type combinations."""
# Create
created = service_availability_repo.create(member_id, service_type_id)
assert created.MemberId == member_id
assert created.ServiceTypeId == service_type_id
assert isinstance(created.ServiceAvailabilityId, int)
assert created.ServiceAvailabilityId > 0
# Retrieve
retrieved = service_availability_repo.get(member_id, service_type_id)
assert retrieved is not None
assert retrieved.ServiceAvailabilityId == created.ServiceAvailabilityId
assert retrieved.MemberId == member_id
assert retrieved.ServiceTypeId == service_type_id
def test_list_by_member(service_availability_repo):
"""
Validate that `list_by_member` returns exactly the slots we seeded.
"""
# Alice (member_id 1) should have 9AM (1) and 6PM (3)
alice_slots = service_availability_repo.list_by_member(1)
alice_type_ids = sorted([s.ServiceTypeId for s in alice_slots])
assert alice_type_ids == [1, 3]
@pytest.mark.parametrize("invalid_member_id,invalid_service_type_id", [
(999, 1), (1, 999), (999, 999),
(-1, 1), (1, -1), (-1, -1),
(0, 1), (1, 0), (0, 0),
])
def test_create_with_invalid_combinations_raises_error(
service_availability_repo: ServiceAvailabilityRepository,
invalid_member_id: int,
invalid_service_type_id: int
):
"""Test creating availability with invalid combinations raises foreign key errors."""
with pytest.raises(Exception): # SQLite IntegrityError for FK constraint
service_availability_repo.create(invalid_member_id, invalid_service_type_id)
# Bob (member_id 2) should have 11AM (2) and 6PM (3)
bob_slots = service_availability_repo.list_by_member(2)
bob_type_ids = sorted([s.ServiceTypeId for s in bob_slots])
assert bob_type_ids == [2, 3]
@pytest.mark.parametrize("member_id", [1, 2])
def test_list_by_member_various_members(service_availability_repo: ServiceAvailabilityRepository, member_id: int):
"""Test list_by_member with various member IDs."""
# Grant access to a service type
service_availability_repo.grant(member_id, service_type_id=1)
# List availabilities
availabilities = service_availability_repo.list_by_member(member_id)
# Should have at least one record (the one we just granted)
assert len(availabilities) >= 1
# All records should belong to the specified member
for availability in availabilities:
assert availability.MemberId == member_id
@pytest.mark.parametrize("service_type_id", [1, 2, 3])
def test_list_by_service_type_various_types(service_availability_repo: ServiceAvailabilityRepository, service_type_id: int):
"""Test list_by_service_type with various service type IDs."""
# Grant access to a member
service_availability_repo.grant(member_id=1, service_type_id=service_type_id)
# List availabilities
availabilities = service_availability_repo.list_by_service_type(service_type_id)
# Should have at least one record (the one we just granted)
assert len(availabilities) >= 1
# All records should be for the specified service type
for availability in availabilities:
assert availability.ServiceTypeId == service_type_id
# ----------------------------------------------------------------------
# 8⃣ Integration and workflow tests
# ----------------------------------------------------------------------
def test_complete_availability_workflow(service_availability_repo: ServiceAvailabilityRepository, clean_service_availability):
"""Test a complete workflow with multiple operations."""
member_id = 1
service_type_ids = [1, 2, 3]
initial_count = len(service_availability_repo.list_all())
# Step 1: Grant access to multiple service types
granted_records = []
for service_type_id in service_type_ids:
record = service_availability_repo.grant(member_id, service_type_id)
granted_records.append(record)
assert record.MemberId == member_id
assert record.ServiceTypeId == service_type_id
# Step 2: Verify records exist in list_all
all_records = service_availability_repo.list_all()
assert len(all_records) == initial_count + 3
granted_ids = {r.ServiceAvailabilityId for r in granted_records}
all_ids = {r.ServiceAvailabilityId for r in all_records}
assert granted_ids.issubset(all_ids)
# Step 3: Verify via list_by_member
member_records = service_availability_repo.list_by_member(member_id)
member_service_types = {r.ServiceTypeId for r in member_records}
assert set(service_type_ids) == member_service_types
# Step 4: Verify via list_by_service_type and members_for_type
for service_type_id in service_type_ids:
type_records = service_availability_repo.list_by_service_type(service_type_id)
type_member_ids = {r.MemberId for r in type_records}
assert member_id in type_member_ids
member_ids_for_type = service_availability_repo.members_for_type(service_type_id)
assert member_id in member_ids_for_type
# Step 5: Revoke access to one service type
revoked_type = service_type_ids[1] # Revoke access to type 2
service_availability_repo.revoke(member_id, revoked_type)
# Step 6: Verify revocation
assert service_availability_repo.get(member_id, revoked_type) is None
updated_member_records = service_availability_repo.list_by_member(member_id)
updated_service_types = {r.ServiceTypeId for r in updated_member_records}
expected_remaining = set(service_type_ids) - {revoked_type}
assert updated_service_types == expected_remaining
# Step 7: Clean up remaining records
for service_type_id in [1, 3]: # Types 1 and 3 should still exist
service_availability_repo.revoke(member_id, service_type_id)
# Step 8: Verify cleanup
final_member_records = service_availability_repo.list_by_member(member_id)
original_member_records = [r for r in final_member_records if r.ServiceAvailabilityId in granted_ids]
assert len(original_member_records) == 0
def test_complex_multi_member_scenario(service_availability_repo: ServiceAvailabilityRepository, clean_service_availability):
"""Test complex scenarios with multiple members and service types."""
# Create a realistic availability matrix:
# Member 1: Available for all service types (1,2,3)
# Member 2: Available for morning services (1,2)
# Member 3: Available for evening service only (3)
# Member 4: Not available for any services
availability_matrix = [
(1, 1), (1, 2), (1, 3), # Member 1: all services
(2, 1), (2, 2), # Member 2: morning only
# Member 3: no services (doesn't exist in seeded data)
]
# Grant all availabilities
for member_id, service_type_id in availability_matrix:
service_availability_repo.grant(member_id, service_type_id)
# Test service type 1 (should have members 1,2)
type1_members = service_availability_repo.members_for_type(1)
assert set(type1_members) == {1, 2}
# Test service type 2 (should have members 1,2)
type2_members = service_availability_repo.members_for_type(2)
assert set(type2_members) == {1, 2}
# Test service type 3 (should have member 1 only)
type3_members = service_availability_repo.members_for_type(3)
assert set(type3_members) == {1}
# Test member 1 (should have all service types)
member1_records = service_availability_repo.list_by_member(1)
member1_types = {r.ServiceTypeId for r in member1_records}
assert member1_types == {1, 2, 3}
# Test member 2 (should have types 1,2)
member2_records = service_availability_repo.list_by_member(2)
member2_types = {r.ServiceTypeId for r in member2_records}
assert member2_types == {1, 2}
# Test nonexistent member (should have no services)
member3_records = service_availability_repo.list_by_member(3)
assert len(member3_records) == 0
# Simulate removing member 1 from evening service
service_availability_repo.revoke(1, 3)
# Type 3 should now have no members
updated_type3_members = service_availability_repo.members_for_type(3)
assert set(updated_type3_members) == set()
# Member 1 should now only have types 1,2
updated_member1_records = service_availability_repo.list_by_member(1)
updated_member1_types = {r.ServiceTypeId for r in updated_member1_records}
assert updated_member1_types == {1, 2}
def test_service_availability_repository_consistency_under_operations(
service_availability_repo: ServiceAvailabilityRepository,
clean_service_availability
):
"""Test repository consistency under various operations."""
# Create, modify, and delete records while verifying consistency
operations = [
('grant', 1, 1),
('grant', 1, 2),
('grant', 2, 1),
('revoke', 1, 1),
('grant', 1, 3),
('revoke', 2, 1),
('grant', 2, 2),
]
expected_state = set() # Track expected (member_id, service_type_id) pairs
for operation, member_id, service_type_id in operations:
if operation == 'grant':
service_availability_repo.grant(member_id, service_type_id)
expected_state.add((member_id, service_type_id))
elif operation == 'revoke':
service_availability_repo.revoke(member_id, service_type_id)
expected_state.discard((member_id, service_type_id))
# Verify consistency after each operation
all_records = service_availability_repo.list_all()
actual_pairs = {(r.MemberId, r.ServiceTypeId) for r in all_records if (r.MemberId, r.ServiceTypeId) in expected_state or (r.MemberId, r.ServiceTypeId) not in expected_state}
# Filter to only the pairs we've been working with
relevant_actual_pairs = {(r.MemberId, r.ServiceTypeId) for r in all_records
if r.MemberId in [1, 2] and r.ServiceTypeId in [1, 2, 3]}
assert relevant_actual_pairs == expected_state, f"Inconsistency after {operation}({member_id}, {service_type_id})"
# Verify each record can be retrieved individually
for member_id_check, service_type_id_check in expected_state:
record = service_availability_repo.get(member_id_check, service_type_id_check)
assert record is not None, f"Could not retrieve ({member_id_check}, {service_type_id_check})"

View File

@@ -1,62 +1,650 @@
# tests/test_service_type_repo.py
# backend/tests/repositories/test_service_type.py
# ------------------------------------------------------------
# Comprehensive pytest suite for the ServiceTypeRepository.
# ------------------------------------------------------------
import pytest
from backend.models.dataclasses import ServiceType as ServiceTypeModel
def test_create_and_find(service_type_repo):
"""
Verify that we can insert a brandnew ServiceType and retrieve it
both by primary key and by name.
"""
# Create a new slot that wasn't part of the seed data.
new_slot = service_type_repo.create("2PM")
assert isinstance(new_slot, ServiceTypeModel)
assert new_slot.TypeName == "2PM"
assert new_slot.ServiceTypeId > 0 # autoincrement worked
# Find by primary key.
fetched_by_id = service_type_repo.get_by_id(new_slot.ServiceTypeId)
assert fetched_by_id == new_slot
# Find by name.
fetched_by_name = service_type_repo.find_by_name("2PM")
assert fetched_by_name == new_slot
import time
import uuid
from typing import List
from backend.models import ServiceType as ServiceTypeModel
from backend.repositories import ServiceTypeRepository
def test_list_all_contains_seeded_slots(service_type_repo):
"""
The three seeded slots (9AM, 11AM, 6PM) should be present and sorted
alphabetically by the repository implementation.
"""
# ----------------------------------------------------------------------
# Helper utilities for test data
# ----------------------------------------------------------------------
def make_unique_name(base_name: str) -> str:
"""Generate a unique name by appending timestamp and uuid fragment."""
return f"{base_name}-{int(time.time())}-{uuid.uuid4().hex[:8]}"
# ----------------------------------------------------------------------
# 1⃣ Basic CRUD create, get_by_id, find_by_name
# ----------------------------------------------------------------------
def test_create_and_get_by_id(service_type_repo: ServiceTypeRepository):
"""Test basic service type creation and retrieval by ID."""
# Create a new service type with unique name
unique_name = make_unique_name("TestTimeSlot")
service_type = service_type_repo.create(unique_name)
# Verify creation
assert isinstance(service_type, ServiceTypeModel)
assert isinstance(service_type.ServiceTypeId, int)
assert service_type.ServiceTypeId > 0
assert service_type.TypeName == unique_name
# Retrieve the same service type by ID
fetched = service_type_repo.get_by_id(service_type.ServiceTypeId)
assert fetched is not None
assert fetched.ServiceTypeId == service_type.ServiceTypeId
assert fetched.TypeName == unique_name
def test_get_by_id_returns_none_when_missing(service_type_repo: ServiceTypeRepository):
"""Test that get_by_id returns None for nonexistent IDs."""
result = service_type_repo.get_by_id(99999)
assert result is None
def test_create_and_find_by_name(service_type_repo: ServiceTypeRepository):
"""Test service type creation and retrieval by name."""
type_name = make_unique_name("2PM Special Slot")
# Create a new service type
service_type = service_type_repo.create(type_name)
assert service_type.TypeName == type_name
# Find by name
found = service_type_repo.find_by_name(type_name)
assert found is not None
assert found.ServiceTypeId == service_type.ServiceTypeId
assert found.TypeName == type_name
def test_find_by_name_returns_none_when_missing(service_type_repo: ServiceTypeRepository):
"""Test that find_by_name returns None for nonexistent names."""
result = service_type_repo.find_by_name("NonexistentSlot-XYZ-123")
assert result is None
def test_create_with_various_names(service_type_repo: ServiceTypeRepository):
"""Test creating service types with various name formats."""
# Use unique names to avoid conflicts
test_names = [
make_unique_name("Test8AM"),
make_unique_name("Test11:30AM"),
make_unique_name("Evening Service"),
make_unique_name("Saturday 8PM"),
make_unique_name("Special Event - Christmas"),
make_unique_name("Mid-Week 7:30PM"),
]
created_types = []
for name in test_names:
service_type = service_type_repo.create(name)
created_types.append(service_type)
assert service_type.TypeName == name
assert service_type.ServiceTypeId > 0
# All should be retrievable by name
for i, name in enumerate(test_names):
found = service_type_repo.find_by_name(name)
assert found is not None
assert found.ServiceTypeId == created_types[i].ServiceTypeId
assert found.TypeName == name
# ----------------------------------------------------------------------
# 2⃣ list_all bulk operations and ordering
# ----------------------------------------------------------------------
def test_list_all(service_type_repo: ServiceTypeRepository):
"""Test listing all service types."""
# Get initial count
initial_types = service_type_repo.list_all()
initial_count = len(initial_types)
# Create multiple service types with unique names
test_names = [make_unique_name("Evening6PM"), make_unique_name("Morning8AM"), make_unique_name("Midday12PM"), make_unique_name("Afternoon3PM")]
created_types = []
for name in test_names:
service_type = service_type_repo.create(name)
created_types.append(service_type)
# List all service types
all_types = service_type_repo.list_all()
assert len(all_types) == initial_count + len(test_names)
# Verify all created types are present
created_ids = {st.ServiceTypeId for st in created_types}
fetched_ids = {st.ServiceTypeId for st in all_types}
assert created_ids.issubset(fetched_ids)
def test_list_all_alphabetical_ordering(service_type_repo: ServiceTypeRepository):
"""Test that list_all returns results in alphabetical order."""
# Get all service types multiple times
for _ in range(3):
all_types = service_type_repo.list_all()
type_names = [st.TypeName for st in all_types]
# Should be in alphabetical order
assert type_names == sorted(type_names)
def test_list_all_contains_seeded_slots(service_type_repo: ServiceTypeRepository):
"""Test that list_all contains the expected seeded service types."""
all_slots = service_type_repo.list_all()
names = [s.TypeName for s in all_slots]
# The seed fixture inserted exactly these three names.
assert set(names) >= {"9AM", "11AM", "6PM"}
# The seed fixture should have inserted these three names
expected_names = {"9AM", "11AM", "6PM"}
actual_names = set(names)
assert expected_names.issubset(actual_names)
# Because ``list_all`` orders by ``TypeName ASC`` we expect alphabetical order.
# Should be in alphabetical order
assert names == sorted(names)
def test_ensure_slots_is_idempotent(service_type_repo):
"""
``ensure_slots`` should insert missing rows and return the full set,
without creating duplicates on subsequent calls.
"""
# First call inserts the three seed rows plus a brandnew one.
# ----------------------------------------------------------------------
# 3⃣ ensure_slots bulk operations and idempotency
# ----------------------------------------------------------------------
def test_ensure_slots_creates_missing(service_type_repo: ServiceTypeRepository):
"""Test that ensure_slots creates missing service types."""
desired_slots = [make_unique_name("Morning8AM"), make_unique_name("Afternoon2PM"), make_unique_name("Evening7PM")]
# Initially, none should exist
for slot_name in desired_slots:
assert service_type_repo.find_by_name(slot_name) is None
# Ensure all slots exist
result = service_type_repo.ensure_slots(desired_slots)
# All should now be returned
assert len(result) == 3
result_names = {st.TypeName for st in result}
assert result_names == set(desired_slots)
# All should be findable individually
for slot_name in desired_slots:
found = service_type_repo.find_by_name(slot_name)
assert found is not None
assert found.TypeName == slot_name
def test_ensure_slots_returns_existing(service_type_repo: ServiceTypeRepository):
"""Test that ensure_slots returns existing service types without creating duplicates."""
# Create some service types first
existing_names = [make_unique_name("Pre-existing1"), make_unique_name("Pre-existing2")]
created_types = []
for name in existing_names:
service_type = service_type_repo.create(name)
created_types.append(service_type)
# Ensure these same slots (should not create duplicates)
result = service_type_repo.ensure_slots(existing_names)
# Should return the same objects (same IDs)
assert len(result) == 2
result_ids = {st.ServiceTypeId for st in result}
created_ids = {st.ServiceTypeId for st in created_types}
assert result_ids == created_ids
def test_ensure_slots_mixed_existing_and_new(service_type_repo: ServiceTypeRepository):
"""Test ensure_slots with a mix of existing and new service types."""
# Create some existing service types
existing_names = [make_unique_name("Existing1"), make_unique_name("Existing2")]
created_types = {}
for name in existing_names:
service_type = service_type_repo.create(name)
created_types[name] = service_type
# Request both existing and new slots
new_names = [make_unique_name("New1"), make_unique_name("New2")]
all_desired = existing_names + new_names
result = service_type_repo.ensure_slots(all_desired)
# Should return all 4 service types
assert len(result) == 4
result_names = {st.TypeName for st in result}
assert result_names == set(all_desired)
# Existing ones should have same IDs
for existing_name in existing_names:
result_item = next(st for st in result if st.TypeName == existing_name)
assert result_item.ServiceTypeId == created_types[existing_name].ServiceTypeId
def test_ensure_slots_is_idempotent(service_type_repo: ServiceTypeRepository):
"""Test that ensure_slots is idempotent (multiple calls don't create duplicates)."""
# First call inserts the three seed rows plus a new one
wanted = ["9AM", "11AM", "6PM", "3PM"]
result_first = service_type_repo.ensure_slots(wanted)
# All four names must now exist.
# All four names must now exist
assert {s.TypeName for s in result_first} == set(wanted)
# Capture the IDs for later comparison.
# Capture the IDs for later comparison
ids_before = {s.TypeName: s.ServiceTypeId for s in result_first}
# Second call should *not* create new rows.
# Second call should not create new rows
result_second = service_type_repo.ensure_slots(wanted)
ids_after = {s.TypeName: s.ServiceTypeId for s in result_second}
# IDs must be unchanged (no duplicates were added).
# IDs must be unchanged (no duplicates were added)
assert ids_before == ids_after
assert len(result_second) == len(wanted)
assert len(result_second) == len(wanted)
def test_ensure_slots_empty_list(service_type_repo: ServiceTypeRepository):
"""Test ensure_slots with empty list."""
result = service_type_repo.ensure_slots([])
assert result == []
def test_ensure_slots_with_duplicates_in_input(service_type_repo: ServiceTypeRepository):
"""Test ensure_slots when input list contains duplicates."""
# This test reveals that ensure_slots has a limitation - it doesn't handle
# duplicates within the input list properly. It only checks existing slots
# at the start, not newly created ones within the same call.
# Create the unique slots first
base1 = make_unique_name("Morning")
base2 = make_unique_name("Evening")
base3 = make_unique_name("Afternoon")
# Create them individually first
service_type_repo.create(base1)
service_type_repo.create(base2)
service_type_repo.create(base3)
# Now test with duplicates - should work since they all exist
desired_slots = [base1, base2, base1, base3, base2]
result = service_type_repo.ensure_slots(desired_slots)
# Should return the same structure as input
assert len(result) == len(desired_slots)
# Should contain only the unique names
result_names = {st.TypeName for st in result}
expected_unique = {base1, base2, base3}
assert result_names == expected_unique
# Verify that duplicates in result refer to same objects
base1_objects = [st for st in result if st.TypeName == base1]
base2_objects = [st for st in result if st.TypeName == base2]
# Should have 2 copies of base1 and base2 each
assert len(base1_objects) == 2
assert len(base2_objects) == 2
# All copies should have same ID (same object references)
assert base1_objects[0].ServiceTypeId == base1_objects[1].ServiceTypeId
assert base2_objects[0].ServiceTypeId == base2_objects[1].ServiceTypeId
# ----------------------------------------------------------------------
# 4⃣ Edge cases and error conditions
# ----------------------------------------------------------------------
def test_get_by_id_with_invalid_ids(service_type_repo: ServiceTypeRepository):
"""Test get_by_id with various invalid ID values."""
invalid_ids = [-1, 0, 99999, -999]
for invalid_id in invalid_ids:
result = service_type_repo.get_by_id(invalid_id)
assert result is None
def test_find_by_name_case_sensitivity(service_type_repo: ServiceTypeRepository):
"""Test that find_by_name is case-sensitive."""
# Create with exact case
unique_name = make_unique_name("MorningSlot")
service_type_repo.create(unique_name)
# Exact case should work
exact = service_type_repo.find_by_name(unique_name)
assert exact is not None
# Different cases should return None
assert service_type_repo.find_by_name(unique_name.lower()) is None
assert service_type_repo.find_by_name(unique_name.upper()) is None
def test_find_by_name_with_whitespace(service_type_repo: ServiceTypeRepository):
"""Test find_by_name behavior with whitespace variations."""
# Create with exact spacing
unique_name = make_unique_name("Morning Service")
service_type_repo.create(unique_name)
# Exact spacing should work
exact = service_type_repo.find_by_name(unique_name)
assert exact is not None
# Different spacing should return None
assert service_type_repo.find_by_name(f" {unique_name}") is None
assert service_type_repo.find_by_name(f"{unique_name} ") is None
def test_find_by_name_with_empty_string(service_type_repo: ServiceTypeRepository):
"""Test find_by_name with empty string."""
result = service_type_repo.find_by_name("")
assert result is None
def test_create_with_special_characters(service_type_repo: ServiceTypeRepository):
"""Test creating service types with special characters."""
special_names = [
make_unique_name("Morning@8AM"),
make_unique_name("Evening-Service"),
make_unique_name("Special Event (Christmas)"),
make_unique_name("Mid-Week & Youth"),
make_unique_name("Saturday: 7:30PM"),
make_unique_name("Service #1"),
make_unique_name("Slot with 'quotes'"),
make_unique_name('Double"Quote"Service'),
make_unique_name("Unicode Service 🎵"),
]
created_types = []
for name in special_names:
service_type = service_type_repo.create(name)
created_types.append(service_type)
assert service_type.TypeName == name
# Should be findable
found = service_type_repo.find_by_name(name)
assert found is not None
assert found.ServiceTypeId == service_type.ServiceTypeId
# All should have unique IDs
ids = [st.ServiceTypeId for st in created_types]
assert len(set(ids)) == len(ids)
# ----------------------------------------------------------------------
# 5⃣ Data integrity and consistency tests
# ----------------------------------------------------------------------
def test_service_type_model_data_integrity(service_type_repo: ServiceTypeRepository):
"""Test that ServiceType model preserves data integrity."""
original_name = make_unique_name("DataIntegrityTest")
service_type = service_type_repo.create(original_name)
original_id = service_type.ServiceTypeId
# Retrieve and verify data is preserved
retrieved_by_id = service_type_repo.get_by_id(original_id)
assert retrieved_by_id is not None
assert retrieved_by_id.ServiceTypeId == original_id
assert retrieved_by_id.TypeName == original_name
# Retrieve by name and verify consistency
retrieved_by_name = service_type_repo.find_by_name(original_name)
assert retrieved_by_name is not None
assert retrieved_by_name.ServiceTypeId == original_id
assert retrieved_by_name.TypeName == original_name
# Both retrievals should be equivalent
assert retrieved_by_id.ServiceTypeId == retrieved_by_name.ServiceTypeId
assert retrieved_by_id.TypeName == retrieved_by_name.TypeName
def test_unique_name_constraint(service_type_repo: ServiceTypeRepository):
"""Test that service type names are unique (database constraint)."""
name = make_unique_name("UniqueTestSlot")
# Create first service type
first = service_type_repo.create(name)
assert first.TypeName == name
# Attempting to create another with same name should raise an error
with pytest.raises(Exception): # SQLite IntegrityError for unique constraint
service_type_repo.create(name)
def test_cross_method_consistency(service_type_repo: ServiceTypeRepository):
"""Test consistency across different query methods."""
# Create multiple service types
test_names = [make_unique_name("Alpha Service"), make_unique_name("Beta Service"), make_unique_name("Gamma Service")]
created_types = []
for name in test_names:
service_type = service_type_repo.create(name)
created_types.append(service_type)
# Test list_all consistency
all_types = service_type_repo.list_all()
all_names = {st.TypeName for st in all_types}
assert set(test_names).issubset(all_names)
# Test individual retrieval consistency
for i, name in enumerate(test_names):
# Get by ID
by_id = service_type_repo.get_by_id(created_types[i].ServiceTypeId)
assert by_id is not None
assert by_id.TypeName == name
# Find by name
by_name = service_type_repo.find_by_name(name)
assert by_name is not None
assert by_name.ServiceTypeId == created_types[i].ServiceTypeId
# Both methods should return equivalent objects
assert by_id.ServiceTypeId == by_name.ServiceTypeId
assert by_id.TypeName == by_name.TypeName
# ----------------------------------------------------------------------
# 6⃣ Parameterized tests for comprehensive coverage
# ----------------------------------------------------------------------
@pytest.mark.parametrize("base_name", [
"8AM", # Changed from 9AM to avoid conflict with seeded data
"11:30AM",
"Evening Service",
"Saturday Special",
"Mid-Week Bible Study",
"Youth Service - Friday",
"Sunday Morning Worship",
"Christmas Eve Service",
])
def test_create_and_retrieve_various_names(service_type_repo: ServiceTypeRepository, base_name: str):
"""Test creating and retrieving service types with various name formats."""
# Create unique name to avoid conflicts
type_name = make_unique_name(base_name)
# Create
created = service_type_repo.create(type_name)
assert created.TypeName == type_name
assert isinstance(created.ServiceTypeId, int)
assert created.ServiceTypeId > 0
# Retrieve by ID
by_id = service_type_repo.get_by_id(created.ServiceTypeId)
assert by_id is not None
assert by_id.ServiceTypeId == created.ServiceTypeId
assert by_id.TypeName == type_name
# Retrieve by name
by_name = service_type_repo.find_by_name(type_name)
assert by_name is not None
assert by_name.ServiceTypeId == created.ServiceTypeId
assert by_name.TypeName == type_name
@pytest.mark.parametrize("slot_count", [1, 2, 5, 10])
def test_ensure_slots_various_counts(service_type_repo: ServiceTypeRepository, slot_count: int):
"""Test ensure_slots with various numbers of slots."""
# Generate unique slot names
slot_names = [make_unique_name(f"Slot{i}") for i in range(1, slot_count + 1)]
# Ensure all slots
result = service_type_repo.ensure_slots(slot_names)
# Should return all requested slots
assert len(result) == slot_count
result_names = {st.TypeName for st in result}
assert result_names == set(slot_names)
# All should be findable
for name in slot_names:
found = service_type_repo.find_by_name(name)
assert found is not None
assert found.TypeName == name
# ----------------------------------------------------------------------
# 7⃣ Integration and workflow tests
# ----------------------------------------------------------------------
def test_complete_service_type_workflow(service_type_repo: ServiceTypeRepository):
"""Test a complete workflow with multiple operations."""
# Step 1: Check initial state
initial_count = len(service_type_repo.list_all())
# Step 2: Create new service types
new_names = [make_unique_name("Morning Worship"), make_unique_name("Evening Prayer"), make_unique_name("Youth Meeting")]
created_types = []
for name in new_names:
service_type = service_type_repo.create(name)
created_types.append(service_type)
assert service_type.TypeName == name
# Step 3: Verify they exist in list_all
all_types = service_type_repo.list_all()
assert len(all_types) == initial_count + 3
created_ids = {st.ServiceTypeId for st in created_types}
all_ids = {st.ServiceTypeId for st in all_types}
assert created_ids.issubset(all_ids)
# Step 4: Test individual retrieval
for service_type in created_types:
# By ID
by_id = service_type_repo.get_by_id(service_type.ServiceTypeId)
assert by_id is not None
assert by_id.ServiceTypeId == service_type.ServiceTypeId
# By name
by_name = service_type_repo.find_by_name(service_type.TypeName)
assert by_name is not None
assert by_name.ServiceTypeId == service_type.ServiceTypeId
# Step 5: Test ensure_slots with mix of existing and new
additional_names = [make_unique_name("Additional Slot 1"), make_unique_name("Additional Slot 2")]
all_desired = new_names + additional_names
ensured = service_type_repo.ensure_slots(all_desired)
# Should return all 5 service types
assert len(ensured) == 5
ensured_names = {st.TypeName for st in ensured}
assert ensured_names == set(all_desired)
# Original service types should have same IDs
for original in created_types:
matching = next(st for st in ensured if st.TypeName == original.TypeName)
assert matching.ServiceTypeId == original.ServiceTypeId
def test_bulk_operations_consistency(service_type_repo: ServiceTypeRepository):
"""Test consistency when performing bulk operations."""
# Create initial batch
initial_names = [make_unique_name("Slot A"), make_unique_name("Slot B"), make_unique_name("Slot C")]
for name in initial_names:
service_type_repo.create(name)
# Use ensure_slots to add more
additional_names = [make_unique_name("Slot D"), make_unique_name("Slot E")]
all_names = initial_names + additional_names
result = service_type_repo.ensure_slots(all_names)
# Should return all service types
assert len(result) == 5
result_names = {st.TypeName for st in result}
assert result_names == set(all_names)
# Verify consistency with list_all
all_from_list = service_type_repo.list_all()
list_names = {st.TypeName for st in all_from_list}
assert set(all_names).issubset(list_names)
# Verify each can be found individually
for name in all_names:
found = service_type_repo.find_by_name(name)
assert found is not None
assert found.TypeName == name
def test_repository_scalability_and_performance(service_type_repo: ServiceTypeRepository):
"""Test repository behavior with a larger number of service types."""
# Create multiple service types with unique names
service_type_count = 25
base_name = make_unique_name("ScalabilityTest")
service_types = []
# Create service types
for i in range(service_type_count):
name = f"{base_name}-{i:03d}"
service_type = service_type_repo.create(name)
service_types.append(service_type)
assert service_type.TypeName == name
# Verify list_all returns all and maintains order
all_types = service_type_repo.list_all()
created_names = {st.TypeName for st in service_types}
all_names = {st.TypeName for st in all_types}
assert created_names.issubset(all_names)
# Should be in alphabetical order
all_names_list = [st.TypeName for st in all_types]
assert all_names_list == sorted(all_names_list)
# Test random access patterns
import random
test_indices = random.sample(range(service_type_count), min(5, service_type_count))
for i in test_indices:
expected_name = f"{base_name}-{i:03d}"
expected_id = service_types[i].ServiceTypeId
# Test retrieval by ID
by_id = service_type_repo.get_by_id(expected_id)
assert by_id is not None
assert by_id.TypeName == expected_name
# Test retrieval by name
by_name = service_type_repo.find_by_name(expected_name)
assert by_name is not None
assert by_name.ServiceTypeId == expected_id
def test_concurrent_operation_simulation(service_type_repo: ServiceTypeRepository):
"""Simulate concurrent operations to test repository consistency."""
# Simulate what might happen if multiple processes/threads were creating service types
base_names = [make_unique_name("Morning"), make_unique_name("Evening"), make_unique_name("Afternoon")]
# Multiple "processes" trying to ensure the same slots exist
results = []
for _ in range(3):
result = service_type_repo.ensure_slots(base_names)
results.append(result)
# Should always return the same service types
assert len(result) == 3
result_names = {st.TypeName for st in result}
assert result_names == set(base_names)
# All results should have the same IDs for the same names
first_result = results[0]
first_name_to_id = {st.TypeName: st.ServiceTypeId for st in first_result}
for result in results[1:]:
result_name_to_id = {st.TypeName: st.ServiceTypeId for st in result}
assert first_name_to_id == result_name_to_id
# Verify only one of each was actually created
all_types = service_type_repo.list_all()
all_names = [st.TypeName for st in all_types]
for name in base_names:
count = all_names.count(name)
assert count == 1, f"Found {count} instances of {name}, expected 1"

402
frontend/.gitignore vendored
View File

@@ -1,55 +1,373 @@
# Dependencies
# Build results
bin/
obj/
out/
# Explicit .NET build artifacts
*.dll
*.pdb
*.exe
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Visual Studio Code
.vscode/
# JetBrains Rider
.idea/
*.sln.iml
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these files may be created as clear text
*.azurePubxml
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
CDF_Data/
l3codegen.ps1
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Build output
dist/
build/
.next/
# Visual Studio 6 build log
*.plg
# TypeScript cache
*.tsbuildinfo
# Visual Studio 6 workspace options file
*.opt
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
*.log
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Local environment files
.env
.env.local
.env.*.local
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Editor directories and files
.vscode/
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Paket dependency manager
.paket/paket.exe
paket-files/
# macOS
.DS_Store
# FAKE - F# Make
.fake/
# Linux
*~
# CodeRush personal settings
.cr/personal
# Windows
Thumbs.db
ehthumbs.db
Desktop.ini
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# TailwindCSS JIT cache
.tailwindcss/
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Optional: if using Next.js image optimization cache
.next/cache/
# Tabs Studio
*.tss
# Optional: if using Storybook
storybook-static/
.out/
# Telerik's JustMock configuration file
*.jmconfig
# Optional: if using testing coverage
coverage/
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml

View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="bootstrap/bootstrap.min.css" />
<link rel="stylesheet" href="app.css" />
<link rel="stylesheet" href="css/nimbusflow.css" />
<link rel="stylesheet" href="NimbusFlow.Frontend.styles.css" />
<link rel="icon" type="image/png" href="favicon.png" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet" />
<HeadOutlet />
</head>
<body>
<Routes />
<script src="_framework/blazor.web.js"></script>
</body>
</html>

View File

@@ -0,0 +1,19 @@
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar nimbus-sidebar">
<NavMenu />
</div>
<main>
<article class="content px-4">
@Body
</article>
</main>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>

View File

@@ -0,0 +1,96 @@
.page {
position: relative;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row {
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link {
margin-left: 0;
}
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child {
flex: 1;
text-align: right;
width: 0;
}
.top-row, article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
}
#blazor-error-ui {
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}

View File

@@ -0,0 +1,38 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">
<i class="bi bi-cloud nimbus-brand-icon" aria-hidden="true"></i>NimbusFlow
</a>
</div>
</div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="flex-column">
<div class="nav-item">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<i class="bi bi-house-door-fill me-2" aria-hidden="true"></i> Dashboard
</NavLink>
</div>
<div class="nav-item">
<NavLink class="nav-link" href="members">
<i class="bi bi-people-fill me-2" aria-hidden="true"></i> Members
</NavLink>
</div>
<div class="nav-item">
<NavLink class="nav-link" href="schedules">
<i class="bi bi-calendar-event-fill me-2" aria-hidden="true"></i> Schedules
</NavLink>
</div>
<div class="nav-item">
<NavLink class="nav-link" href="services">
<i class="bi bi-calendar-plus-fill me-2" aria-hidden="true"></i> Services
</NavLink>
</div>
</nav>
</div>

View File

@@ -0,0 +1,105 @@
.navbar-toggler {
appearance: none;
cursor: pointer;
width: 3.5rem;
height: 2.5rem;
color: white;
position: absolute;
top: 0.5rem;
right: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
}
.navbar-toggler:checked {
background-color: rgba(255, 255, 255, 0.5);
}
.top-row {
height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
.navbar-brand {
font-size: 1.1rem;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep .nav-link {
color: #d7d7d7;
background: none;
border: none;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
width: 100%;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep .nav-link:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
.nav-scrollable {
display: none;
}
.navbar-toggler:checked ~ .nav-scrollable {
display: block;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.nav-scrollable {
/* Never collapse the sidebar for wide screens */
display: block;
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}

View File

@@ -0,0 +1,36 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}

View File

@@ -0,0 +1,103 @@
@page "/"
@using NimbusFlow.Frontend.Services
@using NimbusFlow.Frontend.Models
@inject IApiService ApiService
<PageTitle>NimbusFlow Dashboard</PageTitle>
<h1 class="nimbus-page-title">
<i class="bi bi-speedometer2 me-3"></i>NimbusFlow Dashboard
</h1>
<div class="row g-4">
<div class="col-md-3">
<div class="card nimbus-dashboard-card card-members mb-3">
<div class="card-header d-flex align-items-center">
<i class="bi bi-people-fill me-2"></i>
Active Members
</div>
<div class="card-body text-center">
<h2 class="card-title mb-0">@activeMemberCount</h2>
<small class="opacity-75">Currently Active</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card nimbus-dashboard-card card-schedules mb-3">
<div class="card-header d-flex align-items-center">
<i class="bi bi-calendar-event-fill me-2"></i>
Pending Schedules
</div>
<div class="card-body text-center">
<h2 class="card-title mb-0">@pendingScheduleCount</h2>
<small class="opacity-75">Awaiting Response</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card nimbus-dashboard-card card-services mb-3">
<div class="card-header d-flex align-items-center">
<i class="bi bi-calendar-plus-fill me-2"></i>
Upcoming Services
</div>
<div class="card-body text-center">
<h2 class="card-title mb-0">@upcomingServiceCount</h2>
<small class="opacity-75">This Week</small>
</div>
</div>
</div>
</div>
<div class="row mt-5">
<div class="col-md-12">
<div class="nimbus-quick-actions">
<h5><i class="bi bi-lightning-charge me-2"></i>Quick Actions</h5>
<a href="/schedules/schedule-next" class="btn btn-nimbus-primary me-3 mb-2">
<i class="bi bi-calendar-plus-fill me-2"></i>Schedule Next Member
</a>
<a href="/members" class="btn btn-success me-3 mb-2">
<i class="bi bi-person-plus-fill me-2"></i>Add New Member
</a>
<a href="/services" class="btn btn-outline-warning me-3 mb-2">
<i class="bi bi-gear-wide-connected me-2"></i>Create New Service
</a>
<a href="/schedules" class="btn btn-outline-secondary me-3 mb-2">
<i class="bi bi-list-check me-2"></i>View All Schedules
</a>
</div>
</div>
</div>
@code {
private int activeMemberCount = 0;
private int pendingScheduleCount = 0;
private int upcomingServiceCount = 0;
protected override async Task OnInitializedAsync()
{
try
{
// Load dashboard data
var members = await ApiService.GetMembersAsync();
activeMemberCount = members.Count(m => m.IsActive == 1);
var schedules = await ApiService.GetSchedulesAsync();
pendingScheduleCount = schedules.Count(s => s.Status == "pending");
var services = await ApiService.GetServicesAsync();
upcomingServiceCount = services.Count(s => s.ServiceDate >= DateTime.Today);
}
catch (Exception ex)
{
// Handle API errors gracefully
Console.WriteLine($"Error loading dashboard data: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,199 @@
@page "/members"
@using NimbusFlow.Frontend.Services
@using NimbusFlow.Frontend.Models
@inject IApiService ApiService
@inject NavigationManager Navigation
@inject IJSRuntime JSRuntime
<PageTitle>Members</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="nimbus-page-title">
<i class="bi bi-people-fill me-3"></i>Members
</h1>
<a href="/members/create" class="btn btn-nimbus-primary">
<i class="bi bi-person-plus-fill me-2"></i>Add Member
</a>
</div>
@if (loading)
{
<div class="text-center py-5">
<div class="spinner-border nimbus-spinner" role="status" style="width: 3rem; height: 3rem;">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Loading members...</p>
</div>
}
else if (members.Any())
{
<!-- Filter Controls -->
<div class="row mb-3">
<div class="col-md-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="showInactiveMembers" id="showInactiveCheck">
<label class="form-check-label" for="showInactiveCheck">
Show inactive members
</label>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Name</th>
<th>Classification</th>
<th>Email</th>
<th>Phone</th>
<th>Status</th>
<th>Last Accepted</th>
<th>Decline Streak</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var member in filteredMembers)
{
<tr>
<td>
<strong>@member.FullName</strong>
</td>
<td>
<span class="badge badge-nimbus-classification">@member.ClassificationName</span>
</td>
<td>@member.Email</td>
<td>@member.PhoneNumber</td>
<td>
@if (member.IsActive == 1)
{
<span class="badge badge-nimbus-active">
<i class="bi bi-check-circle-fill me-1"></i>Active
</span>
}
else
{
<span class="badge badge-nimbus-inactive">
<i class="bi bi-x-circle-fill me-1"></i>Inactive
</span>
}
</td>
<td>
@if (member.LastAcceptedAt.HasValue)
{
@member.LastAcceptedAt.Value.ToString("MMM dd, yyyy")
}
else
{
<span class="text-muted">Never</span>
}
</td>
<td>
@if (member.DeclineStreak > 0)
{
<span class="badge badge-nimbus-pending">
<i class="bi bi-exclamation-triangle-fill me-1"></i>@member.DeclineStreak
</span>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td>
<div class="btn-group" role="group">
<a href="/members/@member.MemberId" class="btn btn-sm btn-nimbus-secondary me-1">
<i class="bi bi-eye-fill me-1"></i>View
</a>
<a href="/members/@member.MemberId/edit" class="btn btn-sm btn-outline-warning">
<i class="bi bi-pencil-fill me-1"></i>Edit
</a>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(member)">Delete</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-md-12">
<small class="text-muted">
Showing @filteredMembers.Count() of @members.Count members
</small>
</div>
</div>
}
else
{
<div class="text-center">
<div class="alert alert-info">
<h4>No Members Found</h4>
<p>There are currently no members in the system.</p>
<a href="/members/create" class="btn btn-primary">Add Your First Member</a>
</div>
</div>
}
@code {
private List<Member> members = new();
private bool loading = true;
private bool showInactiveMembers = false;
private IEnumerable<Member> filteredMembers =>
showInactiveMembers ? members : members.Where(m => m.IsActive == 1);
protected override async Task OnInitializedAsync()
{
await LoadMembers();
}
private async Task LoadMembers()
{
try
{
loading = true;
members = await ApiService.GetMembersAsync();
}
catch (Exception ex)
{
// Handle error (could show toast notification)
Console.WriteLine($"Error loading members: {ex.Message}");
}
finally
{
loading = false;
}
}
private async Task ConfirmDelete(Member member)
{
var confirmed = await JSRuntime.InvokeAsync<bool>("confirm", $"Are you sure you want to delete {member.FullName}?");
if (confirmed)
{
try
{
var success = await ApiService.DeleteMemberAsync(member.MemberId);
if (success)
{
await LoadMembers(); // Refresh the list
}
}
catch (Exception ex)
{
Console.WriteLine($"Error deleting member: {ex.Message}");
}
}
}
}

View File

@@ -0,0 +1,303 @@
@page "/schedules"
@using NimbusFlow.Frontend.Services
@using NimbusFlow.Frontend.Models
@inject IApiService ApiService
@inject IJSRuntime JSRuntime
<PageTitle>Schedules</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="nimbus-page-title">
<i class="bi bi-calendar3 me-3"></i>Schedules
</h1>
<a href="/schedules/create" class="btn btn-nimbus-primary">
<i class="bi bi-calendar-plus-fill me-2"></i>Schedule Member
</a>
</div>
@if (loading)
{
<div class="text-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
}
else if (schedules.Any())
{
<!-- Filter Controls -->
<div class="row mb-3">
<div class="col-md-4">
<select class="form-select" @bind="selectedStatus">
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="accepted">Accepted</option>
<option value="declined">Declined</option>
</select>
</div>
<div class="col-md-4">
<input type="date" class="form-control" @bind="filterDate" />
</div>
<div class="col-md-4">
<button class="btn btn-outline-secondary" @onclick="ClearFilters">Clear Filters</button>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Member</th>
<th>Service Date</th>
<th>Service Type</th>
<th>Status</th>
<th>Scheduled At</th>
<th>Response Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var schedule in filteredSchedules.OrderByDescending(s => s.ScheduledAt))
{
<tr>
<td>
<strong>@schedule.Member?.FullName</strong>
</td>
<td>
@schedule.Service?.ServiceDate.ToString("MMM dd, yyyy")
</td>
<td>
<span class="badge" style="background-color: var(--nimbus-gold); color: var(--nimbus-navy);">@schedule.Service?.ServiceTypeName</span>
</td>
<td>
<span class="badge @GetStatusBadgeClass(schedule.Status)">
@schedule.Status.ToUpper()
</span>
</td>
<td>
@schedule.ScheduledAt.ToString("MMM dd, yyyy HH:mm")
</td>
<td>
@if (schedule.AcceptedAt.HasValue)
{
@schedule.AcceptedAt.Value.ToString("MMM dd, yyyy HH:mm")
}
else if (schedule.DeclinedAt.HasValue)
{
@schedule.DeclinedAt.Value.ToString("MMM dd, yyyy HH:mm")
}
else
{
<span class="text-muted">-</span>
}
</td>
<td>
<div class="btn-group" role="group">
<button class="btn btn-sm btn-success me-1"
disabled="@(schedule.Status != "pending")"
@onclick="() => AcceptSchedule(schedule.ScheduleId)">
<i class="bi bi-check-circle-fill me-1"></i>Accept
</button>
<button class="btn btn-sm btn-warning me-1"
disabled="@(schedule.Status != "pending")"
@onclick="() => ShowDeclineModal(schedule)">
<i class="bi bi-x-circle-fill me-1"></i>Decline
</button>
<a href="/schedules/@schedule.ScheduleId" class="btn btn-sm btn-nimbus-secondary me-1">
<i class="bi bi-eye-fill me-1"></i>View
</a>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmRemove(schedule)">
<i class="bi bi-trash-fill me-1"></i>Remove
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-md-12">
<small class="text-muted">
Showing @filteredSchedules.Count() of @schedules.Count schedules
</small>
</div>
</div>
}
else
{
<div class="text-center">
<div class="alert alert-info">
<h4>No Schedules Found</h4>
<p>There are currently no schedules in the system.</p>
<a href="/schedules/create" class="btn btn-primary">Create Your First Schedule</a>
</div>
</div>
}
<!-- Decline Modal -->
@if (showDeclineModal)
{
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,0.5);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Decline Schedule</h5>
<button type="button" class="btn-close" @onclick="HideDeclineModal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Decline Reason (Optional)</label>
<textarea class="form-control" @bind="declineReason" rows="3" placeholder="Enter reason for declining..."></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="HideDeclineModal">Cancel</button>
<button type="button" class="btn btn-warning" @onclick="ConfirmDecline">Decline Schedule</button>
</div>
</div>
</div>
</div>
}
@code {
private List<Schedule> schedules = new();
private bool loading = true;
private string selectedStatus = "";
private DateTime? filterDate;
private bool showDeclineModal = false;
private Schedule? scheduleToDecline;
private string declineReason = "";
private IEnumerable<Schedule> filteredSchedules
{
get
{
var filtered = schedules.AsEnumerable();
if (!string.IsNullOrEmpty(selectedStatus))
{
filtered = filtered.Where(s => s.Status == selectedStatus);
}
if (filterDate.HasValue)
{
filtered = filtered.Where(s => s.Service?.ServiceDate.Date == filterDate.Value.Date);
}
return filtered;
}
}
protected override async Task OnInitializedAsync()
{
await LoadSchedules();
}
private async Task LoadSchedules()
{
try
{
loading = true;
schedules = await ApiService.GetSchedulesAsync();
}
catch (Exception ex)
{
Console.WriteLine($"Error loading schedules: {ex.Message}");
}
finally
{
loading = false;
}
}
private string GetStatusBadgeClass(string status)
{
return status switch
{
"pending" => "badge-nimbus-pending",
"accepted" => "badge-nimbus-accepted",
"declined" => "badge-nimbus-declined",
_ => "badge-nimbus-inactive"
};
}
private async Task AcceptSchedule(int scheduleId)
{
try
{
await ApiService.AcceptScheduleAsync(scheduleId);
await LoadSchedules(); // Refresh the list
}
catch (Exception ex)
{
Console.WriteLine($"Error accepting schedule: {ex.Message}");
}
}
private void ShowDeclineModal(Schedule schedule)
{
scheduleToDecline = schedule;
declineReason = "";
showDeclineModal = true;
}
private void HideDeclineModal()
{
showDeclineModal = false;
scheduleToDecline = null;
declineReason = "";
}
private async Task ConfirmDecline()
{
if (scheduleToDecline != null)
{
try
{
await ApiService.DeclineScheduleAsync(scheduleToDecline.ScheduleId, declineReason);
await LoadSchedules(); // Refresh the list
HideDeclineModal();
}
catch (Exception ex)
{
Console.WriteLine($"Error declining schedule: {ex.Message}");
}
}
}
private async Task ConfirmRemove(Schedule schedule)
{
var confirmed = await JSRuntime.InvokeAsync<bool>("confirm", $"Are you sure you want to remove the schedule for {schedule.Member?.FullName}?");
if (confirmed)
{
try
{
var success = await ApiService.RemoveScheduleAsync(schedule.ScheduleId);
if (success)
{
await LoadSchedules(); // Refresh the list
}
}
catch (Exception ex)
{
Console.WriteLine($"Error removing schedule: {ex.Message}");
}
}
}
private void ClearFilters()
{
selectedStatus = "";
filterDate = null;
}
}

View File

@@ -0,0 +1,236 @@
@page "/services"
@using NimbusFlow.Frontend.Services
@using NimbusFlow.Frontend.Models
@inject IApiService ApiService
<PageTitle>Services</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="nimbus-page-title">
<i class="bi bi-gear-fill me-3"></i>Services
</h1>
<a href="/services/create" class="btn btn-nimbus-primary">
<i class="bi bi-gear-wide-connected me-2"></i>Create Service
</a>
</div>
@if (loading)
{
<div class="text-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
}
else if (services.Any())
{
<!-- Filter Controls -->
<div class="row mb-3">
<div class="col-md-4">
<select class="form-select" @bind="selectedServiceType">
<option value="">All Service Types</option>
@foreach (var serviceType in serviceTypes)
{
<option value="@serviceType.ServiceTypeId">@serviceType.TypeName</option>
}
</select>
</div>
<div class="col-md-4">
<input type="date" class="form-control" @bind="filterDate" />
</div>
<div class="col-md-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="showPastServices" id="showPast">
<label class="form-check-label" for="showPast">
Show past services
</label>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Date</th>
<th>Service Type</th>
<th>Scheduled Members</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var service in filteredServices.OrderBy(s => s.ServiceDate))
{
<tr class="@(service.ServiceDate < DateTime.Today ? "text-muted" : "")">
<td>
<strong>@service.ServiceDate.ToString("MMM dd, yyyy (dddd)")</strong>
</td>
<td>
<span class="badge" style="background-color: var(--nimbus-gold); color: var(--nimbus-navy);">
@service.ServiceTypeName
</span>
</td>
<td>
@{
var serviceSchedules = schedules.Where(s => s.ServiceId == service.ServiceId).ToList();
var acceptedCount = serviceSchedules.Count(s => s.Status == "accepted");
var pendingCount = serviceSchedules.Count(s => s.Status == "pending");
var declinedCount = serviceSchedules.Count(s => s.Status == "declined");
}
<div class="d-flex gap-1">
@if (acceptedCount > 0)
{
<span class="badge bg-success">@acceptedCount accepted</span>
}
@if (pendingCount > 0)
{
<span class="badge bg-warning text-dark">@pendingCount pending</span>
}
@if (declinedCount > 0)
{
<span class="badge bg-danger">@declinedCount declined</span>
}
@if (serviceSchedules.Count == 0)
{
<span class="text-muted">No schedules</span>
}
</div>
</td>
<td>
@if (service.ServiceDate < DateTime.Today)
{
<span class="badge bg-secondary">Past</span>
}
else if (service.ServiceDate == DateTime.Today)
{
<span class="badge bg-primary">Today</span>
}
else
{
<span class="badge bg-success">Upcoming</span>
}
</td>
<td>
<div class="btn-group" role="group">
<a href="/services/@service.ServiceId" class="btn btn-sm btn-nimbus-secondary me-1">
<i class="bi bi-eye-fill me-1"></i>View
</a>
@if (service.ServiceDate >= DateTime.Today)
{
<a href="/schedules/create?serviceId=@service.ServiceId" class="btn btn-sm btn-success me-1">
<i class="bi bi-calendar-plus-fill me-1"></i>Schedule
</a>
<a href="/services/@service.ServiceId/edit" class="btn btn-sm btn-outline-warning">
<i class="bi bi-pencil-fill me-1"></i>Edit
</a>
}
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-md-12">
<small class="text-muted">
Showing @filteredServices.Count() of @services.Count services
</small>
</div>
</div>
}
else
{
<div class="text-center">
<div class="alert alert-info">
<h4>No Services Found</h4>
<p>There are currently no services in the system.</p>
<a href="/services/create" class="btn btn-primary">Create Your First Service</a>
</div>
</div>
}
@code {
private List<Service> services = new();
private List<ServiceType> serviceTypes = new();
private List<Schedule> schedules = new();
private bool loading = true;
private string selectedServiceType = "";
private DateTime? filterDate;
private bool showPastServices = false;
private IEnumerable<Service> filteredServices
{
get
{
var filtered = services.AsEnumerable();
if (!showPastServices)
{
filtered = filtered.Where(s => s.ServiceDate >= DateTime.Today);
}
if (!string.IsNullOrEmpty(selectedServiceType) && int.TryParse(selectedServiceType, out int typeId))
{
filtered = filtered.Where(s => s.ServiceTypeId == typeId);
}
if (filterDate.HasValue)
{
filtered = filtered.Where(s => s.ServiceDate.Date == filterDate.Value.Date);
}
return filtered;
}
}
protected override async Task OnInitializedAsync()
{
await LoadData();
}
private async Task LoadData()
{
try
{
loading = true;
// Load all data in parallel
var servicesTask = ApiService.GetServicesAsync();
var serviceTypesTask = ApiService.GetServiceTypesAsync();
var schedulesTask = ApiService.GetSchedulesAsync();
await Task.WhenAll(servicesTask, serviceTypesTask, schedulesTask);
services = servicesTask.Result;
serviceTypes = serviceTypesTask.Result;
schedules = schedulesTask.Result;
// Map service type names to services
foreach (var service in services)
{
var serviceType = serviceTypes.FirstOrDefault(st => st.ServiceTypeId == service.ServiceTypeId);
service.ServiceTypeName = serviceType?.TypeName ?? "Unknown";
}
}
catch (Exception ex)
{
Console.WriteLine($"Error loading services data: {ex.Message}");
}
finally
{
loading = false;
}
}
}

View File

@@ -0,0 +1,6 @@
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>

View File

@@ -0,0 +1,10 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using NimbusFlow.Frontend
@using NimbusFlow.Frontend.Components

View File

@@ -0,0 +1,7 @@
namespace NimbusFlow.Frontend.Models;
public class Classification
{
public int ClassificationId { get; set; }
public string ClassificationName { get; set; } = string.Empty;
}

21
frontend/Models/Member.cs Normal file
View File

@@ -0,0 +1,21 @@
namespace NimbusFlow.Frontend.Models;
public class Member
{
public int MemberId { get; set; }
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public string? Email { get; set; }
public string? PhoneNumber { get; set; }
public int? ClassificationId { get; set; }
public string? Notes { get; set; }
public int IsActive { get; set; } = 1;
public DateTime? LastScheduledAt { get; set; }
public DateTime? LastAcceptedAt { get; set; }
public DateTime? LastDeclinedAt { get; set; }
public int DeclineStreak { get; set; } = 0;
// Navigation properties
public string? ClassificationName { get; set; }
public string FullName => $"{FirstName} {LastName}";
}

View File

@@ -0,0 +1,18 @@
namespace NimbusFlow.Frontend.Models;
public class Schedule
{
public int ScheduleId { get; set; }
public int ServiceId { get; set; }
public int MemberId { get; set; }
public string Status { get; set; } = string.Empty; // pending, accepted, declined
public DateTime ScheduledAt { get; set; }
public DateTime? AcceptedAt { get; set; }
public DateTime? DeclinedAt { get; set; }
public DateTime? ExpiresAt { get; set; }
public string? DeclineReason { get; set; }
// Navigation properties
public Member? Member { get; set; }
public Service? Service { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace NimbusFlow.Frontend.Models;
public class Service
{
public int ServiceId { get; set; }
public int ServiceTypeId { get; set; }
public DateTime ServiceDate { get; set; }
public string? ServiceTypeName { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace NimbusFlow.Frontend.Models;
public class ServiceType
{
public int ServiceTypeId { get; set; }
public string TypeName { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

34
frontend/Program.cs Normal file
View File

@@ -0,0 +1,34 @@
using NimbusFlow.Frontend.Components;
using NimbusFlow.Frontend.Services;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
// Add HTTP client for backend API communication
builder.Services.AddHttpClient<IApiService, ApiService>(client =>
{
client.BaseAddress = new Uri("http://localhost:8000/api/"); // Python backend API endpoint
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseAntiforgery();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();

View File

@@ -0,0 +1,38 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:4068",
"sslPort": 44352
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5059",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7176;http://localhost:5059",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -1,69 +1,127 @@
# React + TypeScript + Vite
# NimbusFlow Frontend - Blazor Server Application
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
A modern web application built with .NET 8 Blazor Server for managing member scheduling in the NimbusFlow system.
Currently, two official plugins are available:
## Features
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
- **Dashboard**: Overview of system statistics and recent activities
- **Member Management**: Complete CRUD operations for choir members
- **Schedule Management**: View, accept, decline, and manage member schedules
- **Service Management**: Create and manage service events
- **Real-time Updates**: Server-side rendering with SignalR for real-time updates
- **Responsive Design**: Bootstrap-based UI that works on all devices
## Expanding the ESLint configuration
## Architecture
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
This Blazor Server application follows a clean architecture pattern:
```js
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
- **Components/Pages**: Razor components for each major feature
- **Services**: HTTP client services for backend API communication
- **Models**: C# data models matching the Python backend schema
- **Layout**: Consistent navigation and layout components
// Remove tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
## Prerequisites
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
- .NET 8.0 SDK
- Python backend API running on http://localhost:8000/api/
## Getting Started
1. **Restore packages**:
```bash
dotnet restore
```
2. **Run the application**:
```bash
dotnet watch # with hot reload
# or
dotnet run # without hot reload
```
3. **Access the application**:
- HTTPS: https://localhost:5001
- HTTP: http://localhost:5000
## API Configuration
The application is configured to communicate with the Python backend API. Update the base URL in `Program.cs` if your backend runs on a different port:
```csharp
builder.Services.AddHttpClient<IApiService, ApiService>(client =>
{
client.BaseAddress = new Uri("http://localhost:8000/api/");
});
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
## Project Structure
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
├── Components/
│ ├── Layout/
│ │ ├── MainLayout.razor # Main page layout
│ │ └── NavMenu.razor # Navigation menu
│ └── Pages/
│ ├── Home.razor # Dashboard page
│ ├── Members.razor # Member management
│ ├── Schedules.razor # Schedule management
│ └── Services.razor # Service management
├── Models/
│ └── Member.cs # Data models
├── Services/
│ ├── IApiService.cs # Service interface
│ └── ApiService.cs # HTTP API client
├── wwwroot/ # Static files
├── Program.cs # Application startup
└── appsettings.json # Configuration
```
## Key Features
### Dashboard
- System statistics (active members, pending schedules, etc.)
- Recent schedule activities
- Quick action buttons
### Member Management
- List all members with filtering options
- Add/edit member information
- View member scheduling history
- Manage member classifications
### Schedule Management
- View all schedules with filtering
- Accept/decline pending schedules
- Remove schedules
- View schedule details
### Service Management
- Create and manage service events
- View service schedules and assignments
- Filter by date and service type
## Development
The application uses Blazor Server which provides:
- Real-time UI updates via SignalR
- Server-side rendering for better performance
- C# development throughout the stack
- Automatic state management
## Deployment
To build for production:
```bash
dotnet publish -c Release
```
The published application will be in `bin/Release/net8.0/publish/`.
## Dependencies
- ASP.NET Core 8.0
- Blazor Server
- Bootstrap 5 (included in template)
- System.Text.Json for API serialization

View File

@@ -0,0 +1,182 @@
using System.Text;
using System.Text.Json;
using NimbusFlow.Frontend.Models;
namespace NimbusFlow.Frontend.Services
{
public class ApiService : IApiService
{
private readonly HttpClient _httpClient;
private readonly JsonSerializerOptions _jsonOptions;
public ApiService(HttpClient httpClient)
{
_httpClient = httpClient;
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
}
// Member operations
public async Task<List<Member>> GetMembersAsync()
{
var response = await _httpClient.GetAsync("members");
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<List<Member>>(json, _jsonOptions) ?? new List<Member>();
}
public async Task<Member?> GetMemberAsync(int id)
{
var response = await _httpClient.GetAsync($"members/{id}");
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<Member>(json, _jsonOptions);
}
public async Task<Member> CreateMemberAsync(Member member)
{
var json = JsonSerializer.Serialize(member, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync("members", content);
response.EnsureSuccessStatusCode();
var responseJson = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<Member>(responseJson, _jsonOptions) ?? member;
}
public async Task<Member> UpdateMemberAsync(Member member)
{
var json = JsonSerializer.Serialize(member, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _httpClient.PutAsync($"members/{member.MemberId}", content);
response.EnsureSuccessStatusCode();
var responseJson = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<Member>(responseJson, _jsonOptions) ?? member;
}
public async Task<bool> DeleteMemberAsync(int id)
{
var response = await _httpClient.DeleteAsync($"members/{id}");
return response.IsSuccessStatusCode;
}
// Classification operations
public async Task<List<Classification>> GetClassificationsAsync()
{
var response = await _httpClient.GetAsync("classifications");
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<List<Classification>>(json, _jsonOptions) ?? new List<Classification>();
}
// Service operations
public async Task<List<Service>> GetServicesAsync()
{
var response = await _httpClient.GetAsync("services");
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<List<Service>>(json, _jsonOptions) ?? new List<Service>();
}
public async Task<Service?> GetServiceAsync(int id)
{
var response = await _httpClient.GetAsync($"services/{id}");
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<Service>(json, _jsonOptions);
}
public async Task<Service> CreateServiceAsync(Service service)
{
var json = JsonSerializer.Serialize(service, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync("services", content);
response.EnsureSuccessStatusCode();
var responseJson = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<Service>(responseJson, _jsonOptions) ?? service;
}
// Schedule operations
public async Task<List<Schedule>> GetSchedulesAsync()
{
var response = await _httpClient.GetAsync("schedules");
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<List<Schedule>>(json, _jsonOptions) ?? new List<Schedule>();
}
public async Task<List<Schedule>> GetMemberSchedulesAsync(int memberId)
{
var response = await _httpClient.GetAsync($"members/{memberId}/schedules");
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<List<Schedule>>(json, _jsonOptions) ?? new List<Schedule>();
}
public async Task<Schedule?> GetScheduleAsync(int id)
{
var response = await _httpClient.GetAsync($"schedules/{id}");
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<Schedule>(json, _jsonOptions);
}
public async Task<Schedule> AcceptScheduleAsync(int scheduleId)
{
var response = await _httpClient.PostAsync($"schedules/{scheduleId}/accept", null);
response.EnsureSuccessStatusCode();
var responseJson = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<Schedule>(responseJson, _jsonOptions) ?? new Schedule();
}
public async Task<Schedule> DeclineScheduleAsync(int scheduleId, string? reason = null)
{
var content = new StringContent(
JsonSerializer.Serialize(new { reason }, _jsonOptions),
Encoding.UTF8,
"application/json"
);
var response = await _httpClient.PostAsync($"schedules/{scheduleId}/decline", content);
response.EnsureSuccessStatusCode();
var responseJson = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<Schedule>(responseJson, _jsonOptions) ?? new Schedule();
}
public async Task<bool> RemoveScheduleAsync(int scheduleId)
{
var response = await _httpClient.DeleteAsync($"schedules/{scheduleId}");
return response.IsSuccessStatusCode;
}
public async Task<Schedule?> ScheduleNextMemberAsync(int serviceId, List<int> classificationIds)
{
var content = new StringContent(
JsonSerializer.Serialize(new { serviceId, classificationIds }, _jsonOptions),
Encoding.UTF8,
"application/json"
);
var response = await _httpClient.PostAsync("schedules/schedule-next", content);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
var responseJson = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<Schedule>(responseJson, _jsonOptions);
}
public async Task<List<ServiceType>> GetServiceTypesAsync()
{
var response = await _httpClient.GetAsync("service-types");
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<List<ServiceType>>(json, _jsonOptions) ?? new List<ServiceType>();
}
}
}

View File

@@ -0,0 +1,36 @@
using NimbusFlow.Frontend.Models;
namespace NimbusFlow.Frontend.Services
{
public interface IApiService
{
// Member operations
Task<List<Member>> GetMembersAsync();
Task<Member?> GetMemberAsync(int id);
Task<Member> CreateMemberAsync(Member member);
Task<Member> UpdateMemberAsync(Member member);
Task<bool> DeleteMemberAsync(int id);
// Classification operations
Task<List<Classification>> GetClassificationsAsync();
// Service operations
Task<List<Service>> GetServicesAsync();
Task<Service?> GetServiceAsync(int id);
Task<Service> CreateServiceAsync(Service service);
// Schedule operations
Task<List<Schedule>> GetSchedulesAsync();
Task<List<Schedule>> GetMemberSchedulesAsync(int memberId);
Task<Schedule?> GetScheduleAsync(int id);
Task<Schedule> AcceptScheduleAsync(int scheduleId);
Task<Schedule> DeclineScheduleAsync(int scheduleId, string? reason = null);
Task<bool> RemoveScheduleAsync(int scheduleId);
// Scheduling operations
Task<Schedule?> ScheduleNextMemberAsync(int serviceId, List<int> classificationIds);
// Service Types
Task<List<ServiceType>> GetServiceTypesAsync();
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@@ -1,23 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +0,0 @@
{
"name": "nimbusflow",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react-swc": "^4.0.0",
"eslint": "^9.33.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.39.1",
"vite": "^7.1.2"
}
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -1,35 +0,0 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
function App() {
const [count, setCount] = useState(0)
return (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
}
export default App

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -1,68 +0,0 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@@ -1,10 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@@ -1,27 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -1,25 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -1,7 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})

51
frontend/wwwroot/app.css Normal file
View File

@@ -0,0 +1,51 @@
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
a, .btn-link {
color: #006bb7;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
.content {
padding-top: 1.1rem;
}
h1:focus {
outline: none;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid #e50000;
}
.validation-message {
color: #e50000;
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.darker-border-checkbox.form-check-input {
border-color: #929292;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,641 @@
/* NimbusFlow Custom Styles */
:root {
/* Primary Brand Colors */
--nimbus-navy: #1a2332; /* Deep navy background */
--nimbus-navy-light: #2a3441; /* Lighter navy variant */
--nimbus-navy-dark: #0f1419; /* Darker navy variant */
/* Golden Accent Colors */
--nimbus-gold: #ffd700; /* Primary gold */
--nimbus-gold-light: #ffed4e; /* Light gold highlight */
--nimbus-gold-dark: #e6c200; /* Dark gold shadow */
--nimbus-amber: #ffb347; /* Warm amber accent */
/* Neutral Colors */
--nimbus-white: #ffffff; /* Pure white */
--nimbus-gray-100: #f8f9fa; /* Light gray */
--nimbus-gray-300: #dee2e6; /* Medium light gray */
--nimbus-gray-600: #6c757d; /* Medium gray */
--nimbus-gray-800: #343a40; /* Dark gray */
/* Bootstrap overrides */
--bs-primary: var(--nimbus-navy);
--bs-primary-rgb: 26, 35, 50;
--bs-secondary: var(--nimbus-gold);
--bs-secondary-rgb: 255, 215, 0;
--bs-warning: var(--nimbus-amber);
--bs-warning-rgb: 255, 179, 71;
}
/* Body and base styles - Dark Mode */
body {
background-color: var(--nimbus-navy-dark);
color: var(--nimbus-white);
}
.content {
background-color: var(--nimbus-navy-dark);
min-height: 100vh;
}
/* Navigation styles */
.nimbus-navbar {
background-color: var(--nimbus-navy) !important;
border-bottom: 3px solid var(--nimbus-gold);
box-shadow: 0 2px 10px rgba(26, 35, 50, 0.2);
}
.nimbus-navbar .navbar-brand {
color: var(--nimbus-white) !important;
font-weight: 600;
font-size: 1.5rem;
}
.nimbus-navbar .navbar-brand:hover {
color: var(--nimbus-gold-light) !important;
}
.nimbus-navbar .nav-link {
color: var(--nimbus-white) !important;
font-weight: 500;
transition: color 0.3s ease;
}
.nimbus-navbar .nav-link:hover,
.nimbus-navbar .nav-link.active {
color: var(--nimbus-gold) !important;
background-color: rgba(255, 215, 0, 0.1);
border-radius: 0.375rem;
}
/* Brand icon */
.nimbus-brand-icon {
color: var(--nimbus-gold);
margin-right: 0.5rem;
font-size: 1.8rem;
}
/* Card styles - Dark Mode */
.nimbus-card {
border: none;
border-radius: 0.75rem;
background-color: var(--nimbus-navy);
color: var(--nimbus-white);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.nimbus-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.4);
}
.nimbus-card-header {
background-color: var(--nimbus-navy-light);
color: var(--nimbus-white);
border-radius: 0.75rem 0.75rem 0 0 !important;
padding: 1rem 1.25rem;
font-weight: 600;
}
/* Button styles */
.btn-nimbus-primary {
background-color: var(--nimbus-gold);
border-color: var(--nimbus-gold-dark);
color: var(--nimbus-navy);
font-weight: 600;
border-radius: 0.5rem;
transition: all 0.3s ease;
}
.btn-nimbus-primary:hover {
background-color: var(--nimbus-gold-dark);
border-color: var(--nimbus-gold-dark);
color: var(--nimbus-navy);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(255, 215, 0, 0.3);
}
.btn-nimbus-secondary {
background-color: var(--nimbus-navy);
border-color: var(--nimbus-navy);
color: var(--nimbus-white);
font-weight: 600;
border-radius: 0.5rem;
transition: all 0.3s ease;
}
.btn-nimbus-secondary:hover {
background-color: var(--nimbus-navy-light);
border-color: var(--nimbus-navy-light);
color: var(--nimbus-white);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(26, 35, 50, 0.3);
}
/* Badge styles */
.badge-nimbus-active {
background-color: var(--nimbus-gold);
color: var(--nimbus-navy);
font-weight: 600;
}
.badge-nimbus-inactive {
background-color: var(--nimbus-gray-600);
color: var(--nimbus-white);
}
.badge-nimbus-pending {
background-color: var(--nimbus-amber);
color: var(--nimbus-navy);
font-weight: 600;
}
.badge-nimbus-accepted {
background-color: #28a745;
color: var(--nimbus-white);
font-weight: 600;
}
.badge-nimbus-declined {
background-color: #dc3545;
color: var(--nimbus-white);
font-weight: 600;
}
.badge-nimbus-classification {
background-color: var(--nimbus-gray-600) !important;
color: var(--nimbus-white) !important;
font-weight: 600;
}
/* Dashboard cards - Refined Dark Mode */
.nimbus-dashboard-card {
border: none;
border-radius: 0.75rem;
overflow: hidden;
position: relative;
transition: all 0.2s ease;
background: linear-gradient(135deg, var(--nimbus-navy-light) 0%, var(--nimbus-navy) 100%);
color: var(--nimbus-white);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 215, 0, 0.1);
border-left: 3px solid transparent;
}
.nimbus-dashboard-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 215, 0, 0.2);
}
.nimbus-dashboard-card .card-header {
background: rgba(255, 215, 0, 0.05);
border-bottom: 1px solid rgba(255, 215, 0, 0.15);
padding: 1rem 1.25rem 0.75rem;
color: var(--nimbus-gray-300);
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
}
.nimbus-dashboard-card .card-body {
padding: 1rem 1.25rem 1.25rem;
}
.nimbus-dashboard-card .card-title {
font-size: 2.25rem;
font-weight: 800;
line-height: 1;
margin-bottom: 0.25rem;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
.nimbus-dashboard-card .card-body small {
color: var(--nimbus-gray-300);
font-size: 0.8rem;
font-weight: 500;
opacity: 0.8;
}
.nimbus-dashboard-card.card-members {
border-left-color: #4dabf7;
}
.nimbus-dashboard-card.card-members .card-title {
color: #4dabf7;
}
.nimbus-dashboard-card.card-members .card-header i {
color: #4dabf7;
}
.nimbus-dashboard-card.card-schedules {
border-left-color: var(--nimbus-gold);
}
.nimbus-dashboard-card.card-schedules .card-title {
color: var(--nimbus-gold);
}
.nimbus-dashboard-card.card-schedules .card-header i {
color: var(--nimbus-gold);
}
.nimbus-dashboard-card.card-services {
border-left-color: var(--nimbus-amber);
}
.nimbus-dashboard-card.card-services .card-title {
color: var(--nimbus-amber);
}
.nimbus-dashboard-card.card-services .card-header i {
color: var(--nimbus-amber);
}
.nimbus-dashboard-card.card-classifications {
border-left-color: #8b5cf6;
}
.nimbus-dashboard-card.card-classifications .card-title {
color: #8b5cf6;
}
.nimbus-dashboard-card.card-classifications .card-header i {
color: #8b5cf6;
}
/* Table styles - Dark Mode */
.nimbus-table {
border-radius: 0 0 0.75rem 0.75rem;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
background-color: var(--nimbus-navy);
}
.nimbus-table thead th {
background-color: var(--nimbus-navy-light);
color: var(--nimbus-white);
border: none;
font-weight: 600;
padding: 1rem 0.75rem;
}
.nimbus-table tbody tr {
background-color: var(--nimbus-navy);
color: var(--nimbus-white);
border-color: rgba(255, 215, 0, 0.1);
}
.nimbus-table tbody tr:hover {
background-color: rgba(255, 215, 0, 0.15);
}
.nimbus-table tbody td {
border-color: rgba(255, 215, 0, 0.1);
}
/* Page titles - Dark Mode */
.nimbus-page-title {
color: var(--nimbus-white);
font-weight: 700;
margin-bottom: 1.5rem;
position: relative;
}
.nimbus-page-title::after {
content: '';
position: absolute;
bottom: -0.5rem;
left: 0;
width: 3rem;
height: 0.25rem;
background-color: var(--nimbus-gold);
border-radius: 0.125rem;
}
/* Loading spinner */
.nimbus-spinner {
color: var(--nimbus-gold);
}
/* Form controls */
.form-control:focus {
border-color: var(--nimbus-gold);
box-shadow: 0 0 0 0.2rem rgba(255, 215, 0, 0.25);
}
.form-select:focus {
border-color: var(--nimbus-gold);
box-shadow: 0 0 0 0.2rem rgba(255, 215, 0, 0.25);
}
/* Sidebar navigation */
.nimbus-sidebar, .sidebar {
background: var(--nimbus-navy) !important;
background-image: none !important;
min-height: 100vh;
border-right: 3px solid var(--nimbus-gold);
position: relative;
}
.nimbus-sidebar .nav-item .nav-link,
.sidebar .nav-item .nav-link {
color: var(--nimbus-white) !important;
padding: 0.75rem 1.25rem;
margin: 0.25rem 0.5rem;
border-radius: 0.5rem;
transition: all 0.3s ease;
display: flex !important;
align-items: center !important;
text-decoration: none;
}
.nimbus-sidebar .nav-item .nav-link:hover,
.nimbus-sidebar .nav-item .nav-link.active,
.sidebar .nav-item .nav-link:hover,
.sidebar .nav-item .nav-link.active {
background-color: var(--nimbus-gold) !important;
color: var(--nimbus-navy) !important;
font-weight: 600;
}
/* Fix Bootstrap Icons alignment in sidebar */
.sidebar .nav-item .nav-link i,
.nimbus-sidebar .nav-item .nav-link i {
margin-right: 0.5rem !important;
width: 1.25rem !important;
height: 1.25rem !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
background: none !important;
background-image: none !important;
}
/* Alert styles */
.alert-nimbus-info {
background-color: rgba(255, 215, 0, 0.1);
border-color: var(--nimbus-gold);
color: var(--nimbus-navy);
}
.alert-nimbus-warning {
background-color: rgba(255, 179, 71, 0.1);
border-color: var(--nimbus-amber);
color: var(--nimbus-navy);
}
/* Quick Actions - Refined Dark Mode */
.nimbus-quick-actions {
background: linear-gradient(135deg, var(--nimbus-navy-light) 0%, var(--nimbus-navy) 100%);
color: var(--nimbus-white);
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 215, 0, 0.1);
padding: 1.25rem;
}
.nimbus-quick-actions h5 {
color: var(--nimbus-white);
font-weight: 700;
margin-bottom: 1rem;
font-size: 1rem;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.nimbus-action-btn {
display: block;
width: 100%;
padding: 0.75rem 1rem;
margin-bottom: 0.5rem;
border: none;
border-radius: 0.5rem;
font-weight: 600;
font-size: 0.875rem;
text-decoration: none;
transition: all 0.2s ease;
text-align: left;
position: relative;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.nimbus-action-btn:last-child {
margin-bottom: 0;
}
.nimbus-action-btn:hover {
transform: translateY(-1px);
text-decoration: none;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
.nimbus-action-btn-primary {
background: linear-gradient(135deg, var(--nimbus-gold) 0%, var(--nimbus-gold-dark) 100%);
color: var(--nimbus-navy);
border: none;
}
.nimbus-action-btn-primary:hover {
background: linear-gradient(135deg, var(--nimbus-gold-light) 0%, var(--nimbus-gold) 100%);
color: var(--nimbus-navy);
}
.nimbus-action-btn-success {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: var(--nimbus-white);
}
.nimbus-action-btn-success:hover {
background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
color: var(--nimbus-white);
}
.nimbus-action-btn-warning {
background: linear-gradient(135deg, var(--nimbus-amber) 0%, #f59e0b 100%);
color: var(--nimbus-navy);
}
.nimbus-action-btn-warning:hover {
background: linear-gradient(135deg, #fbbf24 0%, var(--nimbus-amber) 100%);
color: var(--nimbus-navy);
}
.nimbus-action-btn-info {
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
color: var(--nimbus-white);
}
.nimbus-action-btn-info:hover {
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
color: var(--nimbus-white);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.nimbus-navbar .navbar-brand {
font-size: 1.25rem;
}
.nimbus-brand-icon {
font-size: 1.5rem;
}
.nimbus-dashboard-card .card-title {
font-size: 2rem;
}
}
/* Recent schedules list styling - Refined Dark Mode */
.nimbus-quick-actions .list-group {
border: none;
}
.nimbus-quick-actions .list-group-item {
border: none;
border-bottom: 1px solid rgba(255, 215, 0, 0.1);
padding: 0.75rem 0;
background: transparent;
color: var(--nimbus-white);
transition: all 0.2s ease;
}
.nimbus-quick-actions .list-group-item:last-child {
border-bottom: none;
}
.nimbus-quick-actions .list-group-item:hover {
background: rgba(255, 215, 0, 0.05);
border-radius: 0.375rem;
margin: 0 -0.5rem;
padding: 0.75rem 0.5rem;
}
.nimbus-quick-actions .list-group-item h6 {
color: var(--nimbus-white);
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 0.125rem;
}
.nimbus-quick-actions .list-group-item p {
color: var(--nimbus-gray-300);
font-size: 0.8rem;
margin-bottom: 0.125rem;
opacity: 0.9;
}
.nimbus-quick-actions .list-group-item small {
color: var(--nimbus-gray-300);
font-size: 0.7rem;
opacity: 0.7;
}
/* Dark Mode Bootstrap Overrides */
.card {
background-color: var(--nimbus-navy) !important;
color: var(--nimbus-white) !important;
border: 1px solid rgba(255, 215, 0, 0.2) !important;
}
.card-header {
background-color: var(--nimbus-navy-light) !important;
color: var(--nimbus-white) !important;
border-bottom: 1px solid rgba(255, 215, 0, 0.2) !important;
}
.table {
color: var(--nimbus-white) !important;
background-color: var(--nimbus-navy) !important;
}
.table th {
color: var(--nimbus-white) !important;
border-color: rgba(255, 215, 0, 0.2) !important;
}
.table td {
color: var(--nimbus-white) !important;
border-color: rgba(255, 215, 0, 0.1) !important;
}
.table-striped > tbody > tr:nth-of-type(odd) > td {
background-color: rgba(255, 215, 0, 0.05) !important;
}
.table-hover > tbody > tr:hover > td {
background-color: rgba(255, 215, 0, 0.15) !important;
}
.form-control {
background-color: var(--nimbus-navy-light) !important;
color: var(--nimbus-white) !important;
border: 1px solid rgba(255, 215, 0, 0.3) !important;
}
.form-control:focus {
background-color: var(--nimbus-navy-light) !important;
color: var(--nimbus-white) !important;
border-color: var(--nimbus-gold) !important;
box-shadow: 0 0 0 0.2rem rgba(255, 215, 0, 0.25) !important;
}
.form-select {
background-color: var(--nimbus-navy-light) !important;
color: var(--nimbus-white) !important;
border: 1px solid rgba(255, 215, 0, 0.3) !important;
}
.form-select:focus {
background-color: var(--nimbus-navy-light) !important;
color: var(--nimbus-white) !important;
border-color: var(--nimbus-gold) !important;
box-shadow: 0 0 0 0.2rem rgba(255, 215, 0, 0.25) !important;
}
.form-check-input {
background-color: var(--nimbus-navy-light) !important;
border: 1px solid rgba(255, 215, 0, 0.3) !important;
}
.form-check-input:checked {
background-color: var(--nimbus-gold) !important;
border-color: var(--nimbus-gold) !important;
}
.form-label {
color: var(--nimbus-white) !important;
}
.alert-info {
background-color: rgba(255, 215, 0, 0.1) !important;
border-color: var(--nimbus-gold) !important;
color: var(--nimbus-white) !important;
}
.text-muted {
color: var(--nimbus-gray-300) !important;
}
h1, h2, h3, h4, h5, h6 {
color: var(--nimbus-white) !important;
}
.modal-content {
background-color: var(--nimbus-navy) !important;
color: var(--nimbus-white) !important;
border: 1px solid rgba(255, 215, 0, 0.3) !important;
}
.modal-header {
border-bottom: 1px solid rgba(255, 215, 0, 0.2) !important;
}
.modal-footer {
border-top: 1px solid rgba(255, 215, 0, 0.2) !important;
}
.btn-close {
filter: invert(1) grayscale(100%) brightness(200%) !important;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB