Compare commits
12 Commits
602a338027
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e536c5b5f | |||
| 0768e4816d | |||
| 133efdddea | |||
| 6063ed62e0 | |||
| 3b9c074bc7 | |||
| b25191d99a | |||
| 1dbfbb9ce6 | |||
| 94900b19f7 | |||
| 954abb704e | |||
| 4df946731a | |||
| 6763a31a41 | |||
| 1379998e5b |
@@ -1 +1 @@
|
|||||||
3.12.4
|
3.13.7
|
||||||
|
|||||||
35
.vscode/launch.json
vendored
Normal file
35
.vscode/launch.json
vendored
Normal 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
41
.vscode/tasks.json
vendored
Normal 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
408
CLAUDE.md
@@ -2,123 +2,389 @@
|
|||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
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
|
### Architecture Overview
|
||||||
- **Frontend**: React + TypeScript + Vite application
|
|
||||||
|
|
||||||
### 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:
|
The backend follows a **layered architecture** with clear separation of concerns:
|
||||||
|
|
||||||
```
|
```
|
||||||
backend/
|
backend/
|
||||||
├── db/ # Database connection layer
|
├── api/ # FastAPI REST API server
|
||||||
├── models/ # Data models and enums
|
├── cli/ # Command-line interface
|
||||||
├── repositories/ # Data access layer
|
├── db/ # Database connection layer
|
||||||
├── services/ # Business logic layer
|
├── models/ # Data models and enums
|
||||||
└── tests/ # Test suite
|
├── 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
|
#### API Layer (`api/`)
|
||||||
- **Repository Pattern**: Each domain entity has its own repository (Member, Service, Classification, etc.)
|
- **app.py**: FastAPI application with comprehensive REST endpoints
|
||||||
- **SchedulingService** (`services/scheduling_service.py`): Core business logic for round-robin scheduling with boost/cooldown algorithms
|
- **__main__.py**: Uvicorn server entry point with auto-reload
|
||||||
- **Data Models** (`models/dataclasses.py`): Dataclasses with SQLite row conversion utilities
|
- 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:
|
#### Repository Pattern (`repositories/`)
|
||||||
- TypeScript for type safety
|
- **MemberRepository** (`member.py`): Member CRUD operations
|
||||||
- Vite for fast development and building
|
- **ScheduleRepository** (`schedule.py`): Schedule management with status updates
|
||||||
- ESLint for code linting
|
- **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
|
## Development Commands
|
||||||
|
|
||||||
### Backend
|
### Backend Development
|
||||||
|
|
||||||
**Setup:**
|
**Environment Setup:**
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
# Activate virtual environment (already exists)
|
# Virtual environment is pre-configured in .venv/
|
||||||
source venv/bin/activate
|
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:**
|
**Testing:**
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
# Run tests using pytest from virtual environment
|
source .venv/bin/activate
|
||||||
venv/bin/pytest
|
pytest # Run all tests
|
||||||
# Run specific test file
|
pytest tests/repositories/ # Run repository tests
|
||||||
venv/bin/pytest tests/repositories/test_member.py
|
pytest --cov # Run with coverage
|
||||||
# Run with coverage
|
|
||||||
venv/bin/pytest --cov
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Run Application:**
|
### Frontend Development
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
# Run the demo script
|
|
||||||
python main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
**Setup:**
|
**Setup:**
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
dotnet restore
|
||||||
```
|
```
|
||||||
|
|
||||||
**Development:**
|
**Development Server:**
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
# Start development server
|
dotnet watch # Hot reload development
|
||||||
npm run dev
|
# Or: dotnet run
|
||||||
# Build for production
|
|
||||||
npm run build
|
|
||||||
# Run linting
|
|
||||||
npm run lint
|
|
||||||
# Preview production build
|
|
||||||
npm run preview
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**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
|
## Core Business Logic
|
||||||
|
|
||||||
The **SchedulingService** implements a sophisticated member scheduling algorithm:
|
### Scheduling Algorithm
|
||||||
|
|
||||||
1. **Round-robin scheduling** based on `Members.LastAcceptedAt` timestamps
|
The **SchedulingService** implements sophisticated member scheduling:
|
||||||
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
|
1. **Round-robin fairness**: Based on `LastAcceptedAt` timestamps
|
||||||
4. **Service availability** - members must be eligible for the specific service type
|
2. **Decline boost**: 5-day priority for recently declined members
|
||||||
5. **Status constraints** - prevents double-booking (pending/accepted/declined statuses)
|
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:**
|
**Key Methods:**
|
||||||
- `schedule_next_member()` - Core scheduling logic with multi-classification support
|
- `schedule_next_member(classification_ids, service_id)`: Core scheduling with multi-role support
|
||||||
- `decline_service_for_user()` - Handle member declining assignments
|
- `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:
|
**Member Management:**
|
||||||
- `Members` - Core member data with scheduling timestamps
|
- `GET /api/members` - List all members
|
||||||
- `Services` - Service instances on specific dates
|
- `GET /api/members/{id}` - Get member details
|
||||||
- `Schedules` - Member-service assignments with status tracking
|
- `POST /api/members` - Create new member
|
||||||
- `Classifications` - Member roles (Soprano, Alto, Tenor, Baritone)
|
- `PUT /api/members/{id}` - Update member
|
||||||
- `ServiceTypes` - Time slots (9AM, 11AM, 6PM)
|
- `DELETE /api/members/{id}` - Delete member
|
||||||
- `ServiceAvailability` - Member eligibility for service types
|
|
||||||
|
|
||||||
**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
|
## Testing
|
||||||
|
|
||||||
Backend uses **pytest** with fixtures for database setup. Tests cover:
|
**Backend Testing with pytest:**
|
||||||
- Repository layer functionality
|
- Repository layer unit tests with in-memory SQLite
|
||||||
- Business logic in services
|
- Service layer business logic tests
|
||||||
- Database schema constraints
|
- 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
7
backend/api/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
FastAPI module for NimbusFlow backend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .app import app
|
||||||
|
|
||||||
|
__all__ = ["app"]
|
||||||
14
backend/api/__main__.py
Normal file
14
backend/api/__main__.py
Normal 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
574
backend/api/app.py
Normal 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 dictionary‑like 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"}
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
█████████████████████████████████████████████████████████████████████▓▓▒▒▒▒▒░░▒░▒▒░░░░░░░░░░░░░░░░▒▒▒▓▓███████████████████████████████████████████████
|
|
||||||
████████████████████████████████████████████████████████████▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒░░░░░░░░░░░▒▒▓████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████▓▒▒▒▒▒▒▒▒▒▓▓▓████████████████████████████████▓▓▒░░░░░░░░░░▒▓████████████████████████████████████
|
|
||||||
█████████████████████████████████████████████████▓▒▒▒▒▓▓██████████████████████████████████████████████████▒░░░░░░░░░▓█████████████████████████████████
|
|
||||||
████████████████████████████████████████████▓▒▒▒▓████████████████████████████████████████████████████████████▓░░░░░░░░▓███████████████████████████████
|
|
||||||
████████████████████████████████████████▓▒▒█████████████████████████████████████████████████████████████████████▒▒░░░░░▒██████████████████████████████
|
|
||||||
█████████████████████████████████████▓▓████████████████████████████████▓▓▓▓▓█████████████████████████████████████▓▒░░░░░░▓████████████████████████████
|
|
||||||
███████████████████████████████████████████████████████████████████▒▒▒▒▒▒░░▒▒░▒▓███▓▓▓▓▓██████████████████████████▓▒░░░░░░▓███████████████████████████
|
|
||||||
██████████████████████████████████████████████████▓▓▒▒▒▓▓█████▓▓▓▒▒▒▒░░░░░░░░░░▒▒▒▒░░░▒▒▒▒▓████████████████████████▒░░░░░░▒███████████████████████████
|
|
||||||
███████████████████████████████████████████████▒▒▒░░░░░░░░▒▓▒▒▒▒░░░▒░░░░░░░░░░░░░░░░░░░░░░▒▒███████████████████████▒░░░░░▒▒███████████████████████████
|
|
||||||
█████████████████████████████████████████████▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒█████████████████████▒░░░░░░▒▒███████████████████████████
|
|
||||||
███████████████████████████████████████████▓▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒██████████████████▓░░░░░░░▒▒▓███████████████████████████
|
|
||||||
███████████████████████████████████████████▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒████████████████▓░░░░░░░░▒▒▒████████████████████████████
|
|
||||||
██████████████████████████████████████████▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒█████████████▓▒░░░░░░░░▒▒▒▒█████████████████████████████
|
|
||||||
████████████████████████████████████████▓▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▓███████▓▒░░░░░░░░░░▒▒▒▒▒██████████████████████████████
|
|
||||||
██████████████████████████████████████▓▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒░░░░░░░░░░░░░░▒▒▒▓▓▒░░░░░░░░░░░░░▒▒▒▒▒▓███████████████████████████████
|
|
||||||
█████████████████████████████████████▓▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒░░░░░░░░░░░░░▒▒░░░░░░░░░░░░░▒▒▒▒▒▒▓█████████████████████████████████
|
|
||||||
████████████████████████████████████▒▒▒▒░░░░░░░░░░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░▒▒░░░░░░░░░░░░░▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▓███████████████████████████████████
|
|
||||||
██████████████████████████████████▓▒▒▒░░░░░░░░░░░░▒▒░░░░░░░░░░░▒░░░░░░░░░░░░░░░░▒▒░░░░░░░░░░░░▒░▒▒░░░░▒▒▒▒▒▒▒▒▒▓██████████████████████████████████████
|
|
||||||
█████████████████████████████████▒▒▒▒░░░░░░░░░░░▒▒░░░░░░░░░░░░░░░▒░░░░░░░░░░░░░▒▒░░░░░░░░▒░░▒▒░░░▒▒▒▒▒▒▒▒▒▒▒▓█████████████████████████████████████████
|
|
||||||
█████████████████████████████████▒▒▒░░░░░░░░░░▒▒▒░░░░░░░░░░░░░░░░░▒░░░░░░░░░░▒▒▒▒░░░░░░░░▒▒▒▒░░▒▒▒▒▒▒▒▒▒▒█████████████████████████████████████████████
|
|
||||||
████████████████████████████████▒▒▒▒░░░░░░░░░▒▒▒░░░░░░░░░░░░░░░░░░▒░░░░░░░░░░░░▒▒▒░░░░░░▒▒▒░░░░▒▒▒▒▒▓█████████████████████████████████████████████████
|
|
||||||
████████████████████████████████▒▒▒▒░░░░░░▒░░▒▒▒░░░░░░░▒░░░░░░░░░░░▒░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒░░░▒▒▒████████████████████████████████████████████████████
|
|
||||||
████████████████████████████████▒▒▒▒░░░░░▒▒░▒▒▒▒░░░░░▒▒░░░░░░░░░░░░▒░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░▒░░▒▒▒▒████████████████████████████████████████████████████
|
|
||||||
█████████████████████████████████▒▒▒▒░░░░▒▒▒▒▒▒▒░░░░░▒░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░▒▒▒▒▒█████████████████████████████████████████████████████
|
|
||||||
█████████████████████████████████▓▒▒▒▒▒░░░▒▒▒▒▒▒▒░░░░▒▒░░░░░░░░░░░▒░░░░░░░░▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▒▒▓█████████████████████████████████████████████████████
|
|
||||||
███████████████████████████████████▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▓███████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░▒▒▒▒▒▓██████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒░░░░░░▒▒░░░░░░░░░░░▒▒▒█████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████▓▒▒▒░░░░▒▒▒▒░░░░░░░░░░░░░░░░░▒░░░░░░▒▒░░░░░░░░▒▒▒▒▓█████████████████████████████████████████████████████████████
|
|
||||||
████████████████████████████████████████▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░▒░░░░░░▒▒░░░░░░▒▒▒▒▒▓██████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████▓▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░▒▒▒░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓███████████████████████████████████████████████████████████████
|
|
||||||
███████████████████████████████████████████████▓▒▒▒░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓█████████████████████████████████████████████████████████████████
|
|
||||||
████████████████████████████████████████████████▓▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▓███▓▓▓▓▓█████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒███████████████████████████████████████████████████████████████████████████████████
|
|
||||||
████████████████████████████████████████████████████▓▒▒▒▒▒▒▒▒▒▒▒▓█████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████
|
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
Base CLI class and utilities for NimbusFlow CLI.
|
Base CLI class and utilities for NimbusFlow CLI.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
from backend.db.connection import DatabaseConnection
|
from backend.db.connection import DatabaseConnection
|
||||||
from backend.repositories import (
|
from backend.repositories import (
|
||||||
MemberRepository,
|
MemberRepository,
|
||||||
@@ -12,6 +15,20 @@ from backend.repositories import (
|
|||||||
ScheduleRepository,
|
ScheduleRepository,
|
||||||
ServiceTypeRepository
|
ServiceTypeRepository
|
||||||
)
|
)
|
||||||
|
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):
|
class CLIError(Exception):
|
||||||
@@ -20,17 +37,125 @@ class CLIError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class NimbusFlowCLI:
|
class NimbusFlowCLI:
|
||||||
"""Main CLI application class."""
|
"""Main CLI application class with database versioning."""
|
||||||
|
|
||||||
def __init__(self, db_path: str = "database6_accepts_and_declines.db"):
|
def __init__(self, db_path: str = "database.db", create_version: bool = True):
|
||||||
"""Initialize CLI with database connection."""
|
"""Initialize CLI with database connection, always using most recent version."""
|
||||||
self.db_path = Path(__file__).parent.parent / db_path
|
self.db_dir = Path(__file__).parent.parent / "db" / "sqlite"
|
||||||
if not self.db_path.exists():
|
self.base_db_path = self.db_dir / db_path
|
||||||
raise CLIError(f"Database not found: {self.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.db = DatabaseConnection(self.db_path)
|
||||||
self._init_repositories()
|
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):
|
def _init_repositories(self):
|
||||||
"""Initialize all repository instances."""
|
"""Initialize all repository instances."""
|
||||||
self.member_repo = MemberRepository(self.db)
|
self.member_repo = MemberRepository(self.db)
|
||||||
@@ -39,6 +164,15 @@ class NimbusFlowCLI:
|
|||||||
self.availability_repo = ServiceAvailabilityRepository(self.db)
|
self.availability_repo = ServiceAvailabilityRepository(self.db)
|
||||||
self.schedule_repo = ScheduleRepository(self.db)
|
self.schedule_repo = ScheduleRepository(self.db)
|
||||||
self.service_type_repo = ServiceTypeRepository(self.db)
|
self.service_type_repo = ServiceTypeRepository(self.db)
|
||||||
|
|
||||||
|
# Initialize scheduling service
|
||||||
|
self.scheduling_service = SchedulingService(
|
||||||
|
classification_repo=self.classification_repo,
|
||||||
|
member_repo=self.member_repo,
|
||||||
|
service_repo=self.service_repo,
|
||||||
|
availability_repo=self.availability_repo,
|
||||||
|
schedule_repo=self.schedule_repo,
|
||||||
|
)
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
"""Clean up database connection."""
|
"""Clean up database connection."""
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ CLI command modules.
|
|||||||
from .members import cmd_members_list, cmd_members_show, setup_members_parser
|
from .members import cmd_members_list, cmd_members_show, setup_members_parser
|
||||||
from .schedules import (
|
from .schedules import (
|
||||||
cmd_schedules_list, cmd_schedules_show, cmd_schedules_accept,
|
cmd_schedules_list, cmd_schedules_show, cmd_schedules_accept,
|
||||||
cmd_schedules_decline, setup_schedules_parser
|
cmd_schedules_decline, cmd_schedules_remove, cmd_schedules_schedule, setup_schedules_parser
|
||||||
)
|
)
|
||||||
from .services import cmd_services_list, setup_services_parser
|
from .services import cmd_services_list, setup_services_parser
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ __all__ = [
|
|||||||
"cmd_members_list", "cmd_members_show", "setup_members_parser",
|
"cmd_members_list", "cmd_members_show", "setup_members_parser",
|
||||||
# Schedule commands
|
# Schedule commands
|
||||||
"cmd_schedules_list", "cmd_schedules_show", "cmd_schedules_accept",
|
"cmd_schedules_list", "cmd_schedules_show", "cmd_schedules_accept",
|
||||||
"cmd_schedules_decline", "setup_schedules_parser",
|
"cmd_schedules_decline", "cmd_schedules_remove", "cmd_schedules_schedule", "setup_schedules_parser",
|
||||||
# Service commands
|
# Service commands
|
||||||
"cmd_services_list", "setup_services_parser",
|
"cmd_services_list", "setup_services_parser",
|
||||||
]
|
]
|
||||||
@@ -8,13 +8,11 @@ from typing import TYPE_CHECKING
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from backend.cli.base import NimbusFlowCLI
|
from backend.cli.base import NimbusFlowCLI
|
||||||
|
|
||||||
from backend.cli.utils import format_member_row
|
from backend.cli.utils import format_member_row, create_table_header, create_table_separator, TableColors
|
||||||
|
|
||||||
|
|
||||||
def cmd_members_list(cli: "NimbusFlowCLI", args) -> None:
|
def cmd_members_list(cli: "NimbusFlowCLI", args) -> None:
|
||||||
"""List all members with optional filters."""
|
"""List all members with optional filters."""
|
||||||
print("Listing members...")
|
|
||||||
|
|
||||||
# Get all classifications for lookup
|
# Get all classifications for lookup
|
||||||
classifications = cli.classification_repo.list_all()
|
classifications = cli.classification_repo.list_all()
|
||||||
classification_map = {c.ClassificationId: c.ClassificationName for c in classifications}
|
classification_map = {c.ClassificationId: c.ClassificationName for c in classifications}
|
||||||
@@ -29,7 +27,7 @@ def cmd_members_list(cli: "NimbusFlowCLI", args) -> None:
|
|||||||
break
|
break
|
||||||
|
|
||||||
if classification_id is None:
|
if classification_id is None:
|
||||||
print(f"❌ Classification '{args.classification}' not found")
|
print(f"{TableColors.ERROR}Classification '{args.classification}' not found{TableColors.RESET}")
|
||||||
return
|
return
|
||||||
|
|
||||||
members = cli.member_repo.get_by_classification_ids([classification_id])
|
members = cli.member_repo.get_by_classification_ids([classification_id])
|
||||||
@@ -39,26 +37,28 @@ def cmd_members_list(cli: "NimbusFlowCLI", args) -> None:
|
|||||||
members = cli.member_repo.list_all()
|
members = cli.member_repo.list_all()
|
||||||
|
|
||||||
if not members:
|
if not members:
|
||||||
print("No members found.")
|
print(f"{TableColors.DIM}No members found.{TableColors.RESET}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Print header
|
# Print styled header
|
||||||
print(f"\n{'ID':<3} | {'First Name':<12} | {'Last Name':<15} | {'Classification':<12} | {'Active':<6} | {'Email'}")
|
print()
|
||||||
print("-" * 80)
|
print(create_table_header("ID ", "First Name ", "Last Name ", "Classification ", "Status ", "Email"))
|
||||||
|
print(create_table_separator(90))
|
||||||
|
|
||||||
# Print members
|
# Print members
|
||||||
for member in members:
|
for member in members:
|
||||||
classification_name = classification_map.get(member.ClassificationId)
|
classification_name = classification_map.get(member.ClassificationId)
|
||||||
print(format_member_row(member, classification_name))
|
print(format_member_row(member, classification_name))
|
||||||
|
|
||||||
print(f"\nTotal: {len(members)} members")
|
print()
|
||||||
|
print(f"{TableColors.SUCCESS}Total: {len(members)} members{TableColors.RESET}")
|
||||||
|
|
||||||
|
|
||||||
def cmd_members_show(cli: "NimbusFlowCLI", args) -> None:
|
def cmd_members_show(cli: "NimbusFlowCLI", args) -> None:
|
||||||
"""Show detailed information about a specific member."""
|
"""Show detailed information about a specific member."""
|
||||||
member = cli.member_repo.get_by_id(args.member_id)
|
member = cli.member_repo.get_by_id(args.member_id)
|
||||||
if not member:
|
if not member:
|
||||||
print(f"❌ Member with ID {args.member_id} not found")
|
print(f"{TableColors.ERROR}Member with ID {args.member_id} not found{TableColors.RESET}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get classification name
|
# Get classification name
|
||||||
@@ -66,20 +66,24 @@ def cmd_members_show(cli: "NimbusFlowCLI", args) -> None:
|
|||||||
if member.ClassificationId:
|
if member.ClassificationId:
|
||||||
classification = cli.classification_repo.get_by_id(member.ClassificationId)
|
classification = cli.classification_repo.get_by_id(member.ClassificationId)
|
||||||
|
|
||||||
print(f"\n📋 Member Details (ID: {member.MemberId})")
|
print(f"\n{TableColors.HEADER}Member Details (ID: {member.MemberId}){TableColors.RESET}")
|
||||||
print("-" * 50)
|
print(f"{TableColors.BORDER}{'─' * 50}{TableColors.RESET}")
|
||||||
print(f"Name: {member.FirstName} {member.LastName}")
|
print(f"{TableColors.BOLD}Name:{TableColors.RESET} {member.FirstName} {member.LastName}")
|
||||||
print(f"Email: {member.Email or 'N/A'}")
|
print(f"{TableColors.BOLD}Email:{TableColors.RESET} {member.Email or f'{TableColors.DIM}N/A{TableColors.RESET}'}")
|
||||||
print(f"Phone: {member.PhoneNumber or 'N/A'}")
|
print(f"{TableColors.BOLD}Phone:{TableColors.RESET} {member.PhoneNumber or f'{TableColors.DIM}N/A{TableColors.RESET}'}")
|
||||||
print(f"Classification: {classification.ClassificationName if classification else 'N/A'}")
|
print(f"{TableColors.BOLD}Classification:{TableColors.RESET} {TableColors.YELLOW}{classification.ClassificationName if classification else f'{TableColors.DIM}N/A'}{TableColors.RESET}")
|
||||||
print(f"Active: {'Yes' if member.IsActive else 'No'}")
|
|
||||||
print(f"Notes: {member.Notes or 'N/A'}")
|
|
||||||
|
|
||||||
print(f"\n⏰ Schedule History:")
|
active_status = f"{TableColors.SUCCESS}Yes{TableColors.RESET}" if member.IsActive else f"{TableColors.ERROR}No{TableColors.RESET}"
|
||||||
print(f"Last Scheduled: {member.LastScheduledAt or 'Never'}")
|
print(f"{TableColors.BOLD}Active:{TableColors.RESET} {active_status}")
|
||||||
print(f"Last Accepted: {member.LastAcceptedAt or 'Never'}")
|
print(f"{TableColors.BOLD}Notes:{TableColors.RESET} {member.Notes or f'{TableColors.DIM}N/A{TableColors.RESET}'}")
|
||||||
print(f"Last Declined: {member.LastDeclinedAt or 'Never'}")
|
|
||||||
print(f"Decline Streak: {member.DeclineStreak}")
|
print(f"\n{TableColors.HEADER}Schedule History:{TableColors.RESET}")
|
||||||
|
print(f"{TableColors.BOLD}Last Scheduled:{TableColors.RESET} {member.LastScheduledAt or f'{TableColors.DIM}Never{TableColors.RESET}'}")
|
||||||
|
print(f"{TableColors.BOLD}Last Accepted:{TableColors.RESET} {member.LastAcceptedAt or f'{TableColors.DIM}Never{TableColors.RESET}'}")
|
||||||
|
print(f"{TableColors.BOLD}Last Declined:{TableColors.RESET} {member.LastDeclinedAt or f'{TableColors.DIM}Never{TableColors.RESET}'}")
|
||||||
|
|
||||||
|
decline_color = TableColors.ERROR if member.DeclineStreak > 0 else TableColors.SUCCESS
|
||||||
|
print(f"{TableColors.BOLD}Decline Streak:{TableColors.RESET} {decline_color}{member.DeclineStreak}{TableColors.RESET}")
|
||||||
|
|
||||||
|
|
||||||
def setup_members_parser(subparsers) -> None:
|
def setup_members_parser(subparsers) -> None:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -10,12 +10,11 @@ if TYPE_CHECKING:
|
|||||||
from backend.cli.base import NimbusFlowCLI
|
from backend.cli.base import NimbusFlowCLI
|
||||||
|
|
||||||
from backend.models.enums import ScheduleStatus
|
from backend.models.enums import ScheduleStatus
|
||||||
|
from backend.cli.utils import format_service_row, create_table_header, create_table_separator, TableColors
|
||||||
|
|
||||||
|
|
||||||
def cmd_services_list(cli: "NimbusFlowCLI", args) -> None:
|
def cmd_services_list(cli: "NimbusFlowCLI", args) -> None:
|
||||||
"""List services with optional filters."""
|
"""List services with optional filters."""
|
||||||
print("Listing services...")
|
|
||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
if args.upcoming:
|
if args.upcoming:
|
||||||
services = cli.service_repo.upcoming(limit=args.limit or 50)
|
services = cli.service_repo.upcoming(limit=args.limit or 50)
|
||||||
@@ -26,7 +25,7 @@ def cmd_services_list(cli: "NimbusFlowCLI", args) -> None:
|
|||||||
all_services = cli.service_repo.list_all()
|
all_services = cli.service_repo.list_all()
|
||||||
services = [s for s in all_services if s.ServiceDate == target_date]
|
services = [s for s in all_services if s.ServiceDate == target_date]
|
||||||
except ValueError:
|
except ValueError:
|
||||||
print(f"❌ Invalid date format '{args.date}'. Use YYYY-MM-DD")
|
print(f"{TableColors.ERROR}Invalid date format '{args.date}'. Use YYYY-MM-DD{TableColors.RESET}")
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
services = cli.service_repo.list_all()
|
services = cli.service_repo.list_all()
|
||||||
@@ -34,7 +33,7 @@ def cmd_services_list(cli: "NimbusFlowCLI", args) -> None:
|
|||||||
services = services[:args.limit]
|
services = services[:args.limit]
|
||||||
|
|
||||||
if not services:
|
if not services:
|
||||||
print("No services found.")
|
print(f"{TableColors.DIM}No services found.{TableColors.RESET}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get service type names for display
|
# Get service type names for display
|
||||||
@@ -57,22 +56,19 @@ def cmd_services_list(cli: "NimbusFlowCLI", args) -> None:
|
|||||||
'total': len(service_schedules)
|
'total': len(service_schedules)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Print header
|
# Print styled header
|
||||||
print(f"\n{'ID':<3} | {'Date':<12} | {'Service Type':<12} | {'Total':<5} | {'Pending':<7} | {'Accepted':<8} | {'Declined'}")
|
print()
|
||||||
print("-" * 85)
|
print(create_table_header("ID ", "Date ", "Service Type ", "Total", "Pending", "Accepted", "Declined"))
|
||||||
|
print(create_table_separator(85))
|
||||||
|
|
||||||
# Print services
|
# Print services
|
||||||
for service in sorted(services, key=lambda s: s.ServiceDate):
|
for service in sorted(services, key=lambda s: s.ServiceDate):
|
||||||
type_name = service_type_map.get(service.ServiceTypeId, "Unknown")
|
type_name = service_type_map.get(service.ServiceTypeId, "Unknown")
|
||||||
counts = schedule_counts.get(service.ServiceId, {'total': 0, 'pending': 0, 'accepted': 0, 'declined': 0})
|
counts = schedule_counts.get(service.ServiceId, {'total': 0, 'pending': 0, 'accepted': 0, 'declined': 0})
|
||||||
|
print(format_service_row(service, type_name, counts))
|
||||||
# Format date properly
|
|
||||||
date_str = str(service.ServiceDate) if service.ServiceDate else "N/A"
|
|
||||||
|
|
||||||
print(f"{service.ServiceId:<3} | {date_str:<12} | {type_name:<12} | "
|
|
||||||
f"{counts['total']:<5} | {counts['pending']:<7} | {counts['accepted']:<8} | {counts['declined']}")
|
|
||||||
|
|
||||||
print(f"\nTotal: {len(services)} services")
|
print()
|
||||||
|
print(f"{TableColors.SUCCESS}Total: {len(services)} services{TableColors.RESET}")
|
||||||
|
|
||||||
|
|
||||||
def setup_services_parser(subparsers) -> None:
|
def setup_services_parser(subparsers) -> None:
|
||||||
|
|||||||
507
backend/cli/interactive.py
Normal file
507
backend/cli/interactive.py
Normal file
@@ -0,0 +1,507 @@
|
|||||||
|
"""
|
||||||
|
Interactive CLI interface for NimbusFlow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .base import NimbusFlowCLI
|
||||||
|
|
||||||
|
from .commands import (
|
||||||
|
cmd_members_list, cmd_members_show,
|
||||||
|
cmd_schedules_list, cmd_schedules_show, cmd_schedules_accept, cmd_schedules_decline, cmd_schedules_remove, cmd_schedules_schedule,
|
||||||
|
cmd_services_list,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ANSI color codes
|
||||||
|
class Colors:
|
||||||
|
RESET = '\033[0m'
|
||||||
|
BOLD = '\033[1m'
|
||||||
|
DIM = '\033[2m'
|
||||||
|
BLUE = '\033[94m'
|
||||||
|
GREEN = '\033[92m'
|
||||||
|
YELLOW = '\033[93m'
|
||||||
|
RED = '\033[91m'
|
||||||
|
CYAN = '\033[96m'
|
||||||
|
WHITE = '\033[97m'
|
||||||
|
GREY = '\033[90m'
|
||||||
|
BG_GREY = '\033[100m'
|
||||||
|
|
||||||
|
# Special combinations
|
||||||
|
HEADER = '\033[1m\033[96m' # Bold Cyan
|
||||||
|
SUCCESS = '\033[1m\033[92m' # Bold Green
|
||||||
|
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:
|
||||||
|
"""Create a grey box around input prompt."""
|
||||||
|
box_top = f"{Colors.INPUT_BOX}┌" + "─" * (width - 2) + f"┐{Colors.RESET}"
|
||||||
|
prompt_line = f"{Colors.INPUT_BOX}│{Colors.RESET} {prompt}"
|
||||||
|
padding = width - len(prompt) - 3
|
||||||
|
if padding > 0:
|
||||||
|
prompt_line += " " * padding + f"{Colors.INPUT_BOX}│{Colors.RESET}"
|
||||||
|
else:
|
||||||
|
prompt_line += f" {Colors.INPUT_BOX}│{Colors.RESET}"
|
||||||
|
box_bottom = f"{Colors.INPUT_BOX}└" + "─" * (width - 2) + f"┘{Colors.RESET}"
|
||||||
|
|
||||||
|
return f"\n{box_top}\n{prompt_line}\n{box_bottom}"
|
||||||
|
|
||||||
|
|
||||||
|
def create_simple_input_box(prompt: str) -> str:
|
||||||
|
"""Create a simple grey box around input prompt."""
|
||||||
|
return f"\n{Colors.INPUT_BOX}┌─ {prompt} ─┐{Colors.RESET}"
|
||||||
|
|
||||||
|
|
||||||
|
def clear_screen():
|
||||||
|
"""Clear the terminal 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 with animated shimmer effect."""
|
||||||
|
print("\033[2J\033[H") # Clear screen and move cursor to top
|
||||||
|
print() # Add some top padding
|
||||||
|
animate_nimbusflow_text()
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def display_main_menu():
|
||||||
|
"""Display the main menu options."""
|
||||||
|
print(f"{Colors.HEADER}Main Menu{Colors.RESET}")
|
||||||
|
print(f"{Colors.GREY}─" * 50 + f"{Colors.RESET}")
|
||||||
|
print()
|
||||||
|
print(f" {Colors.CYAN}1.{Colors.RESET} {Colors.BOLD}Members{Colors.RESET} Manage choir members")
|
||||||
|
print(f" {Colors.CYAN}2.{Colors.RESET} {Colors.BOLD}Schedules{Colors.RESET} View and manage schedules")
|
||||||
|
print(f" {Colors.CYAN}3.{Colors.RESET} {Colors.BOLD}Services{Colors.RESET} Manage services and events")
|
||||||
|
print(f" {Colors.CYAN}4.{Colors.RESET} {Colors.BOLD}Exit{Colors.RESET} Close NimbusFlow CLI")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def display_members_menu():
|
||||||
|
"""Display members submenu."""
|
||||||
|
print(f"\n{Colors.HEADER}Members{Colors.RESET}")
|
||||||
|
print(f"{Colors.GREY}─" * 50 + f"{Colors.RESET}")
|
||||||
|
print()
|
||||||
|
print(f" {Colors.CYAN}1.{Colors.RESET} List all members")
|
||||||
|
print(f" {Colors.CYAN}2.{Colors.RESET} List active members only")
|
||||||
|
print(f" {Colors.CYAN}3.{Colors.RESET} List by classification")
|
||||||
|
print(f" {Colors.CYAN}4.{Colors.RESET} Show member details")
|
||||||
|
print(f" {Colors.CYAN}5.{Colors.RESET} {Colors.DIM}Back to main menu{Colors.RESET}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def display_schedules_menu():
|
||||||
|
"""Display schedules submenu."""
|
||||||
|
print(f"\n{Colors.HEADER}Schedules{Colors.RESET}")
|
||||||
|
print(f"{Colors.GREY}─" * 50 + f"{Colors.RESET}")
|
||||||
|
print()
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
def display_services_menu():
|
||||||
|
"""Display services submenu."""
|
||||||
|
print(f"\n{Colors.HEADER}Services{Colors.RESET}")
|
||||||
|
print(f"{Colors.GREY}─" * 50 + f"{Colors.RESET}")
|
||||||
|
print()
|
||||||
|
print(f" {Colors.CYAN}1.{Colors.RESET} List all services")
|
||||||
|
print(f" {Colors.CYAN}2.{Colors.RESET} List upcoming services")
|
||||||
|
print(f" {Colors.CYAN}3.{Colors.RESET} List services by date")
|
||||||
|
print(f" {Colors.CYAN}4.{Colors.RESET} {Colors.DIM}Back to main menu{Colors.RESET}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_choice(max_options: int) -> int:
|
||||||
|
"""Get user menu choice with validation."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
print(create_simple_input_box(f"Enter your choice (1-{max_options})"))
|
||||||
|
choice = input(f"{Colors.INPUT_BOX}└─> {Colors.RESET}").strip()
|
||||||
|
if not choice:
|
||||||
|
continue
|
||||||
|
choice_int = int(choice)
|
||||||
|
if 1 <= choice_int <= max_options:
|
||||||
|
return choice_int
|
||||||
|
else:
|
||||||
|
print(f"{Colors.ERROR}Please enter a number between 1 and {max_options}{Colors.RESET}")
|
||||||
|
except ValueError:
|
||||||
|
print(f"{Colors.ERROR}Please enter a valid number{Colors.RESET}")
|
||||||
|
except (KeyboardInterrupt, EOFError):
|
||||||
|
print(f"\n{Colors.WARNING}Goodbye!{Colors.RESET}")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
def get_text_input(prompt: str, required: bool = True) -> str:
|
||||||
|
"""Get text input from user."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
print(create_simple_input_box(prompt))
|
||||||
|
value = input(f"{Colors.INPUT_BOX}└─> {Colors.RESET}").strip()
|
||||||
|
if value or not required:
|
||||||
|
return value
|
||||||
|
print(f"{Colors.ERROR}This field is required{Colors.RESET}")
|
||||||
|
except (KeyboardInterrupt, EOFError):
|
||||||
|
print(f"\n{Colors.WARNING}Operation cancelled{Colors.RESET}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_date_input(prompt: str = "Enter date (YYYY-MM-DD)") -> str:
|
||||||
|
"""Get date input from user."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
print(create_simple_input_box(prompt))
|
||||||
|
date_str = input(f"{Colors.INPUT_BOX}└─> {Colors.RESET}").strip()
|
||||||
|
if not date_str:
|
||||||
|
print(f"{Colors.ERROR}Date is required{Colors.RESET}")
|
||||||
|
continue
|
||||||
|
# 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 ""
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
setattr(self, key, value)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_members_menu(cli: "NimbusFlowCLI"):
|
||||||
|
"""Handle members menu interactions."""
|
||||||
|
while True:
|
||||||
|
clear_screen()
|
||||||
|
display_members_menu()
|
||||||
|
choice = get_user_choice(5)
|
||||||
|
|
||||||
|
if choice == 1: # List all members
|
||||||
|
clear_screen()
|
||||||
|
print(f"{Colors.SUCCESS}Listing all members...{Colors.RESET}\n")
|
||||||
|
cmd_members_list(cli, MockArgs(active=False, classification=None))
|
||||||
|
input(f"\n{Colors.DIM}Press Enter to continue...{Colors.RESET}")
|
||||||
|
|
||||||
|
elif choice == 2: # List active members
|
||||||
|
clear_screen()
|
||||||
|
print(f"{Colors.SUCCESS}Listing active members...{Colors.RESET}\n")
|
||||||
|
cmd_members_list(cli, MockArgs(active=True, classification=None))
|
||||||
|
input(f"\n{Colors.DIM}Press Enter to continue...{Colors.RESET}")
|
||||||
|
|
||||||
|
elif choice == 3: # List by classification
|
||||||
|
clear_screen()
|
||||||
|
classification = get_text_input("Enter classification (Soprano, Alto / Mezzo, Tenor, Baritone)", True)
|
||||||
|
if classification:
|
||||||
|
clear_screen()
|
||||||
|
print(f"{Colors.SUCCESS}Listing {classification} members...{Colors.RESET}\n")
|
||||||
|
cmd_members_list(cli, MockArgs(active=False, classification=classification))
|
||||||
|
input(f"\n{Colors.DIM}Press Enter to continue...{Colors.RESET}")
|
||||||
|
|
||||||
|
elif choice == 4: # Show member details
|
||||||
|
clear_screen()
|
||||||
|
member_id = get_text_input("Enter member ID", True)
|
||||||
|
if member_id.isdigit():
|
||||||
|
clear_screen()
|
||||||
|
print(f"{Colors.SUCCESS}Showing details for member {member_id}...{Colors.RESET}\n")
|
||||||
|
cmd_members_show(cli, MockArgs(member_id=int(member_id)))
|
||||||
|
else:
|
||||||
|
print(f"{Colors.ERROR}Invalid member ID{Colors.RESET}")
|
||||||
|
input(f"\n{Colors.DIM}Press Enter to continue...{Colors.RESET}")
|
||||||
|
|
||||||
|
elif choice == 5: # Back to main menu
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def handle_schedules_menu(cli: "NimbusFlowCLI"):
|
||||||
|
"""Handle schedules menu interactions."""
|
||||||
|
while True:
|
||||||
|
clear_screen()
|
||||||
|
display_schedules_menu()
|
||||||
|
choice = get_user_choice(6)
|
||||||
|
|
||||||
|
if choice == 1: # List all schedules
|
||||||
|
clear_screen()
|
||||||
|
|
||||||
|
# Get date filter
|
||||||
|
date = get_date_input_optional("Enter date to filter schedules (or press Enter to skip)")
|
||||||
|
if not date:
|
||||||
|
clear_screen()
|
||||||
|
cmd_schedules_list(cli, MockArgs(service_id=None, status=None))
|
||||||
|
else:
|
||||||
|
# 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 == 2: # Accept schedule
|
||||||
|
clear_screen()
|
||||||
|
date = get_date_input("Enter date for interactive accept")
|
||||||
|
if date:
|
||||||
|
clear_screen()
|
||||||
|
cmd_schedules_accept(cli, MockArgs(date=date, schedule_id=None))
|
||||||
|
input(f"\n{Colors.DIM}Press Enter to continue...{Colors.RESET}")
|
||||||
|
|
||||||
|
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()
|
||||||
|
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 == 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()
|
||||||
|
|
||||||
|
# Ask if they want to schedule by name or use round-robin
|
||||||
|
print(f"\n{Colors.HEADER}Scheduling Options{Colors.RESET}")
|
||||||
|
print(f"{Colors.GREY}─" * 50 + f"{Colors.RESET}")
|
||||||
|
print()
|
||||||
|
print(f" {Colors.CYAN}1.{Colors.RESET} {Colors.YELLOW}Round-robin scheduling{Colors.RESET} (choose next available member)")
|
||||||
|
print(f" {Colors.CYAN}2.{Colors.RESET} {Colors.GREEN}Schedule by name{Colors.RESET} (choose specific member)")
|
||||||
|
print()
|
||||||
|
|
||||||
|
schedule_choice = get_user_choice(2)
|
||||||
|
clear_screen()
|
||||||
|
|
||||||
|
if schedule_choice == 1:
|
||||||
|
# Round-robin scheduling
|
||||||
|
cmd_schedules_schedule(cli, MockArgs(service_id=None, date=date, classifications=None, member_name=None))
|
||||||
|
else:
|
||||||
|
# Name-based scheduling
|
||||||
|
member_name = get_text_input("Enter member name to search for (first, last, or both)", True)
|
||||||
|
if member_name:
|
||||||
|
clear_screen()
|
||||||
|
cmd_schedules_schedule(cli, MockArgs(service_id=None, date=date, classifications=None, member_name=member_name))
|
||||||
|
input(f"\n{Colors.DIM}Press Enter to continue...{Colors.RESET}")
|
||||||
|
|
||||||
|
elif choice == 6: # Back to main menu
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def handle_services_menu(cli: "NimbusFlowCLI"):
|
||||||
|
"""Handle services menu interactions."""
|
||||||
|
while True:
|
||||||
|
clear_screen()
|
||||||
|
display_services_menu()
|
||||||
|
choice = get_user_choice(4)
|
||||||
|
|
||||||
|
if choice == 1: # List all services
|
||||||
|
clear_screen()
|
||||||
|
print(f"{Colors.SUCCESS}Listing all services...{Colors.RESET}\n")
|
||||||
|
cmd_services_list(cli, MockArgs(date=None, upcoming=False, limit=None))
|
||||||
|
input(f"\n{Colors.DIM}Press Enter to continue...{Colors.RESET}")
|
||||||
|
|
||||||
|
elif choice == 2: # List upcoming services
|
||||||
|
clear_screen()
|
||||||
|
print(f"{Colors.WARNING}Listing upcoming services...{Colors.RESET}\n")
|
||||||
|
cmd_services_list(cli, MockArgs(date=None, upcoming=True, limit=20))
|
||||||
|
input(f"\n{Colors.DIM}Press Enter to continue...{Colors.RESET}")
|
||||||
|
|
||||||
|
elif choice == 3: # List by date
|
||||||
|
clear_screen()
|
||||||
|
date = get_date_input("Enter date to filter services")
|
||||||
|
if date:
|
||||||
|
clear_screen()
|
||||||
|
print(f"{Colors.SUCCESS}Listing services for {date}...{Colors.RESET}\n")
|
||||||
|
cmd_services_list(cli, MockArgs(date=date, upcoming=False, limit=None))
|
||||||
|
input(f"\n{Colors.DIM}Press Enter to continue...{Colors.RESET}")
|
||||||
|
|
||||||
|
elif choice == 4: # Back to main menu
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def run_interactive_mode(cli: "NimbusFlowCLI"):
|
||||||
|
"""Run the main interactive CLI mode."""
|
||||||
|
display_welcome()
|
||||||
|
|
||||||
|
print(f"{Colors.HEADER}Welcome to the NimbusFlow Interactive CLI{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}")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
clear_screen() # Clear screen
|
||||||
|
display_main_menu()
|
||||||
|
|
||||||
|
choice = get_user_choice(4)
|
||||||
|
|
||||||
|
if choice == 1: # Members
|
||||||
|
handle_members_menu(cli)
|
||||||
|
|
||||||
|
elif choice == 2: # Schedules
|
||||||
|
handle_schedules_menu(cli)
|
||||||
|
|
||||||
|
elif choice == 3: # Services
|
||||||
|
handle_services_menu(cli)
|
||||||
|
|
||||||
|
elif choice == 4: # Exit
|
||||||
|
clear_screen()
|
||||||
|
print(f"\n{Colors.SUCCESS}Thank you for using NimbusFlow!{Colors.RESET}")
|
||||||
|
print(f"{Colors.DIM}Goodbye!{Colors.RESET}")
|
||||||
|
break
|
||||||
@@ -11,10 +11,11 @@ from .commands import (
|
|||||||
cmd_members_list, cmd_members_show, setup_members_parser,
|
cmd_members_list, cmd_members_show, setup_members_parser,
|
||||||
# Schedule commands
|
# Schedule commands
|
||||||
cmd_schedules_list, cmd_schedules_show, cmd_schedules_accept,
|
cmd_schedules_list, cmd_schedules_show, cmd_schedules_accept,
|
||||||
cmd_schedules_decline, setup_schedules_parser,
|
cmd_schedules_decline, cmd_schedules_remove, cmd_schedules_schedule, setup_schedules_parser,
|
||||||
# Service commands
|
# Service commands
|
||||||
cmd_services_list, setup_services_parser,
|
cmd_services_list, setup_services_parser,
|
||||||
)
|
)
|
||||||
|
from .interactive import run_interactive_mode
|
||||||
|
|
||||||
|
|
||||||
def setup_parser() -> argparse.ArgumentParser:
|
def setup_parser() -> argparse.ArgumentParser:
|
||||||
@@ -41,11 +42,44 @@ def main():
|
|||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if not args.command:
|
if not args.command:
|
||||||
parser.print_help()
|
# Launch interactive mode when no command is provided
|
||||||
|
try:
|
||||||
|
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"{Colors.ERROR}❌ Error: {e}{Colors.RESET}")
|
||||||
|
return 1
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print(f"\n{Colors.WARNING}🛑 Interrupted by user{Colors.RESET}")
|
||||||
|
return 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"{Colors.ERROR}❌ Unexpected error: {e}{Colors.RESET}")
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
if 'cli' in locals():
|
||||||
|
cli.close()
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
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
|
# Route commands to their respective handlers
|
||||||
if args.command == "members":
|
if args.command == "members":
|
||||||
@@ -54,7 +88,7 @@ def main():
|
|||||||
elif args.members_action == "show":
|
elif args.members_action == "show":
|
||||||
cmd_members_show(cli, args)
|
cmd_members_show(cli, args)
|
||||||
else:
|
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":
|
elif args.command == "schedules":
|
||||||
if args.schedules_action == "list":
|
if args.schedules_action == "list":
|
||||||
@@ -65,26 +99,30 @@ def main():
|
|||||||
cmd_schedules_accept(cli, args)
|
cmd_schedules_accept(cli, args)
|
||||||
elif args.schedules_action == "decline":
|
elif args.schedules_action == "decline":
|
||||||
cmd_schedules_decline(cli, args)
|
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:
|
else:
|
||||||
print("❌ Unknown schedules action. Use 'list', 'show', 'accept', or 'decline'")
|
print(f"{Colors.ERROR}❌ Unknown schedules action. Use 'list', 'show', 'accept', 'decline', 'remove', or 'schedule'{Colors.RESET}")
|
||||||
|
|
||||||
elif args.command == "services":
|
elif args.command == "services":
|
||||||
if args.services_action == "list":
|
if args.services_action == "list":
|
||||||
cmd_services_list(cli, args)
|
cmd_services_list(cli, args)
|
||||||
else:
|
else:
|
||||||
print("❌ Unknown services action. Use 'list'")
|
print(f"{Colors.ERROR}❌ Unknown services action. Use 'list'{Colors.RESET}")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print(f"❌ Unknown command: {args.command}")
|
print(f"{Colors.ERROR}❌ Unknown command: {args.command}{Colors.RESET}")
|
||||||
|
|
||||||
except CLIError as e:
|
except CLIError as e:
|
||||||
print(f"❌ Error: {e}")
|
print(f"{Colors.ERROR}❌ Error: {e}{Colors.RESET}")
|
||||||
return 1
|
return 1
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\n🛑 Interrupted by user")
|
print(f"\n{Colors.WARNING}🛑 Interrupted by user{Colors.RESET}")
|
||||||
return 1
|
return 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Unexpected error: {e}")
|
print(f"{Colors.ERROR}❌ Unexpected error: {e}{Colors.RESET}")
|
||||||
return 1
|
return 1
|
||||||
finally:
|
finally:
|
||||||
if 'cli' in locals():
|
if 'cli' in locals():
|
||||||
|
|||||||
@@ -6,22 +6,60 @@ from typing import Optional
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from backend.models.enums import ScheduleStatus
|
from backend.models.enums import ScheduleStatus
|
||||||
|
|
||||||
|
# ANSI color codes for table formatting
|
||||||
|
class TableColors:
|
||||||
|
RESET = '\033[0m'
|
||||||
|
BOLD = '\033[1m'
|
||||||
|
DIM = '\033[2m'
|
||||||
|
BLUE = '\033[94m'
|
||||||
|
GREEN = '\033[92m'
|
||||||
|
YELLOW = '\033[93m'
|
||||||
|
RED = '\033[91m'
|
||||||
|
CYAN = '\033[96m'
|
||||||
|
WHITE = '\033[97m'
|
||||||
|
GREY = '\033[90m'
|
||||||
|
|
||||||
|
# Table-specific colors
|
||||||
|
HEADER = '\033[1m\033[96m' # Bold Cyan
|
||||||
|
SUCCESS = '\033[1m\033[92m' # Bold Green
|
||||||
|
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:
|
def format_member_row(member, classification_name: Optional[str] = None) -> str:
|
||||||
"""Format a member for table display."""
|
"""Format a member for table display with colors."""
|
||||||
active = "✓" if member.IsActive else "✗"
|
# Color coding for active status
|
||||||
|
if member.IsActive:
|
||||||
|
active = f"{TableColors.SUCCESS}✓ Active{TableColors.RESET}"
|
||||||
|
name_color = TableColors.BOLD
|
||||||
|
else:
|
||||||
|
active = f"{TableColors.DIM}✗ Inactive{TableColors.RESET}"
|
||||||
|
name_color = TableColors.DIM
|
||||||
|
|
||||||
classification = classification_name or "N/A"
|
classification = classification_name or "N/A"
|
||||||
return f"{member.MemberId:3d} | {member.FirstName:<12} | {member.LastName:<15} | {classification:<12} | {active:^6} | {member.Email or 'N/A'}"
|
email = member.Email or f"{TableColors.DIM}N/A{TableColors.RESET}"
|
||||||
|
|
||||||
|
return (f"{TableColors.CYAN}{member.MemberId:3d}{TableColors.RESET} {TableColors.BORDER}│{TableColors.RESET} "
|
||||||
|
f"{name_color}{member.FirstName:<12}{TableColors.RESET} {TableColors.BORDER}│{TableColors.RESET} "
|
||||||
|
f"{name_color}{member.LastName:<15}{TableColors.RESET} {TableColors.BORDER}│{TableColors.RESET} "
|
||||||
|
f"{TableColors.YELLOW}{classification:<12}{TableColors.RESET} {TableColors.BORDER}│{TableColors.RESET} "
|
||||||
|
f"{active:<19} {TableColors.BORDER}│{TableColors.RESET} {email}")
|
||||||
|
|
||||||
|
|
||||||
def format_schedule_row(schedule, member_name: str = "", service_info: str = "") -> str:
|
def format_schedule_row(schedule, member_name: str = "", service_info: str = "") -> str:
|
||||||
"""Format a schedule for table display."""
|
"""Format a schedule for table display with colors."""
|
||||||
status_symbols = {
|
# Color-coded status formatting
|
||||||
ScheduleStatus.PENDING: "⏳",
|
status_enum = ScheduleStatus.from_raw(schedule.Status)
|
||||||
ScheduleStatus.ACCEPTED: "✅",
|
if status_enum == ScheduleStatus.PENDING:
|
||||||
ScheduleStatus.DECLINED: "❌"
|
status_display = f"{TableColors.WARNING}⏳ Pending{TableColors.RESET}"
|
||||||
}
|
elif status_enum == ScheduleStatus.ACCEPTED:
|
||||||
status_symbol = status_symbols.get(ScheduleStatus.from_raw(schedule.Status), "❓")
|
status_display = f"{TableColors.SUCCESS}✅ Accepted{TableColors.RESET}"
|
||||||
|
elif status_enum == ScheduleStatus.DECLINED:
|
||||||
|
status_display = f"{TableColors.ERROR}❌ Declined{TableColors.RESET}"
|
||||||
|
else:
|
||||||
|
status_display = f"{TableColors.DIM}❓ Unknown{TableColors.RESET}"
|
||||||
|
|
||||||
# Handle ScheduledAt - could be datetime object or string from DB
|
# Handle ScheduledAt - could be datetime object or string from DB
|
||||||
if schedule.ScheduledAt:
|
if schedule.ScheduledAt:
|
||||||
@@ -36,6 +74,50 @@ def format_schedule_row(schedule, member_name: str = "", service_info: str = "")
|
|||||||
# If it's already a datetime object
|
# If it's already a datetime object
|
||||||
scheduled_date = schedule.ScheduledAt.strftime("%Y-%m-%d %H:%M")
|
scheduled_date = schedule.ScheduledAt.strftime("%Y-%m-%d %H:%M")
|
||||||
else:
|
else:
|
||||||
scheduled_date = "N/A"
|
scheduled_date = f"{TableColors.DIM}N/A{TableColors.RESET}"
|
||||||
|
|
||||||
return f"{schedule.ScheduleId:3d} | {status_symbol} {schedule.Status:<8} | {member_name:<20} | {service_info:<15} | {scheduled_date}"
|
member_display = member_name if member_name else f"{TableColors.DIM}Unknown{TableColors.RESET}"
|
||||||
|
service_display = service_info if service_info else f"{TableColors.DIM}Unknown{TableColors.RESET}"
|
||||||
|
|
||||||
|
return (f"{TableColors.CYAN}{schedule.ScheduleId:3d}{TableColors.RESET} {TableColors.BORDER}│{TableColors.RESET} "
|
||||||
|
f"{status_display:<20} {TableColors.BORDER}│{TableColors.RESET} "
|
||||||
|
f"{TableColors.BOLD}{member_display:<20}{TableColors.RESET} {TableColors.BORDER}│{TableColors.RESET} "
|
||||||
|
f"{TableColors.BLUE}{service_display:<20}{TableColors.RESET} {TableColors.BORDER}│{TableColors.RESET} {scheduled_date}")
|
||||||
|
|
||||||
|
|
||||||
|
def create_table_header(*columns) -> str:
|
||||||
|
"""Create a styled table header."""
|
||||||
|
header_parts = []
|
||||||
|
for i, column in enumerate(columns):
|
||||||
|
if i == 0:
|
||||||
|
header_parts.append(f"{TableColors.HEADER}{column}{TableColors.RESET}")
|
||||||
|
else:
|
||||||
|
header_parts.append(f" {TableColors.BORDER}│{TableColors.RESET} {TableColors.HEADER}{column}{TableColors.RESET}")
|
||||||
|
return "".join(header_parts)
|
||||||
|
|
||||||
|
|
||||||
|
def create_table_separator(total_width: int) -> str:
|
||||||
|
"""Create a styled table separator line."""
|
||||||
|
return f"{TableColors.BORDER}{'─' * total_width}{TableColors.RESET}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_service_row(service, type_name: str, counts: dict) -> str:
|
||||||
|
"""Format a service for table display with colors."""
|
||||||
|
# Format date properly
|
||||||
|
date_str = str(service.ServiceDate) if service.ServiceDate else f"{TableColors.DIM}N/A{TableColors.RESET}"
|
||||||
|
|
||||||
|
# Color-code the counts
|
||||||
|
total_display = f"{TableColors.BOLD}{counts['total']}{TableColors.RESET}" if counts['total'] > 0 else f"{TableColors.DIM}0{TableColors.RESET}"
|
||||||
|
|
||||||
|
pending_display = f"{TableColors.WARNING}{counts['pending']}{TableColors.RESET}" if counts['pending'] > 0 else f"{TableColors.DIM}0{TableColors.RESET}"
|
||||||
|
|
||||||
|
accepted_display = f"{TableColors.SUCCESS}{counts['accepted']}{TableColors.RESET}" if counts['accepted'] > 0 else f"{TableColors.DIM}0{TableColors.RESET}"
|
||||||
|
|
||||||
|
declined_display = f"{TableColors.ERROR}{counts['declined']}{TableColors.RESET}" if counts['declined'] > 0 else f"{TableColors.DIM}0{TableColors.RESET}"
|
||||||
|
|
||||||
|
return (f"{TableColors.CYAN}{service.ServiceId:<3}{TableColors.RESET} {TableColors.BORDER}│{TableColors.RESET} "
|
||||||
|
f"{TableColors.BOLD}{date_str:<12}{TableColors.RESET} {TableColors.BORDER}│{TableColors.RESET} "
|
||||||
|
f"{TableColors.YELLOW}{type_name:<12}{TableColors.RESET} {TableColors.BORDER}│{TableColors.RESET} "
|
||||||
|
f"{total_display:<5} {TableColors.BORDER}│{TableColors.RESET} "
|
||||||
|
f"{pending_display:<7} {TableColors.BORDER}│{TableColors.RESET} "
|
||||||
|
f"{accepted_display:<8} {TableColors.BORDER}│{TableColors.RESET} {declined_display}")
|
||||||
@@ -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:
|
|
||||||
"""
|
|
||||||
High‑level data‑access layer.
|
|
||||||
|
|
||||||
Responsibilities
|
|
||||||
----------------
|
|
||||||
* CRUD helpers for the core tables.
|
|
||||||
* Round‑robin queue that respects:
|
|
||||||
- Members.LastAcceptedAt (fair order)
|
|
||||||
- Members.LastDeclinedAt (one‑day cool‑off)
|
|
||||||
* “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 non‑expired 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 (high‑level):
|
|
||||||
1️⃣ 5‑day decline boost – only if DeclineStreak < 2.
|
|
||||||
2️⃣ Oldest LastAcceptedAt (round‑robin).
|
|
||||||
3️⃣ Oldest LastScheduledAt (tie‑breaker).
|
|
||||||
|
|
||||||
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
|
|
||||||
/* ① 5‑day 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,
|
|
||||||
/* ② Round‑robin: oldest acceptance first */
|
|
||||||
COALESCE(LastAcceptedAt, '1970-01-01') ASC,
|
|
||||||
/* ③ Tie‑breaker: 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 round‑robin 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 round‑robin) and clears any cool‑off.
|
|
||||||
"""
|
|
||||||
# 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 round‑robin for the member
|
|
||||||
self.db.execute(
|
|
||||||
"""
|
|
||||||
UPDATE Members
|
|
||||||
SET LastAcceptedAt = CURRENT_TIMESTAMP,
|
|
||||||
LastDeclinedAt = NULL -- a successful accept clears any cool‑off
|
|
||||||
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 one‑day cool‑off.
|
|
||||||
* 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 one‑day cool‑off
|
|
||||||
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 'YYYY‑MM‑DD'
|
|
||||||
|
|
||||||
# 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 cool‑off 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),
|
|
||||||
)
|
|
||||||
@@ -169,7 +169,7 @@ if __name__ == "__main__":
|
|||||||
from backend.repositories import MemberRepository, ScheduleRepository, ServiceRepository, ServiceAvailabilityRepository
|
from backend.repositories import MemberRepository, ScheduleRepository, ServiceRepository, ServiceAvailabilityRepository
|
||||||
from backend.services.scheduling_service import SchedulingService
|
from backend.services.scheduling_service import SchedulingService
|
||||||
|
|
||||||
DB_PATH = Path(__file__).parent / "database6_accepts_and_declines2.db"
|
DB_PATH = Path(__file__).parent / "db" / "sqlite" / "database.db"
|
||||||
|
|
||||||
# Initialise DB connection (adjust DSN as needed)
|
# Initialise DB connection (adjust DSN as needed)
|
||||||
db = DatabaseConnection(DB_PATH)
|
db = DatabaseConnection(DB_PATH)
|
||||||
|
|||||||
@@ -234,4 +234,20 @@ class MemberRepository(BaseRepository[MemberModel]):
|
|||||||
DeclineStreak = COALESCE(DeclineStreak, 0) + 1
|
DeclineStreak = COALESCE(DeclineStreak, 0) + 1
|
||||||
WHERE {self._PK} = ?
|
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,))
|
||||||
@@ -48,12 +48,17 @@ class ScheduleRepository(BaseRepository[ScheduleModel]):
|
|||||||
scheduled_at, expires_at : datetime‑compatible | None
|
scheduled_at, expires_at : datetime‑compatible | None
|
||||||
``scheduled_at`` defaults to SQLite’s ``CURRENT_TIMESTAMP``.
|
``scheduled_at`` defaults to SQLite’s ``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(
|
schedule = ScheduleModel(
|
||||||
ScheduleId=-1, # placeholder – will be replaced
|
ScheduleId=-1, # placeholder – will be replaced
|
||||||
ServiceId=service_id,
|
ServiceId=service_id,
|
||||||
MemberId=member_id,
|
MemberId=member_id,
|
||||||
Status=status.value,
|
Status=status.value,
|
||||||
ScheduledAt=scheduled_at or "CURRENT_TIMESTAMP",
|
ScheduledAt=scheduled_at,
|
||||||
AcceptedAt=None,
|
AcceptedAt=None,
|
||||||
DeclinedAt=None,
|
DeclinedAt=None,
|
||||||
ExpiresAt=expires_at,
|
ExpiresAt=expires_at,
|
||||||
@@ -110,8 +115,8 @@ class ScheduleRepository(BaseRepository[ScheduleModel]):
|
|||||||
params: list[Any] = [new_status.value]
|
params: list[Any] = [new_status.value]
|
||||||
|
|
||||||
if new_status == ScheduleStatus.DECLINED:
|
if new_status == ScheduleStatus.DECLINED:
|
||||||
set_clause = "Status = ?, DeclinedAt = ?, DeclineReason = ?"
|
set_clause = "Status = ?, DeclinedAt = datetime('now'), DeclineReason = ?"
|
||||||
params.extend(["CURRENT_TIMESTAMP", reason])
|
params.extend([reason])
|
||||||
|
|
||||||
params.append(schedule_id) # WHERE clause param
|
params.append(schedule_id) # WHERE clause param
|
||||||
|
|
||||||
@@ -148,43 +153,6 @@ class ScheduleRepository(BaseRepository[ScheduleModel]):
|
|||||||
row = self.db.fetchone(sql, params)
|
row = self.db.fetchone(sql, params)
|
||||||
return row is not None
|
return row is not None
|
||||||
|
|
||||||
def is_available(self, member_id: int, service_id: int) -> bool:
|
|
||||||
"""
|
|
||||||
Cool‑down 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
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Status‑transition helpers (accept / decline) – kept for completeness.
|
# Status‑transition helpers (accept / decline) – kept for completeness.
|
||||||
@@ -194,16 +162,26 @@ class ScheduleRepository(BaseRepository[ScheduleModel]):
|
|||||||
schedule_id: int,
|
schedule_id: int,
|
||||||
accepted_at: Optional[Any] = None,
|
accepted_at: Optional[Any] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
sql = f"""
|
if accepted_at is None:
|
||||||
UPDATE {self._TABLE}
|
sql = f"""
|
||||||
SET Status = ?,
|
UPDATE {self._TABLE}
|
||||||
AcceptedAt = ?,
|
SET Status = ?,
|
||||||
DeclinedAt = NULL,
|
AcceptedAt = datetime('now'),
|
||||||
DeclineReason = NULL
|
DeclinedAt = NULL,
|
||||||
WHERE {self._PK} = ?
|
DeclineReason = NULL
|
||||||
"""
|
WHERE {self._PK} = ?
|
||||||
ts = accepted_at or "CURRENT_TIMESTAMP"
|
"""
|
||||||
self.db.execute(sql, (ScheduleStatus.ACCEPTED.value, ts, schedule_id))
|
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(
|
def mark_declined(
|
||||||
self,
|
self,
|
||||||
@@ -211,27 +189,36 @@ class ScheduleRepository(BaseRepository[ScheduleModel]):
|
|||||||
declined_at: Optional[Any] = None,
|
declined_at: Optional[Any] = None,
|
||||||
decline_reason: Optional[str] = None,
|
decline_reason: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
sql = f"""
|
if declined_at is None:
|
||||||
UPDATE {self._TABLE}
|
sql = f"""
|
||||||
SET Status = ?,
|
UPDATE {self._TABLE}
|
||||||
DeclinedAt = ?,
|
SET Status = ?,
|
||||||
DeclineReason = ?
|
DeclinedAt = datetime('now'),
|
||||||
WHERE {self._PK} = ?
|
DeclineReason = ?
|
||||||
"""
|
WHERE {self._PK} = ?
|
||||||
ts = declined_at or "CURRENT_TIMESTAMP"
|
"""
|
||||||
self.db.execute(sql, (ScheduleStatus.DECLINED.value, ts, decline_reason, schedule_id))
|
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))
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Same‑day helper – used by the scheduling service
|
# Same‑day helper – used by the scheduling service
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def has_schedule_on_date(self, member_id: int, service_date: str) -> bool:
|
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 YYYY‑MM‑DD).
|
``member_id`` on the calendar day ``service_date`` (format YYYY‑MM‑DD).
|
||||||
|
|
||||||
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
|
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"""
|
sql = f"""
|
||||||
SELECT 1
|
SELECT 1
|
||||||
@@ -239,9 +226,10 @@ class ScheduleRepository(BaseRepository[ScheduleModel]):
|
|||||||
JOIN Services AS sv ON s.ServiceId = sv.ServiceId
|
JOIN Services AS sv ON s.ServiceId = sv.ServiceId
|
||||||
WHERE s.MemberId = ?
|
WHERE s.MemberId = ?
|
||||||
AND sv.ServiceDate = ?
|
AND sv.ServiceDate = ?
|
||||||
|
AND s.Status IN (?, ?)
|
||||||
LIMIT 1
|
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
|
return row is not None
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -257,8 +245,14 @@ class ScheduleRepository(BaseRepository[ScheduleModel]):
|
|||||||
"""
|
"""
|
||||||
rows = self.db.fetchall(sql, (service_id, ScheduleStatus.PENDING.value))
|
rows = self.db.fetchall(sql, (service_id, ScheduleStatus.PENDING.value))
|
||||||
return [ScheduleModel.from_row(r) for r in rows]
|
return [ScheduleModel.from_row(r) for r in rows]
|
||||||
|
|
||||||
def delete(self, schedule_id: int) -> None:
|
def delete_schedule(self, schedule_id: int) -> bool:
|
||||||
"""Hard‑delete a schedule row (use with caution)."""
|
"""
|
||||||
|
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} = ?"
|
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
19
backend/requirements.txt
Normal 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
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional, Tuple, List
|
from typing import Optional, Tuple, List, Iterable, Literal
|
||||||
|
|
||||||
from backend.repositories import (
|
from backend.repositories import (
|
||||||
ClassificationRepository,
|
ClassificationRepository,
|
||||||
@@ -48,7 +48,7 @@ class SchedulingService:
|
|||||||
service_id: int,
|
service_id: int,
|
||||||
*,
|
*,
|
||||||
only_active: bool = True,
|
only_active: bool = True,
|
||||||
boost_seconds: int = 5 * 24 * 60 * 60,
|
boost_seconds: int = 2 * 24 * 60 * 60,
|
||||||
exclude_member_ids: Iterable[int] | None = None,
|
exclude_member_ids: Iterable[int] | None = None,
|
||||||
) -> Optional[Tuple[int, str, str, int]]:
|
) -> Optional[Tuple[int, str, str, int]]:
|
||||||
"""
|
"""
|
||||||
@@ -224,4 +224,78 @@ class SchedulingService:
|
|||||||
status=ScheduleStatus.DECLINED,
|
status=ScheduleStatus.DECLINED,
|
||||||
reason=reason,
|
reason=reason,
|
||||||
)
|
)
|
||||||
return ("created", new_sched.ScheduleId)
|
return ("created", new_sched.ScheduleId)
|
||||||
|
|
||||||
|
def preview_next_member(
|
||||||
|
self,
|
||||||
|
classification_ids: Iterable[int],
|
||||||
|
service_id: int,
|
||||||
|
*,
|
||||||
|
only_active: bool = True,
|
||||||
|
boost_seconds: int = 2 * 24 * 60 * 60,
|
||||||
|
exclude_member_ids: Iterable[int] | None = None,
|
||||||
|
) -> Optional[Tuple[int, str, str]]:
|
||||||
|
"""
|
||||||
|
Preview who would be scheduled next for a service without actually creating the schedule.
|
||||||
|
|
||||||
|
Same logic as schedule_next_member, but doesn't create any records.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Tuple[member_id, first_name, last_name] | None
|
||||||
|
The member who would be scheduled next, or None if no eligible member found.
|
||||||
|
"""
|
||||||
|
# Same logic as schedule_next_member but without creating records
|
||||||
|
svc = self.service_repo.get_by_id(service_id)
|
||||||
|
if svc is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
service_type_id = svc.ServiceTypeId
|
||||||
|
target_date = svc.ServiceDate
|
||||||
|
|
||||||
|
excluded = set(exclude_member_ids or [])
|
||||||
|
candidates: List = self.member_repo.candidate_queue(
|
||||||
|
classification_ids=list(classification_ids),
|
||||||
|
only_active=only_active,
|
||||||
|
boost_seconds=boost_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
for member in candidates:
|
||||||
|
member_id = member.MemberId
|
||||||
|
|
||||||
|
if member_id in excluded:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not self.availability_repo.get(member_id, service_type_id):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if self.schedule_repo.has_schedule_on_date(member_id, target_date):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if self.schedule_repo.has_any(
|
||||||
|
member_id,
|
||||||
|
service_id,
|
||||||
|
statuses=[ScheduleStatus.ACCEPTED],
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
if self.schedule_repo.has_any(
|
||||||
|
member_id,
|
||||||
|
service_id,
|
||||||
|
statuses=[ScheduleStatus.PENDING],
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
if self.schedule_repo.has_any(
|
||||||
|
member_id,
|
||||||
|
service_id,
|
||||||
|
statuses=[ScheduleStatus.DECLINED],
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Found eligible member - return without creating schedule
|
||||||
|
return (
|
||||||
|
member_id,
|
||||||
|
member.FirstName,
|
||||||
|
member.LastName,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
@@ -10,6 +10,7 @@ from backend.db import DatabaseConnection
|
|||||||
from backend.repositories import (
|
from backend.repositories import (
|
||||||
MemberRepository,
|
MemberRepository,
|
||||||
ClassificationRepository,
|
ClassificationRepository,
|
||||||
|
ServiceRepository,
|
||||||
ServiceTypeRepository,
|
ServiceTypeRepository,
|
||||||
ServiceAvailabilityRepository,
|
ServiceAvailabilityRepository,
|
||||||
)
|
)
|
||||||
@@ -156,6 +157,14 @@ def service_type_repo(
|
|||||||
return ServiceTypeRepository(db_connection)
|
return ServiceTypeRepository(db_connection)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service_repo(
|
||||||
|
db_connection: DatabaseConnection,
|
||||||
|
seed_lookup_tables,
|
||||||
|
) -> ServiceRepository:
|
||||||
|
return ServiceRepository(db_connection)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def service_availability_repo(
|
def service_availability_repo(
|
||||||
db_connection: DatabaseConnection,
|
db_connection: DatabaseConnection,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
# backend/tests/repositories/test_classification.py
|
# backend/tests/repositories/test_classification.py
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# Pytest suite for the ClassificationRepository.
|
# Comprehensive pytest suite for the ClassificationRepository.
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from typing import List
|
||||||
from backend.models import Classification as ClassificationModel
|
from backend.models import Classification as ClassificationModel
|
||||||
from backend.repositories import ClassificationRepository
|
from backend.repositories import ClassificationRepository
|
||||||
|
|
||||||
@@ -90,4 +91,383 @@ def test_delete(classification_repo):
|
|||||||
remaining_names = {r.ClassificationName for r in remaining}
|
remaining_names = {r.ClassificationName for r in remaining}
|
||||||
assert "TempVoice" not in remaining_names
|
assert "TempVoice" not in remaining_names
|
||||||
# the original four seeded names must still be present
|
# 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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# tests/repositories/test_member.py
|
# tests/repositories/test_member.py
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
from typing import List
|
from typing import List, Any
|
||||||
|
|
||||||
import pytest
|
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)
|
refreshed2 = member_repo.get_by_id(member.MemberId)
|
||||||
assert refreshed2.DeclineStreak == 2
|
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
|
||||||
444
backend/tests/repositories/test_schedule.py
Normal file
444
backend/tests/repositories/test_schedule.py
Normal 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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -1,69 +1,652 @@
|
|||||||
# tests/test_service_availability.py
|
# backend/tests/repositories/test_service_availability.py
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Comprehensive pytest suite for the ServiceAvailabilityRepository.
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
import pytest
|
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,
|
# Helper fixtures for test data
|
||||||
service_type_repo,
|
# ----------------------------------------------------------------------
|
||||||
|
@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
|
||||||
):
|
):
|
||||||
"""
|
"""Test creating and retrieving various valid member/service type combinations."""
|
||||||
Verify that:
|
# Create
|
||||||
• `grant` adds a new (member, service_type) pair idempotently.
|
created = service_availability_repo.create(member_id, service_type_id)
|
||||||
• `revoke` removes the pair.
|
assert created.MemberId == member_id
|
||||||
• The helper `members_for_type` returns the expected IDs.
|
assert created.ServiceTypeId == service_type_id
|
||||||
"""
|
assert isinstance(created.ServiceAvailabilityId, int)
|
||||||
# ------------------------------------------------------------------
|
assert created.ServiceAvailabilityId > 0
|
||||||
# Arrange – fetch the IDs we know exist from the fixture.
|
|
||||||
# ------------------------------------------------------------------
|
# Retrieve
|
||||||
# Alice is member_id 1, Bob is member_id 2 (AUTOINCREMENT order).
|
retrieved = service_availability_repo.get(member_id, service_type_id)
|
||||||
alice_id = 1
|
assert retrieved is not None
|
||||||
bob_id = 2
|
assert retrieved.ServiceAvailabilityId == created.ServiceAvailabilityId
|
||||||
|
assert retrieved.MemberId == member_id
|
||||||
# Service type IDs correspond to the order we inserted them:
|
assert retrieved.ServiceTypeId == service_type_id
|
||||||
# 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
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_by_member(service_availability_repo):
|
@pytest.mark.parametrize("invalid_member_id,invalid_service_type_id", [
|
||||||
"""
|
(999, 1), (1, 999), (999, 999),
|
||||||
Validate that `list_by_member` returns exactly the slots we seeded.
|
(-1, 1), (1, -1), (-1, -1),
|
||||||
"""
|
(0, 1), (1, 0), (0, 0),
|
||||||
# Alice (member_id 1) should have 9AM (1) and 6PM (3)
|
])
|
||||||
alice_slots = service_availability_repo.list_by_member(1)
|
def test_create_with_invalid_combinations_raises_error(
|
||||||
alice_type_ids = sorted([s.ServiceTypeId for s in alice_slots])
|
service_availability_repo: ServiceAvailabilityRepository,
|
||||||
assert alice_type_ids == [1, 3]
|
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)
|
@pytest.mark.parametrize("member_id", [1, 2])
|
||||||
bob_type_ids = sorted([s.ServiceTypeId for s in bob_slots])
|
def test_list_by_member_various_members(service_availability_repo: ServiceAvailabilityRepository, member_id: int):
|
||||||
assert bob_type_ids == [2, 3]
|
"""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})"
|
||||||
@@ -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
|
import pytest
|
||||||
|
import time
|
||||||
from backend.models.dataclasses import ServiceType as ServiceTypeModel
|
import uuid
|
||||||
|
from typing import List
|
||||||
def test_create_and_find(service_type_repo):
|
from backend.models import ServiceType as ServiceTypeModel
|
||||||
"""
|
from backend.repositories import ServiceTypeRepository
|
||||||
Verify that we can insert a brand‑new 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 # auto‑increment 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
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_all_contains_seeded_slots(service_type_repo):
|
# ----------------------------------------------------------------------
|
||||||
"""
|
# Helper utilities for test data
|
||||||
The three seeded slots (9AM, 11AM, 6PM) should be present and sorted
|
# ----------------------------------------------------------------------
|
||||||
alphabetically by the repository implementation.
|
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()
|
all_slots = service_type_repo.list_all()
|
||||||
names = [s.TypeName for s in all_slots]
|
names = [s.TypeName for s in all_slots]
|
||||||
|
|
||||||
# The seed fixture inserted exactly these three names.
|
# The seed fixture should have inserted these three names
|
||||||
assert set(names) >= {"9AM", "11AM", "6PM"}
|
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)
|
assert names == sorted(names)
|
||||||
|
|
||||||
|
|
||||||
def test_ensure_slots_is_idempotent(service_type_repo):
|
# ----------------------------------------------------------------------
|
||||||
"""
|
# 3️⃣ ensure_slots – bulk operations and idempotency
|
||||||
``ensure_slots`` should insert missing rows and return the full set,
|
# ----------------------------------------------------------------------
|
||||||
without creating duplicates on subsequent calls.
|
def test_ensure_slots_creates_missing(service_type_repo: ServiceTypeRepository):
|
||||||
"""
|
"""Test that ensure_slots creates missing service types."""
|
||||||
# First call – inserts the three seed rows plus a brand‑new one.
|
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"]
|
wanted = ["9AM", "11AM", "6PM", "3PM"]
|
||||||
result_first = service_type_repo.ensure_slots(wanted)
|
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)
|
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}
|
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)
|
result_second = service_type_repo.ensure_slots(wanted)
|
||||||
ids_after = {s.TypeName: s.ServiceTypeId for s in result_second}
|
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 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
402
frontend/.gitignore
vendored
@@ -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/
|
node_modules/
|
||||||
|
|
||||||
# Build output
|
# Visual Studio 6 build log
|
||||||
dist/
|
*.plg
|
||||||
build/
|
|
||||||
.next/
|
|
||||||
|
|
||||||
# TypeScript cache
|
# Visual Studio 6 workspace options file
|
||||||
*.tsbuildinfo
|
*.opt
|
||||||
|
|
||||||
# Logs
|
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||||
npm-debug.log*
|
*.vbw
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# Local environment files
|
# Visual Studio LightSwitch build output
|
||||||
.env
|
**/*.HTMLClient/GeneratedArtifacts
|
||||||
.env.local
|
**/*.DesktopClient/GeneratedArtifacts
|
||||||
.env.*.local
|
**/*.DesktopClient/ModelManifest.xml
|
||||||
|
**/*.Server/GeneratedArtifacts
|
||||||
|
**/*.Server/ModelManifest.xml
|
||||||
|
_Pvt_Extensions
|
||||||
|
|
||||||
# Editor directories and files
|
# Paket dependency manager
|
||||||
.vscode/
|
.paket/paket.exe
|
||||||
.idea/
|
paket-files/
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
|
|
||||||
# macOS
|
# FAKE - F# Make
|
||||||
.DS_Store
|
.fake/
|
||||||
|
|
||||||
# Linux
|
# CodeRush personal settings
|
||||||
*~
|
.cr/personal
|
||||||
|
|
||||||
# Windows
|
# Python Tools for Visual Studio (PTVS)
|
||||||
Thumbs.db
|
__pycache__/
|
||||||
ehthumbs.db
|
*.pyc
|
||||||
Desktop.ini
|
|
||||||
|
|
||||||
# TailwindCSS JIT cache
|
# Cake - Uncomment if you are using it
|
||||||
.tailwindcss/
|
# tools/**
|
||||||
|
# !tools/packages.config
|
||||||
|
|
||||||
# Optional: if using Next.js image optimization cache
|
# Tabs Studio
|
||||||
.next/cache/
|
*.tss
|
||||||
|
|
||||||
# Optional: if using Storybook
|
# Telerik's JustMock configuration file
|
||||||
storybook-static/
|
*.jmconfig
|
||||||
.out/
|
|
||||||
|
|
||||||
# Optional: if using testing coverage
|
# BizTalk build output
|
||||||
coverage/
|
*.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
|
||||||
22
frontend/Components/App.razor
Normal file
22
frontend/Components/App.razor
Normal 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>
|
||||||
19
frontend/Components/Layout/MainLayout.razor
Normal file
19
frontend/Components/Layout/MainLayout.razor
Normal 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>
|
||||||
96
frontend/Components/Layout/MainLayout.razor.css
Normal file
96
frontend/Components/Layout/MainLayout.razor.css
Normal 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;
|
||||||
|
}
|
||||||
38
frontend/Components/Layout/NavMenu.razor
Normal file
38
frontend/Components/Layout/NavMenu.razor
Normal 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>
|
||||||
|
|
||||||
105
frontend/Components/Layout/NavMenu.razor.css
Normal file
105
frontend/Components/Layout/NavMenu.razor.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
frontend/Components/Pages/Error.razor
Normal file
36
frontend/Components/Pages/Error.razor
Normal 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;
|
||||||
|
}
|
||||||
103
frontend/Components/Pages/Home.razor
Normal file
103
frontend/Components/Pages/Home.razor
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
199
frontend/Components/Pages/Members.razor
Normal file
199
frontend/Components/Pages/Members.razor
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
303
frontend/Components/Pages/Schedules.razor
Normal file
303
frontend/Components/Pages/Schedules.razor
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
236
frontend/Components/Pages/Services.razor
Normal file
236
frontend/Components/Pages/Services.razor
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
6
frontend/Components/Routes.razor
Normal file
6
frontend/Components/Routes.razor
Normal 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>
|
||||||
10
frontend/Components/_Imports.razor
Normal file
10
frontend/Components/_Imports.razor
Normal 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
|
||||||
7
frontend/Models/Classification.cs
Normal file
7
frontend/Models/Classification.cs
Normal 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
21
frontend/Models/Member.cs
Normal 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}";
|
||||||
|
}
|
||||||
18
frontend/Models/Schedule.cs
Normal file
18
frontend/Models/Schedule.cs
Normal 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; }
|
||||||
|
}
|
||||||
9
frontend/Models/Service.cs
Normal file
9
frontend/Models/Service.cs
Normal 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; }
|
||||||
|
}
|
||||||
7
frontend/Models/ServiceType.cs
Normal file
7
frontend/Models/ServiceType.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace NimbusFlow.Frontend.Models;
|
||||||
|
|
||||||
|
public class ServiceType
|
||||||
|
{
|
||||||
|
public int ServiceTypeId { get; set; }
|
||||||
|
public string TypeName { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
9
frontend/NimbusFlow.Frontend.csproj
Normal file
9
frontend/NimbusFlow.Frontend.csproj
Normal 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
34
frontend/Program.cs
Normal 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();
|
||||||
38
frontend/Properties/launchSettings.json
Normal file
38
frontend/Properties/launchSettings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
- **Dashboard**: Overview of system statistics and recent activities
|
||||||
- [@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
|
- **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
|
- **Components/Pages**: Razor components for each major feature
|
||||||
export default tseslint.config([
|
- **Services**: HTTP client services for backend API communication
|
||||||
globalIgnores(['dist']),
|
- **Models**: C# data models matching the Python backend schema
|
||||||
{
|
- **Layout**: Consistent navigation and layout components
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
## Prerequisites
|
||||||
...tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
...tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
...tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
- .NET 8.0 SDK
|
||||||
],
|
- Python backend API running on http://localhost:8000/api/
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
## Getting Started
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
1. **Restore packages**:
|
||||||
},
|
```bash
|
||||||
// other options...
|
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
|
||||||
182
frontend/Services/ApiService.cs
Normal file
182
frontend/Services/ApiService.cs
Normal 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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
frontend/Services/IApiService.cs
Normal file
36
frontend/Services/IApiService.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
8
frontend/appsettings.Development.json
Normal file
8
frontend/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
frontend/appsettings.json
Normal file
9
frontend/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
@@ -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>
|
|
||||||
3021
frontend/package-lock.json
generated
3021
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 |
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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 |
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>,
|
|
||||||
)
|
|
||||||
1
frontend/src/vite-env.d.ts
vendored
1
frontend/src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
@@ -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"]
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"files": [],
|
|
||||||
"references": [
|
|
||||||
{ "path": "./tsconfig.app.json" },
|
|
||||||
{ "path": "./tsconfig.node.json" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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"]
|
|
||||||
}
|
|
||||||
@@ -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
51
frontend/wwwroot/app.css
Normal 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;
|
||||||
|
}
|
||||||
7
frontend/wwwroot/bootstrap/bootstrap.min.css
vendored
Normal file
7
frontend/wwwroot/bootstrap/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/wwwroot/bootstrap/bootstrap.min.css.map
Normal file
1
frontend/wwwroot/bootstrap/bootstrap.min.css.map
Normal file
File diff suppressed because one or more lines are too long
641
frontend/wwwroot/css/nimbusflow.css
Normal file
641
frontend/wwwroot/css/nimbusflow.css
Normal 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;
|
||||||
|
}
|
||||||
BIN
frontend/wwwroot/favicon.png
Normal file
BIN
frontend/wwwroot/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Reference in New Issue
Block a user