public-ready-init

This commit is contained in:
Damien Coles 2026-01-26 10:30:49 -05:00
commit 4b80fe28fe
238 changed files with 25139 additions and 0 deletions

70
.gitignore vendored Normal file
View File

@ -0,0 +1,70 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
.venv/
venv/
ENV/
env/
# Django
*.log
local_settings.py
db.sqlite3
*.pot
*.pyc
staticfiles/
media/
# Environment files
.env
.env.local
.env.*.local
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# SvelteKit
.svelte-kit/
# Testing
.coverage
htmlcov/
.pytest_cache/
# Misc
.DS_Store
Thumbs.db
*.bak
# Docker
docker-compose.override.yml

299
README.md Normal file
View File

@ -0,0 +1,299 @@
# Nexus 3
A modern business management platform built with Django GraphQL and SvelteKit. Nexus 3 represents a significant architectural evolution from its predecessors, featuring a GraphQL API, modular backend architecture, and enhanced frontend state management.
## Improvements Over Nexus 1 and Nexus 2
### Architectural Improvements Over Nexus 1
| Feature | Nexus 1 | Nexus 3 |
|---------|---------|---------|
| **API Architecture** | REST API | GraphQL API |
| **Frontend Framework** | React (JSX) | SvelteKit with TypeScript |
| **Backend Structure** | Monolithic (single `models.py`, `views.py`) | Modular (separate directories per domain) |
| **State Management** | Prop drilling | Svelte stores with typed operations |
| **Authentication** | JWT with DRF | JWT with graphql-jwt |
| **Type Safety** | None | Full TypeScript support |
| **Code Organization** | Flat structure | Domain-driven design |
### Architectural Improvements Over Nexus 2
| Feature | Nexus 2 | Nexus 3 |
|---------|---------|---------|
| **API Architecture** | REST API (DRF ViewSets) | GraphQL API (Graphene) |
| **Data Fetching** | Multiple REST endpoints | Single GraphQL endpoint with precise queries |
| **Backend Patterns** | MVC with serializers | Repository + Command pattern |
| **Settings Management** | Single `settings.py` | Split settings (base/dev/prod) |
| **Frontend Data Layer** | Direct API calls | Typed GraphQL operations with URQL |
| **Caching** | None | URQL exchange-based caching |
| **Error Handling** | HTTP status codes | GraphQL error extensions |
### Key Improvements in Nexus 3
1. **GraphQL API**: Clients request exactly the data they need, reducing over-fetching and under-fetching
2. **Repository Pattern**: Data access is abstracted through repositories, making testing and maintenance easier
3. **Command Pattern**: Business logic is encapsulated in commands, promoting single responsibility
4. **Factory Pattern**: Object creation is standardized through factories
5. **Split Settings**: Environment-specific configuration (development, production)
6. **Typed Frontend**: Full TypeScript support with generated types for GraphQL operations
7. **URQL Client**: Modern GraphQL client with built-in caching and auth exchange
8. **Modular Structure**: Each domain (accounts, customers, services, etc.) has its own:
- Models
- Repositories
- Commands
- GraphQL types, inputs, queries, and mutations
- Frontend stores and components
## Tech Stack
### Backend
- Python 3.11+
- Django 4.2+
- Graphene-Django (GraphQL)
- graphql-jwt (Authentication)
- PostgreSQL
- Redis (caching)
### Frontend
- SvelteKit
- TypeScript
- URQL (GraphQL client)
- TailwindCSS
## Project Structure
```
nexus-3/
├── backend/
│ ├── config/
│ │ └── settings/
│ │ ├── base.py
│ │ ├── development.py
│ │ └── production.py
│ ├── core/
│ │ ├── models/ # Domain models
│ │ ├── repositories/ # Data access layer
│ │ ├── commands/ # Business logic
│ │ ├── factories/ # Object creation
│ │ ├── services/ # Service layer
│ │ └── utils/ # Utilities
│ └── graphql_api/
│ ├── types/ # GraphQL types
│ ├── inputs/ # GraphQL input types
│ ├── queries/ # GraphQL queries
│ └── mutations/ # GraphQL mutations
├── frontend/
│ └── src/
│ ├── lib/
│ │ ├── components/ # UI components by domain
│ │ ├── graphql/ # GraphQL client & operations
│ │ └── stores/ # Svelte stores by domain
│ └── routes/ # SvelteKit routes
└── docker-compose.yml
```
## Quick Start
### Prerequisites
- Python 3.11+
- Node.js 18+
- PostgreSQL 15+ (or use Docker)
- Redis (optional, for caching)
### Using Docker (Recommended)
```bash
# Clone the repository
git clone <repository-url>
cd nexus-3
# Copy environment file
cp backend/.env.example backend/.env
# Edit backend/.env with your settings
# Start all services
docker-compose up -d
# Run migrations
docker-compose exec backend python manage.py migrate
# Create a superuser
docker-compose exec backend python manage.py createsuperuser
```
### Manual Setup
#### Backend
```bash
cd backend
# Create virtual environment
python -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
# Copy environment file
cp .env.example .env
# Edit .env with your settings
# Run migrations
python manage.py migrate
# Create superuser
python manage.py createsuperuser
# Start development server
python manage.py runserver
```
#### Frontend
```bash
cd frontend
# Install dependencies
npm install
# Start development server
npm run dev
```
## Configuration
### Backend Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `DJANGO_SETTINGS_MODULE` | Settings module to use | `config.settings.development` |
| `DJANGO_SECRET_KEY` | Django secret key (production) | - |
| `DEV_SECRET_KEY` | Django secret key (development) | - |
| `DB_NAME` | Database name | `nexus` |
| `DB_USER` | Database user | `postgres` |
| `DB_PASSWORD` | Database password | - |
| `DB_HOST` | Database host | `localhost` |
| `DB_PORT` | Database port | `5432` |
| `REDIS_URL` | Redis connection URL | `redis://localhost:6379/1` |
| `SENTRY_DSN` | Sentry DSN for error tracking | - |
### Frontend Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `PUBLIC_GRAPHQL_URL` | GraphQL API endpoint | `http://localhost:8000/graphql/` |
## GraphQL API
The GraphQL API is available at `/graphql/`. In development, you can use the GraphiQL interface to explore the schema.
### Example Query
```graphql
query GetAccounts {
accounts {
id
name
customer {
id
name
}
services {
id
status
scheduledDate
}
}
}
```
### Example Mutation
```graphql
mutation CreateAccount($input: AccountCreateInput!) {
createAccount(input: $input) {
account {
id
name
}
errors
}
}
```
## Core Entities
- **Customers**: Client organizations
- **Accounts**: Individual locations/sites belonging to customers
- **Services**: Scheduled service visits
- **Projects**: Multi-service projects
- **Invoices**: Billing records
- **Labor**: Labor tracking records
- **Revenue**: Revenue tracking
- **Schedules**: Employee schedules
- **Reports**: Generated reports
- **Punchlists**: Customizable service checklists
## Customizing the Punchlist Feature
The Punchlist model is designed to be customizable for your specific service workflow. The default fields provide a generic structure with sections for:
- Front area (customer-facing)
- Main work area
- Equipment
- Back area
- End of visit checklist
To customize:
1. Edit `backend/core/models/punchlists/punchlists.py` to add/remove/rename fields
2. Update `backend/graphql_api/types/punchlists.py` to expose the new fields
3. Update `backend/graphql_api/inputs/punchlists/punchlists.py` for create/update inputs
4. Run `python manage.py makemigrations` and `python manage.py migrate`
5. Update the frontend components accordingly
## Development
### Running Tests
```bash
# Backend tests
cd backend
python manage.py test
# Frontend tests
cd frontend
npm run test
```
### Code Style
```bash
# Backend (using black and isort)
cd backend
black .
isort .
# Frontend (using prettier and eslint)
cd frontend
npm run lint
npm run format
```
## Deployment
### Production Checklist
1. Set `DJANGO_SETTINGS_MODULE=config.settings.production`
2. Generate a strong `DJANGO_SECRET_KEY`
3. Configure PostgreSQL with proper credentials
4. Set up Redis for caching
5. Configure ALLOWED_HOSTS and CORS settings
6. Set up SSL/TLS
7. Configure email settings
8. (Optional) Set up Sentry for error tracking
## License
MIT License - See LICENSE file for details.

31
backend/.env.example Normal file
View File

@ -0,0 +1,31 @@
# Django Settings
DJANGO_SETTINGS_MODULE=config.settings.development
DJANGO_SECRET_KEY=your-secret-key-here-generate-a-new-one
DEV_SECRET_KEY=your-dev-secret-key-here
# Database (Production)
DB_NAME=nexus
DB_USER=postgres
DB_PASSWORD=your-database-password
DB_HOST=localhost
DB_PORT=5432
# Allowed Hosts (Production)
DJANGO_ALLOWED_HOST=your-domain.com
# Email Settings (Production)
EMAIL_HOST=smtp.your-email-provider.com
EMAIL_PORT=587
EMAIL_HOST_USER=your-email@your-domain.com
EMAIL_HOST_PASSWORD=your-email-password
# Redis Cache (Production)
REDIS_URL=redis://localhost:6379/1
# Sentry Error Tracking (Optional)
SENTRY_DSN=
# Google API Integration (Optional)
# GOOGLE_CREDENTIALS_PATH=/path/to/credentials.json
# GOOGLE_CALENDAR_ID=primary
# GOOGLE_IMPERSONATOR_EMAIL=admin@your-domain.com

3
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.env
db.sqlite3
data/

22
backend/Dockerfile Normal file
View File

@ -0,0 +1,22 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
libpq-dev \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Expose port
EXPOSE 8000
# Default command
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

0
backend/__init__.py Normal file
View File

View File

10
backend/config/asgi.py Normal file
View File

@ -0,0 +1,10 @@
"""
ASGI config for the project.
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
application = get_asgi_application()

View File

@ -0,0 +1,13 @@
"""
Settings initialization.
This module determines which settings file to use based on environment.
"""
import os
# Set the environment variable to control which settings file is loaded
environment = os.environ.get('DJANGO_ENVIRONMENT', 'development')
if environment == 'production':
from .production import *
else:
from .development import *

View File

@ -0,0 +1,166 @@
"""
Base settings for the application.
"""
import os
from datetime import timedelta
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent.parent
# Application definition
INSTALLED_APPS = [
# Django apps
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# Third-party apps
'graphene_django',
'corsheaders',
'django_filters',
# Local apps
'backend.core',
'backend.graphql_api',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'config.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'config.wsgi.application'
# Password validation
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
STATIC_URL = 'static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
# Media files
MEDIA_URL = 'media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
# Default primary key field type
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# GraphQL settings
GRAPHENE = {
'SCHEMA': 'backend.graphql_api.schema.schema',
"MIDDLEWARE": [
"graphql_jwt.middleware.JSONWebTokenMiddleware",
],
}
# Authentication backends
AUTHENTICATION_BACKENDS = [
'graphql_jwt.backends.JSONWebTokenBackend',
'django.contrib.auth.backends.ModelBackend',
]
# JWT settings
GRAPHQL_JWT = {
'JWT_VERIFY_EXPIRATION': True,
'JWT_EXPIRATION_DELTA': timedelta(days=7),
'JWT_REFRESH_EXPIRATION_DELTA': timedelta(days=30),
}
# Create logs directory if it doesn't exist
logs_dir = os.path.join(BASE_DIR, 'logs')
if not os.path.exists(logs_dir):
os.makedirs(logs_dir)
# Logging configuration
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '{levelname} {asctime} {module} {message}',
'style': '{',
},
'simple': {
'format': '{levelname} {message}',
'style': '{',
},
},
'handlers': {
'console': {
'level': 'INFO',
'class': 'logging.StreamHandler',
'formatter': 'verbose',
},
'file': {
'level': 'INFO',
'class': 'logging.FileHandler',
'filename': os.path.join(logs_dir, 'django.log'),
'formatter': 'verbose',
},
},
'loggers': {
'django': {
'handlers': ['console', 'file'],
'level': 'INFO',
'propagate': True,
},
'core': {
'handlers': ['console', 'file'],
'level': 'INFO',
'propagate': True,
},
'graphql_api': {
'handlers': ['console', 'file'],
'level': 'INFO',
'propagate': True,
},
},
}

View File

@ -0,0 +1,42 @@
"""
Development settings for the application.
"""
from .base import *
import os
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.environ.get('DEV_SECRET_KEY')
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = ['localhost', '127.0.0.1']
# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# CORS settings
CORS_ALLOW_ALL_ORIGINS = True # Only in development!
# Email settings
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
# GraphiQL settings
GRAPHENE['MIDDLEWARE'] += ['graphene_django.debug.DjangoDebugMiddleware']
# Show SQL queries in console
LOGGING['loggers']['django.db.backends'] = {
'handlers': ['console'],
'level': 'DEBUG',
'propagate': False,
}

View File

@ -0,0 +1,99 @@
"""
Production settings for the application.
"""
from .base import *
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
import os
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY')
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False
ALLOWED_HOSTS = [
os.environ.get('DJANGO_ALLOWED_HOST', 'example.com'),
'www.example.com', # Update with your domain
]
# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ.get('DB_NAME', 'app_db'),
'USER': os.environ.get('DB_USER', 'app_user'),
'PASSWORD': os.environ.get('DB_PASSWORD', ''),
'HOST': os.environ.get('DB_HOST', 'localhost'),
'PORT': os.environ.get('DB_PORT', '5432'),
'CONN_MAX_AGE': 600, # 10 minutes
}
}
# Security settings
SECURE_HSTS_SECONDS = 31536000 # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# CORS settings
CORS_ALLOWED_ORIGINS = [
'https://example.com',
'https://www.example.com', # Update with your frontend domain
]
# Email settings
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = os.environ.get('EMAIL_HOST')
EMAIL_PORT = int(os.environ.get('EMAIL_PORT', 587))
EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER')
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD')
EMAIL_USE_TLS = True
DEFAULT_FROM_EMAIL = 'noreply@example.com' # Update with your email
# Static files
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
# Configure Sentry for error tracking (optional)
try:
dsn = os.environ.get('SENTRY_DSN')
if dsn: # Only initialize if DSN is provided
sentry_sdk.init(
dsn=dsn,
integrations=[DjangoIntegration()],
traces_sample_rate=0.1,
send_default_pii=False
)
except ImportError:
pass
# Cache settings
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': os.environ.get('REDIS_URL', 'redis://localhost:6379/1'),
}
}
# Logging
log_path = '/var/log/django/django.log'
log_dir = os.path.dirname(log_path)
if os.path.exists(log_dir) and os.access(log_dir, os.W_OK):
LOGGING['handlers']['file']['filename'] = log_path
# Turn off DRF Browsable API in production
REST_FRAMEWORK = {
'DEFAULT_RENDERER_CLASSES': (
'rest_framework.renderers.JSONRenderer',
)
}

19
backend/config/urls.py Normal file
View File

@ -0,0 +1,19 @@
"""
URL Configuration for the project.
"""
from django.contrib import admin
from django.urls import path
from django.conf import settings
from django.conf.urls.static import static
from django.views.decorators.csrf import csrf_exempt
from graphene_django.views import GraphQLView
urlpatterns = [
path('admin/', admin.site.urls),
path('graphql/', csrf_exempt(GraphQLView.as_view(graphiql=settings.DEBUG))),
]
# Serve static and media files in development
if settings.DEBUG:
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

10
backend/config/wsgi.py Normal file
View File

@ -0,0 +1,10 @@
"""
WSGI config for the project.
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
application = get_wsgi_application()

0
backend/core/__init__.py Normal file
View File

13
backend/core/admin.py Normal file
View File

@ -0,0 +1,13 @@
from django.contrib import admin
from backend.core.models import *
admin.site.register(Account)
admin.site.register(Customer)
admin.site.register(Invoice)
admin.site.register(Labor)
admin.site.register(Profile)
admin.site.register(Project)
admin.site.register(Report)
admin.site.register(Revenue)
admin.site.register(Service)
admin.site.register(Schedule)

6
backend/core/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'backend.core'

View File

@ -0,0 +1,102 @@
from backend.core.commands.base import Command, CommandResult
from backend.core.commands.customers.customers import CreateCustomerCommand, UpdateCustomerCommand, \
DeleteCustomerCommand, MarkCustomerInactiveCommand
from backend.core.commands.accounts.accounts import CreateAccountCommand, UpdateAccountCommand, DeleteAccountCommand, \
MarkAccountInactiveCommand, GetAccountRevenueCommand
from backend.core.commands.services.services import CreateServiceCommand, UpdateServiceCommand, DeleteServiceCommand, \
CompleteServiceCommand, CancelServiceCommand, AssignTeamMembersCommand, GetServicesByDateRangeCommand
from backend.core.commands.services.bulk_schedule import BulkScheduleServicesCommand
from backend.core.commands.projects.projects import CreateProjectCommand, UpdateProjectCommand, DeleteProjectCommand
from backend.core.commands.invoices.invoices import CreateInvoiceCommand, SendInvoiceCommand, MarkInvoicePaidCommand, \
CancelInvoiceCommand, FilterInvoicesCommand
from backend.core.commands.labor.labor import CreateLaborCommand, UpdateLaborCommand, DeleteLaborCommand, \
EndLaborCommand, CalculateLaborCostCommand
from backend.core.commands.profiles.profiles import CreateProfileCommand, UpdateProfileCommand, DeleteProfileCommand, \
SearchProfilesCommand
from backend.core.commands.reports.reports import (
CreateReportCommand,
UpdateReportCommand,
DeleteReportCommand,
GetTeamMemberReportsCommand,
GetTeamMemberActivityCommand,
GetTeamSummaryCommand
)
from backend.core.commands.revenues.revenues import (
CreateRevenueCommand,
UpdateRevenueCommand,
DeleteRevenueCommand,
EndRevenueCommand,
GetRevenueByDateRangeCommand,
CalculateTotalRevenueCommand,
GetActiveRevenuesCommand
)
from backend.core.commands.schedules.schedules import (
CreateScheduleCommand,
UpdateScheduleCommand,
DeleteScheduleCommand,
EndScheduleCommand,
GetActiveSchedulesCommand,
GenerateServicesCommand,
GetScheduleByAccountCommand,
SearchSchedulesCommand
)
__all__ = [
'Command',
'CommandResult',
'CreateServiceCommand',
'UpdateServiceCommand',
'DeleteServiceCommand',
'CreateProjectCommand',
'UpdateProjectCommand',
'DeleteProjectCommand',
'CreateCustomerCommand',
'UpdateCustomerCommand',
'DeleteCustomerCommand',
'MarkCustomerInactiveCommand',
'CreateAccountCommand',
'UpdateAccountCommand',
'DeleteAccountCommand',
'MarkAccountInactiveCommand',
'GetAccountRevenueCommand',
'CreateInvoiceCommand',
'SendInvoiceCommand',
'MarkInvoicePaidCommand',
'CancelInvoiceCommand',
'FilterInvoicesCommand',
'CreateLaborCommand',
'UpdateLaborCommand',
'DeleteLaborCommand',
'EndLaborCommand',
'CalculateLaborCostCommand',
'CreateProfileCommand',
'UpdateProfileCommand',
'DeleteProfileCommand',
'SearchProfilesCommand',
'CreateReportCommand',
'UpdateReportCommand',
'DeleteReportCommand',
'GetTeamMemberReportsCommand',
'GetTeamMemberActivityCommand',
'GetTeamSummaryCommand',
'CreateRevenueCommand',
'UpdateRevenueCommand',
'DeleteRevenueCommand',
'EndRevenueCommand',
'GetRevenueByDateRangeCommand',
'CalculateTotalRevenueCommand',
'GetActiveRevenuesCommand',
'CreateScheduleCommand',
'UpdateScheduleCommand',
'DeleteScheduleCommand',
'EndScheduleCommand',
'GetActiveSchedulesCommand',
'GenerateServicesCommand',
'CompleteServiceCommand',
'CancelServiceCommand',
'AssignTeamMembersCommand',
'GetServicesByDateRangeCommand',
'GetScheduleByAccountCommand',
'SearchSchedulesCommand',
'BulkScheduleServicesCommand',
]

View File

@ -0,0 +1,677 @@
"""
Commands for account-related operations.
"""
from typing import Any, Dict, List, Optional
from datetime import datetime
from backend.core.models.accounts.accounts import Account
from backend.core.repositories.accounts.accounts import AccountRepository
from backend.core.repositories.customers.customers import CustomerRepository
from backend.core.utils.validators import (
is_valid_uuid, is_valid_email, is_valid_phone, is_valid_date,
validate_required_fields, validate_model_exists
)
from backend.core.utils.helpers import generate_uuid, parse_date
from backend.core.commands.base import Command, CommandResult
class CreateAccountCommand(Command):
"""
Command to create a new account.
"""
def __init__(
self,
account_repo: AccountRepository,
customer_repo: CustomerRepository,
customer_id: str,
name: str,
street_address: str,
city: str,
state: str,
zip_code: str,
start_date: str,
primary_contact_first_name: str, # Changed from contact_first_name
primary_contact_last_name: str, # Changed from contact_last_name
primary_contact_phone: str, # Changed from contact_phone
primary_contact_email: str, # Changed from contact_email
secondary_contact_first_name: Optional[str] = None, # Added
secondary_contact_last_name: Optional[str] = None, # Added
secondary_contact_phone: Optional[str] = None, # Added
secondary_contact_email: Optional[str] = None, # Added
end_date: Optional[str] = None
):
"""
Initialize the create account command.
Args:
account_repo: Repository for account operations.
customer_repo: Repository for customer operations.
customer_id: ID of the customer this account belongs to.
name: Name of the account.
street_address: Street address of the account.
city: City of the account.
state: State of the account.
zip_code: ZIP code of the account.
primary_contact_first_name: First name of the primary contact.
primary_contact_last_name: Last name of the primary contact.
primary_contact_phone: Phone number of the primary contact.
primary_contact_email: Email of the primary contact.
secondary_contact_first_name: First name of the secondary contact (optional).
secondary_contact_last_name: Last name of the secondary contact (optional).
secondary_contact_phone: Phone number of the secondary contact (optional).
secondary_contact_email: Email of the secondary contact (optional).
start_date: Start date of the account (YYYY-MM-DD).
end_date: End date of the account (YYYY-MM-DD, optional).
"""
self.account_repo = account_repo
self.customer_repo = customer_repo
self.customer_id = customer_id
self.name = name
self.street_address = street_address
self.city = city
self.state = state
self.zip_code = zip_code
self.primary_contact_first_name = primary_contact_first_name
self.primary_contact_last_name = primary_contact_last_name
self.primary_contact_phone = primary_contact_phone
self.primary_contact_email = primary_contact_email
self.secondary_contact_first_name = secondary_contact_first_name
self.secondary_contact_last_name = secondary_contact_last_name
self.secondary_contact_phone = secondary_contact_phone
self.secondary_contact_email = secondary_contact_email
self.start_date = start_date
self.end_date = end_date
def validate(self) -> Dict[str, Any]:
"""
Validate the account creation data.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Check required fields
required_fields = [
'customer_id', 'name', 'street_address', 'city', 'state', 'zip_code',
'primary_contact_first_name', 'primary_contact_last_name',
'primary_contact_phone', 'primary_contact_email', 'start_date'
]
field_values = {
'customer_id': self.customer_id,
'name': self.name,
'street_address': self.street_address,
'city': self.city,
'state': self.state,
'zip_code': self.zip_code,
'primary_contact_first_name': self.primary_contact_first_name,
'primary_contact_last_name': self.primary_contact_last_name,
'primary_contact_phone': self.primary_contact_phone,
'primary_contact_email': self.primary_contact_email,
'start_date': self.start_date
}
missing_fields = validate_required_fields(field_values, required_fields)
if missing_fields:
errors.append(f"Required fields missing: {', '.join(missing_fields)}")
# Validate customer exists
if not errors and self.customer_id:
customer_validation = validate_model_exists(
self.customer_id, 'customer', self.customer_repo.get_by_id
)
if not customer_validation['valid']:
errors.append(customer_validation['error'])
if not errors and self.primary_contact_email and not is_valid_email(self.primary_contact_email):
errors.append("Invalid primary contact email format.")
if not errors and self.primary_contact_phone and not is_valid_phone(self.primary_contact_phone):
errors.append("Invalid primary contact phone format.")
if not errors and self.secondary_contact_email and not is_valid_email(self.secondary_contact_email):
errors.append("Invalid secondary contact email format.")
if not errors and self.secondary_contact_phone and not is_valid_phone(self.secondary_contact_phone):
errors.append("Invalid secondary contact phone format.")
# Validate date formats
if not errors and self.start_date and not is_valid_date(self.start_date):
errors.append("Invalid start date format. Use YYYY-MM-DD.")
if not errors and self.end_date and not is_valid_date(self.end_date):
errors.append("Invalid end date format. Use YYYY-MM-DD.")
# Validate start date is before end date if both provided
if not errors and self.start_date and self.end_date:
start = parse_date(self.start_date)
end = parse_date(self.end_date)
if start and end and start > end:
errors.append("Start date must be before end date.")
# Check if customer is active
if not errors:
customer = self.customer_repo.get_by_id(self.customer_id)
if customer and customer.end_date:
customer_end_date = parse_date(customer.end_date)
today = datetime.now().date()
if customer_end_date and customer_end_date < today:
errors.append(f"Cannot create account for inactive customer")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[Account]:
"""
Execute the account creation command.
Returns:
CommandResult[Account]: Result of the command execution.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Create account data
account_id = generate_uuid()
# Create account data dictionary
account_data = {
'id': account_id,
'customer_id': self.customer_id,
'name': self.name,
'street_address': self.street_address,
'city': self.city,
'state': self.state,
'zip_code': self.zip_code,
'primary_contact_first_name': self.primary_contact_first_name,
'primary_contact_last_name': self.primary_contact_last_name,
'primary_contact_phone': self.primary_contact_phone,
'primary_contact_email': self.primary_contact_email,
'secondary_contact_first_name': self.secondary_contact_first_name,
'secondary_contact_last_name': self.secondary_contact_last_name,
'secondary_contact_phone': self.secondary_contact_phone,
'secondary_contact_email': self.secondary_contact_email,
'start_date': self.start_date,
'end_date': self.end_date
}
# Save to repository
created_account = self.account_repo.create(account_data)
return CommandResult.success_result(
created_account,
f"Account {self.name} created successfully for customer {self.customer_id}"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to create account"
)
class UpdateAccountCommand(Command):
"""
Command to update an existing account.
"""
def __init__(
self,
account_repo: AccountRepository,
account_id: str,
name: Optional[str] = None,
street_address: Optional[str] = None,
city: Optional[str] = None,
state: Optional[str] = None,
zip_code: Optional[str] = None,
primary_contact_first_name: Optional[str] = None,
primary_contact_last_name: Optional[str] = None,
primary_contact_phone: Optional[str] = None,
primary_contact_email: Optional[str] = None,
secondary_contact_first_name: Optional[str] = None,
secondary_contact_last_name: Optional[str] = None,
secondary_contact_phone: Optional[str] = None,
secondary_contact_email: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None
):
"""
Initialize the update account command.
Args:
account_repo: Repository for account operations.
account_id: ID of the account to update.
name: New name for the account.
street_address: New street address for the account.
city: New city for the account.
state: New state for the account.
zip_code: New ZIP code for the account.
primary_contact_first_name: New first name for the primary contact.
primary_contact_last_name: New last name for the primary contact.
primary_contact_phone: New phone number for the primary contact.
primary_contact_email: New email for the primary contact.
secondary_contact_first_name: New first name for the secondary contact.
secondary_contact_last_name: New last name for the secondary contact.
secondary_contact_phone: New phone number for the secondary contact.
secondary_contact_email: New email for the secondary contact.
start_date: New start date for the account.
end_date: New end date for the account.
"""
self.account_repo = account_repo
self.account_id = account_id
self.name = name
self.street_address = street_address
self.city = city
self.state = state
self.zip_code = zip_code
self.primary_contact_first_name = primary_contact_first_name
self.primary_contact_last_name = primary_contact_last_name
self.primary_contact_phone = primary_contact_phone
self.primary_contact_email = primary_contact_email
self.secondary_contact_first_name = secondary_contact_first_name
self.secondary_contact_last_name = secondary_contact_last_name
self.secondary_contact_phone = secondary_contact_phone
self.secondary_contact_email = secondary_contact_email
self.start_date = start_date
self.end_date = end_date
def validate(self) -> Dict[str, Any]:
"""
Validate the account update data.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate account exists
if not is_valid_uuid(self.account_id):
errors.append("Invalid account ID format")
else:
account = self.account_repo.get_by_id(self.account_id)
if not account:
errors.append(f"Account with ID {self.account_id} not found")
if not errors and self.primary_contact_email is not None and not is_valid_email(self.primary_contact_email):
errors.append("Invalid primary contact email format.")
if not errors and self.primary_contact_phone is not None and not is_valid_phone(self.primary_contact_phone):
errors.append("Invalid primary contact phone format.")
if not errors and self.secondary_contact_email is not None and not is_valid_email(self.secondary_contact_email):
errors.append("Invalid secondary contact email format.")
if not errors and self.secondary_contact_phone is not None and not is_valid_phone(self.secondary_contact_phone):
errors.append("Invalid secondary contact phone format.")
# Validate date formats if provided
if not errors and self.start_date is not None and not is_valid_date(self.start_date):
errors.append("Invalid start date format. Use YYYY-MM-DD.")
if not errors and self.end_date is not None and not is_valid_date(self.end_date):
errors.append("Invalid end date format. Use YYYY-MM-DD.")
# Validate start date is before end date if both provided
if not errors and self.start_date and self.end_date:
start = parse_date(self.start_date)
end = parse_date(self.end_date)
if start and end and start > end:
errors.append("Start date must be before end date.")
# If only updating end_date, validate it's after the existing start_date
if not errors and self.end_date and not self.start_date:
account = self.account_repo.get_by_id(self.account_id)
if account:
end = parse_date(self.end_date)
start = parse_date(account.start_date)
if end and start and start > end:
errors.append("End date must be after the existing start date.")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[Account]:
"""
Execute the account update command.
Returns:
CommandResult[Account]: Result of the command execution.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Create a dictionary of fields to update
update_data = {}
# Add fields to update_data if they were provided
if self.name is not None:
update_data['name'] = self.name
if self.street_address is not None:
update_data['street_address'] = self.street_address
if self.city is not None:
update_data['city'] = self.city
if self.state is not None:
update_data['state'] = self.state
if self.zip_code is not None:
update_data['zip_code'] = self.zip_code
if self.primary_contact_first_name is not None:
update_data['primary_contact_first_name'] = self.primary_contact_first_name
if self.primary_contact_last_name is not None:
update_data['primary_contact_last_name'] = self.primary_contact_last_name
if self.primary_contact_phone is not None:
update_data['primary_contact_phone'] = self.primary_contact_phone
if self.primary_contact_email is not None:
update_data['primary_contact_email'] = self.primary_contact_email
if self.secondary_contact_first_name is not None:
update_data['secondary_contact_first_name'] = self.secondary_contact_first_name
if self.secondary_contact_last_name is not None:
update_data['secondary_contact_last_name'] = self.secondary_contact_last_name
if self.secondary_contact_phone is not None:
update_data['secondary_contact_phone'] = self.secondary_contact_phone
if self.secondary_contact_email is not None:
update_data['secondary_contact_email'] = self.secondary_contact_email
if self.start_date is not None:
update_data['start_date'] = self.start_date
if self.end_date is not None:
update_data['end_date'] = self.end_date
# Update the account with the data dictionary
updated_account = self.account_repo.update(self.account_id, update_data)
return CommandResult.success_result(
updated_account,
f"Account {self.account_id} updated successfully"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to update account"
)
class DeleteAccountCommand(Command):
"""
Command to delete an account.
"""
def __init__(
self,
account_repo: AccountRepository,
account_id: str
):
"""
Initialize the delete account command.
Args:
account_repo: Repository for account operations.
account_id: ID of the account to delete.
"""
self.account_repo = account_repo
self.account_id = account_id
def validate(self) -> Dict[str, Any]:
"""
Validate the account deletion request.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate account exists
if not is_valid_uuid(self.account_id):
errors.append("Invalid account ID format")
else:
account = self.account_repo.get_by_id(self.account_id)
if not account:
errors.append(f"Account with ID {self.account_id} not found")
# Check if account has associated services or projects
if not errors:
account_with_relations = self.account_repo.get_with_all_related(self.account_id)
if account_with_relations:
if hasattr(account_with_relations, 'services') and account_with_relations.services.exists():
errors.append(f"Cannot delete account with associated services")
if hasattr(account_with_relations, 'projects') and account_with_relations.projects.exists():
errors.append(f"Cannot delete account with associated projects")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[bool]:
"""
Execute the account deletion command.
Returns:
CommandResult[bool]: Result of the command execution.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Delete the account
success = self.account_repo.delete(self.account_id)
if success:
return CommandResult.success_result(
True,
f"Account {self.account_id} deleted successfully"
)
else:
return CommandResult.failure_result(
"Failed to delete account",
f"Account {self.account_id} could not be deleted"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to delete account"
)
class MarkAccountInactiveCommand(Command):
"""
Command to mark an account as inactive.
"""
def __init__(
self,
account_repo: AccountRepository,
account_id: str,
end_date: Optional[str] = None
):
"""
Initialize the mark account inactive command.
Args:
account_repo: Repository for account operations.
account_id: ID of the account to mark as inactive.
end_date: End date for the account (defaults to today if not provided).
"""
self.account_repo = account_repo
self.account_id = account_id
self.end_date = end_date
def validate(self) -> Dict[str, Any]:
"""
Validate the mark account inactive request.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate account exists
if not is_valid_uuid(self.account_id):
errors.append("Invalid account ID format")
else:
account = self.account_repo.get_by_id(self.account_id)
if not account:
errors.append(f"Account with ID {self.account_id} not found")
elif account.end_date is not None:
errors.append(f"Account is already marked as inactive")
# Validate end date format if provided
if not errors and self.end_date and not is_valid_date(self.end_date):
errors.append("Invalid end date format. Use YYYY-MM-DD.")
# Validate end date is after start date if provided
if not errors and self.end_date:
account = self.account_repo.get_by_id(self.account_id)
if account:
end = parse_date(self.end_date)
start = parse_date(account.start_date)
if end and start and start > end:
errors.append("End date must be after the start date.")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[Account]:
"""
Execute the mark account inactive command.
Returns:
CommandResult[Account]: Result of the command execution.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Mark the account as inactive
end_date = self.end_date or datetime.now().strftime('%Y-%m-%d')
updated_account = self.account_repo.update(
self.account_id,
{'end_date': end_date}
)
if updated_account:
return CommandResult.success_result(
updated_account,
f"Account {self.account_id} marked as inactive successfully"
)
else:
return CommandResult.failure_result(
"Failed to mark account as inactive",
f"Account {self.account_id} could not be marked as inactive"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to mark account as inactive"
)
class GetAccountRevenueCommand(Command):
"""
Command to get the revenue information for an account.
"""
def __init__(
self,
account_repo: AccountRepository,
account_id: str
):
"""
Initialize the get account revenue command.
Args:
account_repo: Repository for account operations.
account_id: ID of the account to get revenue for.
"""
self.account_repo = account_repo
self.account_id = account_id
def validate(self) -> Dict[str, Any]:
"""
Validate the get account revenue request.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate account exists
if not is_valid_uuid(self.account_id):
errors.append("Invalid account ID format")
else:
account = self.account_repo.get_by_id(self.account_id)
if not account:
errors.append(f"Account with ID {self.account_id} not found")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[List[Any]]:
"""
Execute the get account revenue command.
Returns:
CommandResult[List[Any]]: Result of the command execution with revenue data.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Retrieve the account's revenue information
account_with_revenues = self.account_repo.get_with_revenues(self.account_id)
if account_with_revenues and hasattr(account_with_revenues, 'revenues'):
revenues = list(account_with_revenues.revenues.all())
return CommandResult.success_result(
revenues,
f"Retrieved revenue data for account {self.account_id}"
)
else:
return CommandResult.failure_result(
"No revenue data found",
f"No revenue data found for account {self.account_id}"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to retrieve account revenue data"
)

View File

@ -0,0 +1,113 @@
"""
Base classes for command pattern implementation.
"""
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional, TypeVar, Generic, Union
# Type variable for the result of a command
T = TypeVar('T')
class Command(ABC):
"""
Base abstract class for all commands.
Commands encapsulate business logic and ensure data integrity.
"""
@abstractmethod
def execute(self) -> Any:
"""
Execute the command and return the result.
Must be implemented by all command subclasses.
Returns:
Any: The result of the command execution.
"""
pass
def validate(self) -> Dict[str, Any]:
"""
Validate command data before execution.
Should be overridden by command subclasses.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
return {'is_valid': True, 'errors': None}
class CommandResult(Generic[T]):
"""
Represents the result of a command execution.
Attributes:
success (bool): Whether the command was successful.
data (Optional[T]): The result data, if successful.
errors (Optional[List[str]]): List of errors, if unsuccessful.
message (Optional[str]): An optional message about the operation.
"""
def __init__(
self,
success: bool = True,
data: Optional[T] = None,
errors: Optional[List[str]] = None,
message: Optional[str] = None
):
"""
Initialize a new CommandResult.
Args:
success: Whether the command was successful.
data: The result data, if successful.
errors: List of errors, if unsuccessful.
message: An optional message about the operation.
"""
self.success = success
self.data = data
self.errors = errors or []
self.message = message
def to_dict(self) -> Dict[str, Any]:
"""
Convert the command result to a dictionary.
Returns:
Dict[str, Any]: The command result as a dictionary.
"""
return {
'success': self.success,
'data': self.data,
'errors': self.errors,
'message': self.message
}
@classmethod
def success_result(cls, data: T = None, message: Optional[str] = None) -> 'CommandResult[T]':
"""
Create a successful command result.
Args:
data: The result data.
message: An optional success message.
Returns:
CommandResult[T]: A successful command result.
"""
return cls(True, data, None, message)
@classmethod
def failure_result(cls, errors: Union[str, List[str]], message: Optional[str] = None) -> 'CommandResult[T]':
"""
Create a failed command result.
Args:
errors: Error or list of errors.
message: An optional failure message.
Returns:
CommandResult[T]: A failed command result.
"""
if isinstance(errors, str):
errors = [errors]
return cls(False, None, errors, message)

View File

@ -0,0 +1,629 @@
"""
Commands for customer-related operations.
"""
from typing import Any, Dict, Optional, Union, List
from graphene import UUID
from backend.core.models.customers.customers import Customer
from backend.core.repositories.customers.customers import CustomerRepository
from backend.core.repositories.accounts.accounts import AccountRepository
from backend.core.utils.validators import (
is_valid_uuid, is_valid_email, is_valid_phone, is_valid_date,
validate_required_fields
)
from backend.core.utils.helpers import generate_uuid, parse_date
from backend.core.commands.base import Command, CommandResult
class CreateCustomerCommand(Command):
"""
Command to create a new customer.
"""
def __init__(
self,
customer_repo: CustomerRepository,
name: str,
primary_contact_first_name: str,
primary_contact_last_name: str,
primary_contact_phone: str,
primary_contact_email: str,
billing_contact_first_name: str,
billing_contact_last_name: str,
billing_street_address: str,
billing_city: str,
billing_state: str,
billing_zip_code: str,
billing_email: str,
billing_terms: str,
start_date: str,
secondary_contact_first_name: Optional[str] = None,
secondary_contact_last_name: Optional[str] = None,
secondary_contact_phone: Optional[str] = None,
secondary_contact_email: Optional[str] = None,
end_date: Optional[str] = None
):
"""
Initialize the create customer command.
Args:
customer_repo: Repository for customer operations.
name: Name of the customer.
primary_contact_first_name: First name of primary contact.
primary_contact_last_name: Last name of primary contact.
primary_contact_phone: Phone number of primary contact.
primary_contact_email: Email of primary contact.
billing_contact_first_name: First name of billing contact.
billing_contact_last_name: Last name of billing contact.
billing_street_address: Street address for billing.
billing_city: City for billing.
billing_state: State for billing.
billing_zip_code: ZIP code for billing.
billing_email: Email for billing.
billing_terms: Terms for billing.
start_date: Start date of customer relationship (YYYY-MM-DD).
secondary_contact_first_name: First name of secondary contact (optional).
secondary_contact_last_name: Last name of secondary contact (optional).
secondary_contact_phone: Phone number of secondary contact (optional).
secondary_contact_email: Email of secondary contact (optional).
end_date: End date of customer relationship (YYYY-MM-DD, optional).
"""
self.customer_repo = customer_repo
self.name = name
self.primary_contact_first_name = primary_contact_first_name
self.primary_contact_last_name = primary_contact_last_name
self.primary_contact_phone = primary_contact_phone
self.primary_contact_email = primary_contact_email
self.secondary_contact_first_name = secondary_contact_first_name
self.secondary_contact_last_name = secondary_contact_last_name
self.secondary_contact_phone = secondary_contact_phone
self.secondary_contact_email = secondary_contact_email
self.billing_contact_first_name = billing_contact_first_name
self.billing_contact_last_name = billing_contact_last_name
self.billing_street_address = billing_street_address
self.billing_city = billing_city
self.billing_state = billing_state
self.billing_zip_code = billing_zip_code
self.billing_email = billing_email
self.billing_terms = billing_terms
self.start_date = start_date
self.end_date = end_date
def validate(self) -> Dict[str, Any]:
"""
Validate the customer creation data.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Check required fields
required_fields = [
'name', 'primary_contact_first_name', 'primary_contact_last_name',
'primary_contact_phone', 'primary_contact_email',
'billing_contact_first_name', 'billing_contact_last_name',
'billing_street_address', 'billing_city', 'billing_state',
'billing_zip_code', 'billing_email', 'billing_terms', 'start_date'
]
field_values = {
'name': self.name,
'primary_contact_first_name': self.primary_contact_first_name,
'primary_contact_last_name': self.primary_contact_last_name,
'primary_contact_phone': self.primary_contact_phone,
'primary_contact_email': self.primary_contact_email,
'billing_contact_first_name': self.billing_contact_first_name,
'billing_contact_last_name': self.billing_contact_last_name,
'billing_street_address': self.billing_street_address,
'billing_city': self.billing_city,
'billing_state': self.billing_state,
'billing_zip_code': self.billing_zip_code,
'billing_email': self.billing_email,
'billing_terms': self.billing_terms,
'start_date': self.start_date
}
missing_fields = validate_required_fields(field_values, required_fields)
if missing_fields:
errors.append(f"Required fields missing: {', '.join(missing_fields)}")
# Validate email formats
if not errors and self.primary_contact_email and not is_valid_email(self.primary_contact_email):
errors.append("Invalid primary contact email format.")
if not errors and self.secondary_contact_email and not is_valid_email(self.secondary_contact_email):
errors.append("Invalid secondary contact email format.")
if not errors and self.billing_email and not is_valid_email(self.billing_email):
errors.append("Invalid billing email format.")
# Validate phone formats
if not errors and self.primary_contact_phone and not is_valid_phone(self.primary_contact_phone):
errors.append("Invalid primary contact phone format.")
if not errors and self.secondary_contact_phone and not is_valid_phone(self.secondary_contact_phone):
errors.append("Invalid secondary contact phone format.")
# Validate date formats
if not errors and self.start_date and not is_valid_date(self.start_date):
errors.append("Invalid start date format. Use YYYY-MM-DD.")
if not errors and self.end_date and not is_valid_date(self.end_date):
errors.append("Invalid end date format. Use YYYY-MM-DD.")
# Validate start date is before end date if both provided
if not errors and self.start_date and self.end_date:
start = parse_date(self.start_date)
end = parse_date(self.end_date)
if start and end and start > end:
errors.append("Start date must be before end date.")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[Customer]:
"""
Execute the customer creation command.
Returns:
CommandResult[Customer]: Result of the command execution.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Create customer data
customer_id = generate_uuid()
# Create customer data dictionary
customer_data = {
'id': customer_id,
'name': self.name,
'primary_contact_first_name': self.primary_contact_first_name,
'primary_contact_last_name': self.primary_contact_last_name,
'primary_contact_phone': self.primary_contact_phone,
'primary_contact_email': self.primary_contact_email,
'secondary_contact_first_name': self.secondary_contact_first_name,
'secondary_contact_last_name': self.secondary_contact_last_name,
'secondary_contact_phone': self.secondary_contact_phone,
'secondary_contact_email': self.secondary_contact_email,
'billing_contact_first_name': self.billing_contact_first_name,
'billing_contact_last_name': self.billing_contact_last_name,
'billing_street_address': self.billing_street_address,
'billing_city': self.billing_city,
'billing_state': self.billing_state,
'billing_zip_code': self.billing_zip_code,
'billing_email': self.billing_email,
'billing_terms': self.billing_terms,
'start_date': self.start_date,
'end_date': self.end_date
}
# Save to repository
created_customer = self.customer_repo.create(customer_data)
return CommandResult.success_result(
created_customer,
f"Customer {self.name} created successfully"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to create customer"
)
class UpdateCustomerCommand(Command):
"""
Command to update an existing customer.
"""
def __init__(
self,
customer_repo: CustomerRepository,
id: UUID,
name: Optional[str] = None,
primary_contact_first_name: Optional[str] = None,
primary_contact_last_name: Optional[str] = None,
primary_contact_phone: Optional[str] = None,
primary_contact_email: Optional[str] = None,
secondary_contact_first_name: Optional[str] = None,
secondary_contact_last_name: Optional[str] = None,
secondary_contact_phone: Optional[str] = None,
secondary_contact_email: Optional[str] = None,
billing_contact_first_name: Optional[str] = None,
billing_contact_last_name: Optional[str] = None,
billing_street_address: Optional[str] = None,
billing_city: Optional[str] = None,
billing_state: Optional[str] = None,
billing_zip_code: Optional[str] = None,
billing_email: Optional[str] = None,
billing_terms: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None
):
"""
Initialize the update customer command.
Args:
customer_repo: Repository for customer operations.
id: ID of the customer to update.
name: New name for the customer.
primary_contact_first_name: New first name of primary contact.
primary_contact_last_name: New last name of primary contact.
primary_contact_phone: New phone number of primary contact.
primary_contact_email: New email of primary contact.
secondary_contact_first_name: New first name of secondary contact.
secondary_contact_last_name: New last name of secondary contact.
secondary_contact_phone: New phone number of secondary contact.
secondary_contact_email: New email of secondary contact.
billing_contact_first_name: New first name of billing contact.
billing_contact_last_name: New last name of billing contact.
billing_street_address: New street address for billing.
billing_city: New city for billing.
billing_state: New state for billing.
billing_zip_code: New ZIP code for billing.
billing_email: New email for billing.
billing_terms: New terms for billing.
start_date: New start date of customer relationship.
end_date: New end date of customer relationship.
"""
self.customer_repo = customer_repo
self.id = str(id)
self.name = name
self.primary_contact_first_name = primary_contact_first_name
self.primary_contact_last_name = primary_contact_last_name
self.primary_contact_phone = primary_contact_phone
self.primary_contact_email = primary_contact_email
self.secondary_contact_first_name = secondary_contact_first_name
self.secondary_contact_last_name = secondary_contact_last_name
self.secondary_contact_phone = secondary_contact_phone
self.secondary_contact_email = secondary_contact_email
self.billing_contact_first_name = billing_contact_first_name
self.billing_contact_last_name = billing_contact_last_name
self.billing_street_address = billing_street_address
self.billing_city = billing_city
self.billing_state = billing_state
self.billing_zip_code = billing_zip_code
self.billing_email = billing_email
self.billing_terms = billing_terms
self.start_date = start_date
self.end_date = end_date
def validate(self) -> Dict[str, Union[bool, List[str]]]:
"""
Validate the customer update data.
Returns:
Dict[str, Union[bool, List[str]]]: Validation result with 'is_valid' and 'errors'.
"""
errors = []
# Validate customer exists
if not is_valid_uuid(self.id):
errors.append("Invalid customer ID format")
else:
customer = self.customer_repo.get_by_id(self.id)
if not customer:
errors.append(f"Customer with ID {self.id} not found")
# Validate email formats if provided
if not errors and self.primary_contact_email is not None and not is_valid_email(self.primary_contact_email):
errors.append("Invalid primary contact email format.")
if not errors and self.secondary_contact_email is not None and not is_valid_email(self.secondary_contact_email):
errors.append("Invalid secondary contact email format.")
if not errors and self.billing_email is not None and not is_valid_email(self.billing_email):
errors.append("Invalid billing email format.")
# Validate phone formats if provided
if not errors and self.primary_contact_phone is not None and not is_valid_phone(self.primary_contact_phone):
errors.append("Invalid primary contact phone format.")
if not errors and self.secondary_contact_phone is not None and not is_valid_phone(self.secondary_contact_phone):
errors.append("Invalid secondary contact phone format.")
# Validate date formats if provided
if not errors and self.start_date is not None and not is_valid_date(self.start_date):
errors.append("Invalid start date format. Use YYYY-MM-DD.")
if not errors and self.end_date is not None and not is_valid_date(self.end_date):
errors.append("Invalid end date format. Use YYYY-MM-DD.")
# Validate start date is before end date if both provided
if not errors and self.start_date and self.end_date:
start = parse_date(self.start_date)
end = parse_date(self.end_date)
if start and end and start > end:
errors.append("Start date must be before end date.")
# If only updating end_date, validate it's after the existing start_date
if not errors and self.end_date and not self.start_date:
customer = self.customer_repo.get_by_id(self.id)
if customer:
end = parse_date(self.end_date)
if end and customer.start_date > end:
errors.append("End date must be after the existing start date.")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[Customer]:
"""
Execute the customer update command.
Returns:
CommandResult[Customer]: Result of the command execution.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Create a dictionary of fields to update
update_data = {}
# Add fields to update_data if they were provided
if self.name is not None:
update_data['name'] = self.name
if self.primary_contact_first_name is not None:
update_data['primary_contact_first_name'] = self.primary_contact_first_name
if self.primary_contact_last_name is not None:
update_data['primary_contact_last_name'] = self.primary_contact_last_name
if self.primary_contact_phone is not None:
update_data['primary_contact_phone'] = self.primary_contact_phone
if self.primary_contact_email is not None:
update_data['primary_contact_email'] = self.primary_contact_email
if self.secondary_contact_first_name is not None:
update_data['secondary_contact_first_name'] = self.secondary_contact_first_name
if self.secondary_contact_last_name is not None:
update_data['secondary_contact_last_name'] = self.secondary_contact_last_name
if self.secondary_contact_phone is not None:
update_data['secondary_contact_phone'] = self.secondary_contact_phone
if self.secondary_contact_email is not None:
update_data['secondary_contact_email'] = self.secondary_contact_email
if self.billing_contact_first_name is not None:
update_data['billing_contact_first_name'] = self.billing_contact_first_name
if self.billing_contact_last_name is not None:
update_data['billing_contact_last_name'] = self.billing_contact_last_name
if self.billing_street_address is not None:
update_data['billing_street_address'] = self.billing_street_address
if self.billing_city is not None:
update_data['billing_city'] = self.billing_city
if self.billing_state is not None:
update_data['billing_state'] = self.billing_state
if self.billing_zip_code is not None:
update_data['billing_zip_code'] = self.billing_zip_code
if self.billing_email is not None:
update_data['billing_email'] = self.billing_email
if self.billing_terms is not None:
update_data['billing_terms'] = self.billing_terms
if self.start_date is not None:
update_data['start_date'] = self.start_date
if self.end_date is not None:
update_data['end_date'] = self.end_date
# Update the customer with the data dictionary
updated_customer = self.customer_repo.update(self.id, update_data)
return CommandResult.success_result(
updated_customer,
f"Customer {self.id} updated successfully"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to update customer"
)
class DeleteCustomerCommand(Command):
"""
Command to delete a customer.
"""
def __init__(
self,
customer_repo: CustomerRepository,
account_repo: AccountRepository,
customer_id: str
):
"""
Initialize the delete customer command.
Args:
customer_repo: Repository for customer operations.
account_repo: Repository for account operations.
customer_id: ID of the customer to delete.
"""
self.customer_repo = customer_repo
self.account_repo = account_repo
self.customer_id = customer_id
def validate(self) -> Dict[str, Any]:
"""
Validate the customer deletion request.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate customer exists
if not is_valid_uuid(self.customer_id):
errors.append("Invalid customer ID format")
else:
customer = self.customer_repo.get_by_id(self.customer_id)
if not customer:
errors.append(f"Customer with ID {self.customer_id} not found")
# Check if customer has associated accounts
if not errors:
customer_with_accounts = self.customer_repo.get_with_accounts(self.customer_id)
# First check if customer_with_accounts is not None
if customer_with_accounts is not None:
# Now we can safely access the accounts attribute
if hasattr(customer_with_accounts, 'accounts') and customer_with_accounts.accounts.exists():
errors.append(f"Cannot delete customer with associated accounts")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[bool]:
"""
Execute the customer deletion command.
Returns:
CommandResult[bool]: Result of the command execution.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Delete the customer
success = self.customer_repo.delete(self.customer_id)
if success:
return CommandResult.success_result(
True,
f"Customer {self.customer_id} deleted successfully"
)
else:
return CommandResult.failure_result(
"Failed to delete customer",
f"Customer {self.customer_id} could not be deleted"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to delete customer"
)
class MarkCustomerInactiveCommand(Command):
"""
Command to mark a customer as inactive.
"""
def __init__(
self,
customer_repo: CustomerRepository,
customer_id: str,
end_date: Optional[str] = None
):
"""
Initialize the mark customer inactive command.
Args:
customer_repo: Repository for customer operations.
customer_id: ID of the customer to mark as inactive.
end_date: End date for the customer relationship (defaults to today if not provided).
"""
self.customer_repo = customer_repo
self.customer_id = customer_id
self.end_date = end_date
def validate(self) -> Dict[str, Any]:
"""
Validate the mark customer inactive request.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate customer exists
if not is_valid_uuid(self.customer_id):
errors.append("Invalid customer ID format")
else:
customer = self.customer_repo.get_by_id(self.customer_id)
if not customer:
errors.append(f"Customer with ID {self.customer_id} not found")
elif customer.end_date is not None:
errors.append(f"Customer is already marked as inactive")
# Validate end date format if provided
if not errors and self.end_date and not is_valid_date(self.end_date):
errors.append("Invalid end date format. Use YYYY-MM-DD.")
# Validate end date is after start date if provided
if not errors and self.end_date:
customer = self.customer_repo.get_by_id(self.customer_id)
if customer:
end = parse_date(self.end_date)
if end and customer.start_date > end:
errors.append("End date must be after the start date.")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[Customer]:
"""
Execute the mark customer inactive command.
Returns:
CommandResult[Customer]: Result of the command execution.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Mark the customer as inactive
updated_customer = self.customer_repo.mark_inactive(self.customer_id)
if updated_customer:
return CommandResult.success_result(
updated_customer,
f"Customer {self.customer_id} marked as inactive successfully"
)
else:
return CommandResult.failure_result(
"Failed to mark customer as inactive",
f"Customer {self.customer_id} could not be marked as inactive"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to mark customer as inactive"
)

View File

@ -0,0 +1,445 @@
"""
Commands for invoice-related operations.
"""
from typing import Any, Dict, List, Optional, Union
from datetime import date
from backend.core.models.invoices.invoices import Invoice
from backend.core.repositories.invoices.invoices import InvoiceRepository
from backend.core.repositories.customers.customers import CustomerRepository
from backend.core.utils.validators import (
is_valid_uuid, is_valid_date, validate_required_fields,
validate_model_exists
)
from backend.core.utils.helpers import generate_uuid, parse_date
from backend.core.commands.base import Command, CommandResult
class CreateInvoiceCommand(Command):
"""
Command to create a new invoice.
"""
def __init__(
self,
invoice_repo: InvoiceRepository,
customer_repo: CustomerRepository,
customer_id: str,
invoice_date: str,
account_ids: Optional[List[str]] = None,
project_ids: Optional[List[str]] = None,
total_amount: Optional[float] = None,
status: str = 'draft'
):
"""
Initialize the create invoice command.
"""
self.invoice_repo = invoice_repo
self.customer_repo = customer_repo
self.customer_id = customer_id
self.date = invoice_date
self.account_ids = account_ids or []
self.project_ids = project_ids or []
self.total_amount = total_amount or 0
self.status = status
def validate(self) -> Dict[str, Any]:
"""
Validate the invoice creation data.
"""
errors = []
# Check required fields
missing_fields = validate_required_fields(
{'customer_id': self.customer_id, 'date': self.date},
['customer_id', 'date']
)
if missing_fields:
errors.append(f"Required fields missing: {', '.join(missing_fields)}")
# Validate customer exists
if not errors and self.customer_id:
customer_validation = validate_model_exists(
self.customer_id, 'customer', self.customer_repo.get_by_id
)
if not customer_validation['valid']:
errors.append(customer_validation['error'])
# Validate date format
if not errors and self.date and not is_valid_date(self.date):
errors.append("Invalid date format. Use YYYY-MM-DD.")
# Validate status
valid_statuses = ['draft', 'sent', 'paid', 'overdue', 'cancelled']
if not errors and self.status not in valid_statuses:
errors.append(f"Invalid status. Must be one of: {', '.join(valid_statuses)}")
# If no accounts or projects are specified, warn about empty invoice
if not errors and not self.account_ids and not self.project_ids:
errors.append("Invoice must include at least one account or project")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[Invoice]:
"""
Execute the invoice creation command.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Create invoice data
invoice_id = generate_uuid()
# Setup invoice data
invoice_data = {
'id': invoice_id,
'customer_id': self.customer_id,
'date': self.date,
'status': self.status,
'total_amount': self.total_amount
}
# Create invoice with related items
created_invoice = self.invoice_repo.create_with_items(
invoice_data,
self.account_ids,
self.project_ids
)
return CommandResult.success_result(
created_invoice,
f"Invoice created successfully for customer {self.customer_id}"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to create invoice"
)
class SendInvoiceCommand(Command):
"""
Command to send an invoice.
"""
def __init__(
self,
invoice_repo: InvoiceRepository,
invoice_id: str
):
"""
Initialize the send invoice command.
"""
self.invoice_repo = invoice_repo
self.invoice_id = invoice_id
def validate(self) -> Dict[str, Any]:
"""
Validate the invoice sending request.
"""
errors = []
# Validate invoice exists
if not is_valid_uuid(self.invoice_id):
errors.append("Invalid invoice ID format")
else:
invoice = self.invoice_repo.get_by_id(self.invoice_id)
if not invoice:
errors.append(f"Invoice with ID {self.invoice_id} not found")
# Check if invoice is in draft status
if not errors and invoice.status != 'draft':
errors.append(f"Only invoices in 'draft' status can be sent. Current status: {invoice.status}")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[Invoice]:
"""
Execute the send invoice command.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Update invoice status to 'sent'
updated_invoice = self.invoice_repo.update_status(self.invoice_id, 'sent')
if updated_invoice:
return CommandResult.success_result(
updated_invoice,
f"Invoice {self.invoice_id} sent successfully"
)
else:
return CommandResult.failure_result(
"Failed to send invoice",
f"Invoice {self.invoice_id} could not be sent"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to send invoice"
)
class MarkInvoicePaidCommand(Command):
"""
Command to mark an invoice as paid.
"""
def __init__(
self,
invoice_repo: InvoiceRepository,
invoice_id: str,
payment_type: str,
date_paid: Optional[str] = None
):
"""
Initialize the mark invoice paid command.
"""
self.invoice_repo = invoice_repo
self.invoice_id = invoice_id
self.payment_type = payment_type
self.date_paid = date_paid
def validate(self) -> Dict[str, Any]:
"""
Validate the mark invoice paid request.
"""
errors = []
# Validate invoice exists
if not is_valid_uuid(self.invoice_id):
errors.append("Invalid invoice ID format")
else:
invoice = self.invoice_repo.get_by_id(self.invoice_id)
if not invoice:
errors.append(f"Invoice with ID {self.invoice_id} not found")
# Check if invoice can be marked as paid
if not errors and invoice.status not in ['sent', 'overdue']:
errors.append(
f"Only invoices in 'sent' or 'overdue' status can be marked as paid. Current status: {invoice.status}")
# Validate payment type
valid_payment_types = ['check', 'credit_card', 'bank_transfer', 'cash']
if not errors and self.payment_type not in valid_payment_types:
errors.append(f"Invalid payment type. Must be one of: {', '.join(valid_payment_types)}")
# Validate date_paid format if provided
if not errors and self.date_paid and not is_valid_date(self.date_paid):
errors.append("Invalid date_paid format. Use YYYY-MM-DD.")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[Invoice]:
"""
Execute the mark invoice paid command.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# If date_paid is provided, update with that date
if self.date_paid:
update_data = {
'status': 'paid',
'date_paid': self.date_paid,
'payment_type': self.payment_type
}
updated_invoice = self.invoice_repo.update(self.invoice_id, update_data)
else:
# Use the repository method to mark as paid with today's date
updated_invoice = self.invoice_repo.mark_as_paid(self.invoice_id, self.payment_type)
return CommandResult.success_result(
updated_invoice,
f"Invoice {self.invoice_id} marked as paid successfully"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to mark invoice as paid"
)
class CancelInvoiceCommand(Command):
"""
Command to cancel an invoice.
"""
def __init__(
self,
invoice_repo: InvoiceRepository,
invoice_id: str
):
"""
Initialize the cancel invoice command.
"""
self.invoice_repo = invoice_repo
self.invoice_id = invoice_id
def validate(self) -> Dict[str, Any]:
"""
Validate the invoice cancellation request.
"""
errors = []
# Validate invoice exists
if not is_valid_uuid(self.invoice_id):
errors.append("Invalid invoice ID format")
else:
invoice = self.invoice_repo.get_by_id(self.invoice_id)
if not invoice:
errors.append(f"Invoice with ID {self.invoice_id} not found")
# Check if invoice can be cancelled
if not errors and invoice.status == 'paid':
errors.append("Paid invoices cannot be cancelled")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[Invoice]:
"""
Execute the cancel invoice command.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Update invoice status to 'cancelled'
updated_invoice = self.invoice_repo.update_status(self.invoice_id, 'cancelled')
return CommandResult.success_result(
updated_invoice,
f"Invoice {self.invoice_id} cancelled successfully"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to cancel invoice"
)
class FilterInvoicesCommand(Command):
"""
Command to filter invoices by multiple criteria.
"""
def __init__(
self,
invoice_repo: InvoiceRepository,
customer_id: Optional[str] = None,
status: Optional[str] = None,
date_from: Optional[Union[str, date]] = None,
date_to: Optional[Union[str, date]] = None,
account_id: Optional[str] = None,
project_id: Optional[str] = None
):
"""
Initialize the filter invoices command.
"""
self.invoice_repo = invoice_repo
self.customer_id = customer_id
self.status = status
# Convert string dates to date objects if needed
if isinstance(date_from, str):
self.date_from = parse_date(date_from)
else:
self.date_from = date_from
if isinstance(date_to, str):
self.date_to = parse_date(date_to)
else:
self.date_to = date_to
self.account_id = account_id
self.project_id = project_id
def validate(self) -> Dict[str, Any]:
"""
Validate the filter invoices request.
"""
errors = []
# Validate UUID formats
if self.customer_id and not is_valid_uuid(self.customer_id):
errors.append("Invalid customer ID format")
if self.account_id and not is_valid_uuid(self.account_id):
errors.append("Invalid account ID format")
if self.project_id and not is_valid_uuid(self.project_id):
errors.append("Invalid project ID format")
# Validate status if provided
if self.status:
valid_statuses = ['draft', 'sent', 'paid', 'overdue', 'cancelled']
if self.status not in valid_statuses:
errors.append(f"Invalid status. Must be one of: {', '.join(valid_statuses)}")
# Validate date range if both provided
if self.date_from and self.date_to and self.date_from > self.date_to:
errors.append("Start date must be before end date")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[List[Invoice]]:
"""
Execute the filter invoices command.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Filter invoices
filtered_invoices = self.invoice_repo.filter_invoices(
customer_id=self.customer_id,
status=self.status,
date_from=self.date_from,
date_to=self.date_to,
account_id=self.account_id,
project_id=self.project_id
)
return CommandResult.success_result(
list(filtered_invoices),
f"Found {filtered_invoices.count()} matching invoices"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to filter invoices"
)

View File

View File

@ -0,0 +1,550 @@
"""
Commands for labor-related operations.
"""
from typing import Any, Dict, Optional, Union
from decimal import Decimal
from backend.core.models.labor.labor import Labor
from backend.core.repositories.labor.labor import LaborRepository
from backend.core.repositories.accounts.accounts import AccountRepository
from backend.core.utils.validators import (
is_valid_uuid, is_valid_date, validate_required_fields,
validate_model_exists, validate_decimal_amount
)
from backend.core.utils.helpers import generate_uuid, parse_date
from backend.core.commands.base import Command, CommandResult
class CreateLaborCommand(Command):
"""
Command to create a new labor record.
"""
def __init__(
self,
labor_repo: LaborRepository,
account_repo: AccountRepository,
account_id: str,
amount: Union[float, str, Decimal],
start_date: str,
end_date: Optional[str] = None
):
"""
Initialize the create labor command.
Args:
labor_repo: Repository for labor operations.
account_repo: Repository for account operations.
account_id: ID of the account the labor is for.
amount: Labor cost amount.
start_date: Start date of the labor (YYYY-MM-DD).
end_date: Optional end date of the labor (YYYY-MM-DD).
"""
self.labor_repo = labor_repo
self.account_repo = account_repo
self.account_id = account_id
self.amount = amount
self.start_date = start_date
self.end_date = end_date
def validate(self) -> Dict[str, Any]:
"""
Validate the labor creation data.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Check required fields
missing_fields = validate_required_fields(
{
'account_id': self.account_id,
'amount': self.amount,
'start_date': self.start_date
},
['account_id', 'amount', 'start_date']
)
if missing_fields:
errors.append(f"Required fields missing: {', '.join(missing_fields)}")
# Validate account exists
if not errors and self.account_id:
account_validation = validate_model_exists(
self.account_id, 'account', self.account_repo.get_by_id
)
if not account_validation['valid']:
errors.append(account_validation['error'])
# Validate amount
if not errors:
amount_validation = validate_decimal_amount(self.amount, 'amount')
if not amount_validation['valid']:
errors.append(amount_validation['error'])
# Validate date formats
if not errors and self.start_date and not is_valid_date(self.start_date):
errors.append("Invalid start date format. Use YYYY-MM-DD.")
if not errors and self.end_date and not is_valid_date(self.end_date):
errors.append("Invalid end date format. Use YYYY-MM-DD.")
# Validate start date is before end date if both provided
if not errors and self.start_date and self.end_date:
start = parse_date(self.start_date)
end = parse_date(self.end_date)
if start and end and start > end:
errors.append("Start date must be before end date.")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[Labor]:
"""
Execute the labor creation command.
Returns:
CommandResult[Labor]: Result of the command execution.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Create labor data
labor_id = generate_uuid()
# Create labor data dictionary
labor_data = {
'id': labor_id,
'account_id': self.account_id,
'amount': self.amount,
'start_date': self.start_date,
'end_date': self.end_date
}
# Save to repository
created_labor = self.labor_repo.create(labor_data)
return CommandResult.success_result(
created_labor,
f"Labor record for account {self.account_id} created successfully"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to create labor record"
)
class UpdateLaborCommand(Command):
"""
Command to update an existing labor record.
"""
def __init__(
self,
labor_repo: LaborRepository,
account_repo: AccountRepository,
labor_id: str,
amount: Optional[Union[float, str, Decimal]] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None
):
"""
Initialize the update labor command.
Args:
labor_repo: Repository for labor operations.
account_repo: Repository for account operations.
labor_id: ID of the labor record to update.
amount: New labor cost amount.
start_date: New start date of the labor.
end_date: New end date of the labor.
"""
self.labor_repo = labor_repo
self.account_repo = account_repo
self.labor_id = labor_id
self.amount = amount
self.start_date = start_date
self.end_date = end_date
def validate(self) -> Dict[str, Any]:
"""
Validate the labor update data.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate labor exists
if not is_valid_uuid(self.labor_id):
errors.append("Invalid labor ID format")
else:
labor = self.labor_repo.get_by_id(self.labor_id)
if not labor:
errors.append(f"Labor record with ID {self.labor_id} not found")
# Validate amount if provided
if not errors and self.amount is not None:
amount_validation = validate_decimal_amount(self.amount, 'amount')
if not amount_validation['valid']:
errors.append(amount_validation['error'])
# Validate date formats if provided
if not errors and self.start_date and not is_valid_date(self.start_date):
errors.append("Invalid start date format. Use YYYY-MM-DD.")
if not errors and self.end_date and not is_valid_date(self.end_date):
errors.append("Invalid end date format. Use YYYY-MM-DD.")
# Validate start date is before end date if both provided
if not errors and self.start_date and self.end_date:
start = parse_date(self.start_date)
end = parse_date(self.end_date)
if start and end and start > end:
errors.append("Start date must be before end date.")
# If only updating end_date, validate it's after the existing start_date
if not errors and self.end_date and not self.start_date:
labor = self.labor_repo.get_by_id(self.labor_id)
if labor:
end = parse_date(self.end_date)
if end and labor.start_date > end:
errors.append("End date must be after the existing start date.")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[Labor]:
"""
Execute the labor update command.
Returns:
CommandResult[Labor]: Result of the command execution.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Create a dictionary of fields to update
update_data = {}
# Add fields to update_data if they were provided
if self.amount is not None:
update_data['amount'] = self.amount
if self.start_date is not None:
update_data['start_date'] = self.start_date
if self.end_date is not None:
update_data['end_date'] = self.end_date
# Update the labor record with the data dictionary
updated_labor = self.labor_repo.update(self.labor_id, update_data)
return CommandResult.success_result(
updated_labor,
f"Labor record {self.labor_id} updated successfully"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to update labor record"
)
class DeleteLaborCommand(Command):
"""
Command to delete a labor record.
"""
def __init__(self, labor_repo: LaborRepository, labor_id: str):
"""
Initialize the delete labor command.
Args:
labor_repo: Repository for labor operations.
labor_id: ID of the labor record to delete.
"""
self.labor_repo = labor_repo
self.labor_id = labor_id
def validate(self) -> Dict[str, Any]:
"""
Validate the labor deletion request.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate labor exists
if not is_valid_uuid(self.labor_id):
errors.append("Invalid labor ID format")
else:
labor = self.labor_repo.get_by_id(self.labor_id)
if not labor:
errors.append(f"Labor record with ID {self.labor_id} not found")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[bool]:
"""
Execute the labor deletion command.
Returns:
CommandResult[bool]: Result of the command execution.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Delete the labor record
success = self.labor_repo.delete(self.labor_id)
if success:
return CommandResult.success_result(
True,
f"Labor record {self.labor_id} deleted successfully"
)
else:
return CommandResult.failure_result(
"Failed to delete labor record",
f"Labor record {self.labor_id} could not be deleted"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to delete labor record"
)
class EndLaborCommand(Command):
"""
Command to end a labor record by setting its end date to today.
"""
def __init__(self, labor_repo: LaborRepository, labor_id: str, end_date: Optional[str] = None):
"""
Initialize the end labor command.
Args:
labor_repo: Repository for labor operations.
labor_id: ID of the labor record to end.
end_date: Optional specific end date (defaults to today if not provided).
"""
self.labor_repo = labor_repo
self.labor_id = labor_id
self.end_date = end_date
def validate(self) -> Dict[str, Any]:
"""
Validate the labor end request.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate labor exists
if not is_valid_uuid(self.labor_id):
errors.append("Invalid labor ID format")
else:
labor = self.labor_repo.get_by_id(self.labor_id)
if not labor:
errors.append(f"Labor record with ID {self.labor_id} not found")
elif labor.end_date is not None:
errors.append(f"Labor record is already ended")
# Validate end date format if provided
if not errors and self.end_date and not is_valid_date(self.end_date):
errors.append("Invalid end date format. Use YYYY-MM-DD.")
# Validate end date is after start date if provided
if not errors and self.end_date:
labor = self.labor_repo.get_by_id(self.labor_id)
if labor:
end = parse_date(self.end_date)
if end and labor.start_date > end:
errors.append("End date must be after the start date.")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[Labor]:
"""
Execute the end labor command.
Returns:
CommandResult[Labor]: Result of the command execution.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# End the labor record
if self.end_date:
# Use provided end date
updated_labor = self.labor_repo.update(self.labor_id, {'end_date': self.end_date})
else:
# Use repository method that sets end date to today
updated_labor = self.labor_repo.end_labor(self.labor_id)
if updated_labor:
return CommandResult.success_result(
updated_labor,
f"Labor record {self.labor_id} ended successfully"
)
else:
return CommandResult.failure_result(
"Failed to end labor record",
f"Labor record {self.labor_id} could not be ended"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to end labor record"
)
class CalculateLaborCostCommand(Command):
"""
Command to calculate labor cost for an account or all accounts within a date range.
"""
def __init__(
self,
labor_repo: LaborRepository,
account_repo: AccountRepository,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
account_id: Optional[str] = None
):
"""
Initialize the calculate labor cost command.
Args:
labor_repo: Repository for labor operations.
account_repo: Repository for account operations.
start_date: Optional start date for the period.
end_date: Optional end date for the period.
account_id: Optional account ID to filter by.
"""
self.labor_repo = labor_repo
self.account_repo = account_repo
self.start_date = start_date
self.end_date = end_date
self.account_id = account_id
def validate(self) -> Dict[str, Any]:
"""
Validate the labor cost calculation request.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate account exists if provided
# Validate account exists if provided
if self.account_id:
if not is_valid_uuid(self.account_id):
errors.append("Invalid account ID format")
else:
account_validation = validate_model_exists(
self.account_id, 'account', self.account_repo.get_by_id
)
if not account_validation['valid']:
errors.append(account_validation['error'])
# Validate date formats if provided
if self.start_date and not is_valid_date(self.start_date):
errors.append("Invalid start date format. Use YYYY-MM-DD.")
if self.end_date and not is_valid_date(self.end_date):
errors.append("Invalid end date format. Use YYYY-MM-DD.")
# Validate start date is before end date if both provided
if self.start_date and self.end_date:
start = parse_date(self.start_date)
end = parse_date(self.end_date)
if start and end and start > end:
errors.append("Start date must be before end date.")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[float]:
"""
Execute the labor cost calculation command.
Returns:
CommandResult[float]: Result of the command execution with the calculated cost.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Parse dates if provided
start_date_obj = parse_date(self.start_date) if self.start_date else None
end_date_obj = parse_date(self.end_date) if self.end_date else None
# Calculate labor cost
total_cost = self.labor_repo.get_total_labor_cost(
account_id=self.account_id,
start_date=start_date_obj,
end_date=end_date_obj
)
# Create a descriptive message
message = "Total labor cost"
if self.account_id:
account = self.account_repo.get_by_id(self.account_id)
if account:
message += f" for account '{account.name}'"
if self.start_date and self.end_date:
message += f" from {self.start_date} to {self.end_date}"
elif self.start_date:
message += f" from {self.start_date}"
elif self.end_date:
message += f" until {self.end_date}"
message += f": ${total_cost:.2f}"
return CommandResult.success_result(
total_cost,
message
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to calculate labor cost"
)

View File

@ -0,0 +1,429 @@
"""
Commands for profile-related operations.
"""
from typing import Any, Dict, Optional
from django.contrib.auth.models import User
from backend.core.models.profiles.profiles import Profile
from backend.core.repositories.profiles.profiles import ProfileRepository
from backend.core.utils.validators import (
is_valid_uuid, is_valid_email, is_valid_phone,
validate_required_fields
)
from backend.core.utils.helpers import generate_uuid
from backend.core.commands.base import Command, CommandResult
class CreateProfileCommand(Command):
"""
Command to create a new profile.
"""
def __init__(
self,
profile_repo: ProfileRepository,
user: User,
first_name: str,
last_name: str,
primary_phone: str,
email: str,
role: str = 'team_member',
secondary_phone: Optional[str] = None
):
"""
Initialize the create profile command.
Args:
profile_repo: Repository for profile operations.
user: The Django User object to associate with the profile.
first_name: First name of the profile.
last_name: Last name of the profile.
primary_phone: Primary phone number.
email: Email address.
role: Role of the profile ('admin', 'team_leader', 'team_member').
secondary_phone: Optional secondary phone number.
"""
self.profile_repo = profile_repo
self.user = user
self.first_name = first_name
self.last_name = last_name
self.primary_phone = primary_phone
self.email = email
self.role = role
self.secondary_phone = secondary_phone
def validate(self) -> Dict[str, Any]:
"""
Validate the profile creation data.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Check required fields
missing_fields = validate_required_fields(
{
'user': self.user,
'first_name': self.first_name,
'last_name': self.last_name,
'primary_phone': self.primary_phone,
'email': self.email
},
['user', 'first_name', 'last_name', 'primary_phone', 'email']
)
if missing_fields:
errors.append(f"Required fields missing: {', '.join(missing_fields)}")
# Validate email format
if not errors and self.email and not is_valid_email(self.email):
errors.append("Invalid email format.")
# Validate phone formats
if not errors and self.primary_phone and not is_valid_phone(self.primary_phone):
errors.append("Invalid primary phone format.")
if not errors and self.secondary_phone and not is_valid_phone(self.secondary_phone):
errors.append("Invalid secondary phone format.")
# Validate role
valid_roles = ['admin', 'team_leader', 'team_member']
if not errors and self.role not in valid_roles:
errors.append(f"Invalid role. Must be one of: {', '.join(valid_roles)}")
# Check if user already has a profile
if not errors and self.profile_repo.get_by_user(self.user):
errors.append(f"User already has a profile")
# Check if email is already in use
if not errors and self.profile_repo.get_by_email(self.email):
errors.append(f"Email is already in use")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[Profile]:
"""
Execute the profile creation command.
Returns:
CommandResult[Profile]: Result of the command execution.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Create profile data
profile_id = generate_uuid()
# Create profile data dictionary
profile_data = {
'id': profile_id,
'user': self.user,
'first_name': self.first_name,
'last_name': self.last_name,
'primary_phone': self.primary_phone,
'secondary_phone': self.secondary_phone,
'email': self.email,
'role': self.role
}
# Save to repository
created_profile = self.profile_repo.create(profile_data)
return CommandResult.success_result(
created_profile,
f"Profile for {self.first_name} {self.last_name} created successfully"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to create profile"
)
class UpdateProfileCommand(Command):
"""
Command to update an existing profile.
"""
def __init__(
self,
profile_repo: ProfileRepository,
profile_id: str,
first_name: Optional[str] = None,
last_name: Optional[str] = None,
primary_phone: Optional[str] = None,
email: Optional[str] = None,
role: Optional[str] = None,
secondary_phone: Optional[str] = None
):
"""
Initialize the update profile command.
Args:
profile_repo: Repository for profile operations.
profile_id: ID of the profile to update.
first_name: New first name.
last_name: New last name.
primary_phone: New primary phone number.
email: New email address.
role: New role.
secondary_phone: New secondary phone number.
"""
self.profile_repo = profile_repo
self.profile_id = profile_id
self.first_name = first_name
self.last_name = last_name
self.primary_phone = primary_phone
self.email = email
self.role = role
self.secondary_phone = secondary_phone
def validate(self) -> Dict[str, Any]:
"""
Validate the profile update data.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate profile exists
if not is_valid_uuid(self.profile_id):
errors.append("Invalid profile ID format")
else:
profile = self.profile_repo.get_by_id(self.profile_id)
if not profile:
errors.append(f"Profile with ID {self.profile_id} not found")
# Validate email format if provided
if not errors and self.email is not None and not is_valid_email(self.email):
errors.append("Invalid email format.")
# Validate phone formats if provided
if not errors and self.primary_phone is not None and not is_valid_phone(self.primary_phone):
errors.append("Invalid primary phone format.")
if not errors and self.secondary_phone is not None and not is_valid_phone(self.secondary_phone):
errors.append("Invalid secondary phone format.")
# Validate role if provided
valid_roles = ['admin', 'team_leader', 'team_member']
if not errors and self.role is not None and self.role not in valid_roles:
errors.append(f"Invalid role. Must be one of: {', '.join(valid_roles)}")
# Check if email is already in use by another profile
if not errors and self.email is not None:
existing_profile = self.profile_repo.get_by_email(self.email)
if existing_profile and str(existing_profile.id) != self.profile_id:
errors.append(f"Email is already in use by another profile")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[Profile]:
"""
Execute the profile update command.
Returns:
CommandResult[Profile]: Result of the command execution.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Create a dictionary of fields to update
update_data = {}
# Add fields to update_data if they were provided
if self.first_name is not None:
update_data['first_name'] = self.first_name
if self.last_name is not None:
update_data['last_name'] = self.last_name
if self.primary_phone is not None:
update_data['primary_phone'] = self.primary_phone
if self.secondary_phone is not None:
update_data['secondary_phone'] = self.secondary_phone
if self.email is not None:
update_data['email'] = self.email
if self.role is not None:
update_data['role'] = self.role
# Update the profile with the data dictionary
updated_profile = self.profile_repo.update(self.profile_id, update_data)
return CommandResult.success_result(
updated_profile,
f"Profile {self.profile_id} updated successfully"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to update profile"
)
class DeleteProfileCommand(Command):
"""
Command to delete a profile.
"""
def __init__(self, profile_repo: ProfileRepository, profile_id: str):
"""
Initialize the delete profile command.
Args:
profile_repo: Repository for profile operations.
profile_id: ID of the profile to delete.
"""
self.profile_repo = profile_repo
self.profile_id = profile_id
def validate(self) -> Dict[str, Any]:
"""
Validate the profile deletion request.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate profile exists
if not is_valid_uuid(self.profile_id):
errors.append("Invalid profile ID format")
else:
profile = self.profile_repo.get_by_id(self.profile_id)
if not profile:
errors.append(f"Profile with ID {self.profile_id} not found")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[bool]:
"""
Execute the profile deletion command.
Returns:
CommandResult[bool]: Result of the command execution.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Delete the profile
success = self.profile_repo.delete(self.profile_id)
if success:
return CommandResult.success_result(
True,
f"Profile {self.profile_id} deleted successfully"
)
else:
return CommandResult.failure_result(
"Failed to delete profile",
f"Profile {self.profile_id} could not be deleted"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to delete profile"
)
class SearchProfilesCommand(Command):
"""
Command to search for profiles.py.
"""
def __init__(
self,
profile_repo: ProfileRepository,
search_term: str,
role: Optional[str] = None
):
"""
Initialize the search profiles.py command.
Args:
profile_repo: Repository for profile operations.
search_term: The search term to look for.
role: Optional role to filter by.
"""
self.profile_repo = profile_repo
self.search_term = search_term
self.role = role
def validate(self) -> Dict[str, Any]:
"""
Validate the profile search request.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate role if provided
valid_roles = ['admin', 'team_leader', 'team_member']
if self.role is not None and self.role not in valid_roles:
errors.append(f"Invalid role. Must be one of: {', '.join(valid_roles)}")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[list]:
"""
Execute the profile search command.
Returns:
CommandResult[list]: Result of the command execution with list of matching profiles.py.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Search for profiles.py
results = self.profile_repo.search(self.search_term)
# Filter by role if provided
if self.role:
results = results.filter(role=self.role)
# Convert QuerySet to list
profiles = list(results)
return CommandResult.success_result(
profiles,
f"Found {len(profiles)} matching profiles.py"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to search profiles.py"
)

View File

@ -0,0 +1,444 @@
"""
Commands for project-related operations.
"""
from typing import Any, Dict, List, Optional, Union
from decimal import Decimal
from backend.core.models.projects.projects import Project
from backend.core.repositories.projects.projects import ProjectRepository
from backend.core.repositories.customers.customers import CustomerRepository
from backend.core.repositories.accounts.accounts import AccountRepository
from backend.core.repositories.profiles.profiles import ProfileRepository
from backend.core.utils.validators import (
is_valid_uuid, is_valid_date, validate_required_fields,
validate_model_exists, validate_decimal_amount
)
from backend.core.utils.helpers import generate_uuid
from backend.core.commands.base import Command, CommandResult
class CreateProjectCommand(Command):
"""
Command to create a new project.
"""
def __init__(
self,
project_repo: ProjectRepository,
customer_repo: CustomerRepository,
account_repo: AccountRepository,
profile_repo: ProfileRepository,
customer_id: str,
date: str,
labor: Union[float, str, Decimal],
status: str = 'planned',
account_id: Optional[str] = None,
team_member_ids: Optional[List[str]] = None,
notes: Optional[str] = None,
amount: Optional[Union[float, str, Decimal]] = None,
):
"""
Initialize the create project command.
Args:
project_repo: Repository for project operations.
customer_repo: Repository for customer operations.
account_repo: Repository for account operations.
profile_repo: Repository for profile operations.
customer_id: ID of the customer the project is for.
date: Date of the project (YYYY-MM-DD).
labor: Labor cost for the project.
status: Status of the project ('planned', 'in_progress', 'completed', 'cancelled').
account_id: Optional ID of the account the project is for.
team_member_ids: List of profile IDs for team members assigned to the project.
notes: Additional notes about the project.
amount: Billing amount for the project.
"""
self.project_repo = project_repo
self.customer_repo = customer_repo
self.account_repo = account_repo
self.profile_repo = profile_repo
self.customer_id = customer_id
self.date = date
self.labor = labor
self.status = status
self.account_id = account_id
self.team_member_ids = team_member_ids or []
self.notes = notes
self.amount = amount or labor # Default billing amount to labor cost if not specified
def validate(self) -> Dict[str, Any]:
"""
Validate the project creation data.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Check required fields
missing_fields = validate_required_fields(
{
'customer_id': self.customer_id,
'date': self.date,
'labor': self.labor
},
['customer_id', 'date', 'labor']
)
if missing_fields:
errors.append(f"Required fields missing: {', '.join(missing_fields)}")
# Validate customer exists
if not errors and self.customer_id:
customer_validation = validate_model_exists(
self.customer_id, 'customer', self.customer_repo.get_by_id
)
if not customer_validation['valid']:
errors.append(customer_validation['error'])
# Validate account exists if provided
if not errors and self.account_id:
account_validation = validate_model_exists(
self.account_id, 'account', self.account_repo.get_by_id
)
if not account_validation['valid']:
errors.append(account_validation['error'])
# Validate date format
if not errors and self.date and not is_valid_date(self.date):
errors.append("Invalid date format. Use YYYY-MM-DD.")
# Validate labor amount
if not errors:
labor_validation = validate_decimal_amount(self.labor, 'labor')
if not labor_validation['valid']:
errors.append(labor_validation['error'])
# Validate billing amount if provided
if not errors and self.amount is not None:
amount_validation = validate_decimal_amount(self.amount, 'amount')
if not amount_validation['valid']:
errors.append(amount_validation['error'])
# Validate status
valid_statuses = ['planned', 'in_progress', 'completed', 'cancelled']
if not errors and self.status not in valid_statuses:
errors.append(f"Invalid status. Must be one of: {', '.join(valid_statuses)}")
# Validate team member IDs
if not errors and self.team_member_ids:
for member_id in self.team_member_ids:
if not is_valid_uuid(member_id):
errors.append(f"Invalid team member ID format: {member_id}")
elif not self.profile_repo.get_by_id(member_id):
errors.append(f"Team member with ID {member_id} not found")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[Project]:
"""
Execute the project creation command.
Returns:
CommandResult[Project]: Result of the command execution.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Create project data
project_id = generate_uuid()
# Normalize decimal values
labor = float(self.labor)
amount = float(self.amount) if self.amount is not None else labor
# Create project data dictionary instead of Project object
project_data = {
'id': project_id,
'customer_id': self.customer_id,
'account_id': self.account_id,
'date': self.date,
'status': self.status,
'notes': self.notes,
'labor': labor,
'amount': amount
}
# Save to repository and handle team members
if self.team_member_ids:
# If there's a create_with_team_members method like in ServiceRepository
created_project = self.project_repo.create_with_team_members(
project_data,
self.team_member_ids
)
else:
# Otherwise create the project first, then assign team members
created_project = self.project_repo.create(project_data)
# Assign team members if any
if self.team_member_ids:
team_members = []
for member_id in self.team_member_ids:
member = self.profile_repo.get_by_id(member_id)
if member:
team_members.append(member)
# Assuming there's a method to assign team members
self.project_repo.assign_team_members(project_id, self.team_member_ids)
return CommandResult.success_result(
created_project,
f"Project for customer {self.customer_id} created successfully"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to create project"
)
class UpdateProjectCommand(Command):
"""
Command to update an existing project.
"""
def __init__(
self,
project_repo: ProjectRepository,
customer_repo: CustomerRepository,
account_repo: AccountRepository,
profile_repo: ProfileRepository,
project_id: str,
status: Optional[str] = None,
date: Optional[str] = None,
labor: Optional[Union[float, str, Decimal]] = None,
account_id: Optional[str] = None,
team_member_ids: Optional[List[str]] = None,
notes: Optional[str] = None,
amount: Optional[Union[float, str, Decimal]] = None
):
"""
Initialize the update project command.
Args:
project_repo: Repository for project operations.
customer_repo: Repository for customer operations.
account_repo: Repository for account operations.
profile_repo: Repository for profile operations.
project_id: ID of the project to update.
status: New status for the project.
date: New date for the project.
labor: New labor cost for the project.
account_id: New account ID for the project.
team_member_ids: New list of team member IDs.
notes: New notes for the project.
amount: New billing amount for the project.
"""
self.project_repo = project_repo
self.customer_repo = customer_repo
self.account_repo = account_repo
self.profile_repo = profile_repo
self.project_id = project_id
self.status = status
self.date = date
self.labor = labor
self.account_id = account_id
self.team_member_ids = team_member_ids
self.notes = notes
self.amount = amount
def validate(self) -> Dict[str, Any]:
"""
Validate the project update data.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate project exists
if not is_valid_uuid(self.project_id):
errors.append("Invalid project ID format")
else:
project = self.project_repo.get_by_id(self.project_id)
if not project:
errors.append(f"Project with ID {self.project_id} not found")
# Validate date format if provided
if not errors and self.date and not is_valid_date(self.date):
errors.append("Invalid date format. Use YYYY-MM-DD.")
# Validate labor amount if provided
if not errors and self.labor is not None:
labor_validation = validate_decimal_amount(self.labor, 'labor')
if not labor_validation['valid']:
errors.append(labor_validation['error'])
# Validate billing amount if provided
if not errors and self.amount is not None:
amount_validation = validate_decimal_amount(self.amount, 'amount')
if not amount_validation['valid']:
errors.append(amount_validation['error'])
# Validate status if provided
if not errors and self.status:
valid_statuses = ['planned', 'in_progress', 'completed', 'cancelled']
if self.status not in valid_statuses:
errors.append(f"Invalid status. Must be one of: {', '.join(valid_statuses)}")
# Validate account exists if provided
if not errors and self.account_id:
account_validation = validate_model_exists(
self.account_id, 'account', self.account_repo.get_by_id
)
if not account_validation['valid']:
errors.append(account_validation['error'])
# Validate team member IDs if provided
if not errors and self.team_member_ids is not None:
for member_id in self.team_member_ids:
if not is_valid_uuid(member_id):
errors.append(f"Invalid team member ID format: {member_id}")
elif not self.profile_repo.get_by_id(member_id):
errors.append(f"Team member with ID {member_id} not found")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[Project]:
"""
Execute the project update command.
Returns:
CommandResult[Project]: Result of the command execution.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Create a dictionary of fields to update
update_data = {}
# Add fields to update_data if they were provided
if self.status is not None:
update_data['status'] = self.status
if self.date is not None:
update_data['date'] = self.date
if self.labor is not None:
update_data['labor'] = float(self.labor)
if self.account_id is not None:
update_data['account_id'] = self.account_id
if self.notes is not None:
update_data['notes'] = self.notes
if self.amount is not None:
update_data['amount'] = float(self.amount)
# Update the project with the data dictionary
updated_project = self.project_repo.update(self.project_id, update_data)
# Update team members if provided
if self.team_member_ids is not None:
# Assuming there's a method to assign team members
updated_project = self.project_repo.assign_team_members(
self.project_id,
self.team_member_ids
)
return CommandResult.success_result(
updated_project,
f"Project {self.project_id} updated successfully"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to update project"
)
class DeleteProjectCommand(Command):
"""
Command to delete a project.
"""
def __init__(self, project_repo: ProjectRepository, project_id: str):
"""
Initialize the delete project command.
Args:
project_repo: Repository for project operations.
project_id: ID of the project to delete.
"""
self.project_repo = project_repo
self.project_id = project_id
def validate(self) -> Dict[str, Any]:
"""
Validate the project deletion request.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate project exists
if not is_valid_uuid(self.project_id):
errors.append("Invalid project ID format")
else:
project = self.project_repo.get_by_id(self.project_id)
if not project:
errors.append(f"Project with ID {self.project_id} not found")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[bool]:
"""
Execute the project deletion command.
Returns:
CommandResult[bool]: Result of the command execution.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Delete the project
success = self.project_repo.delete(self.project_id)
if success:
return CommandResult.success_result(
True,
f"Project {self.project_id} deleted successfully"
)
else:
return CommandResult.failure_result(
"Failed to delete project",
f"Project {self.project_id} could not be deleted"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to delete project"
)

View File

@ -0,0 +1,296 @@
"""
Commands for handling reports.
"""
from typing import List, Optional
from datetime import date
from django.utils import timezone
from backend.core.repositories import ReportRepository
from backend.core.models import Service, Project
from backend.core.commands.base import Command, CommandResult
class CreateReportCommand(Command):
"""
Command to create a new report.
Args:
team_member_id: UUID of the team member
report_date: Date of the report
service_ids: Optional list of service IDs to associate
project_ids: Optional list of project IDs to associate
notes: Optional notes about the report
"""
def __init__(
self,
team_member_id: str,
report_date: date,
service_ids: Optional[List[str]] = None,
project_ids: Optional[List[str]] = None,
notes: Optional[str] = None
):
self.team_member_id = team_member_id
self.report_date = report_date
self.service_ids = service_ids or []
self.project_ids = project_ids or []
self.notes = notes
def execute(self) -> CommandResult:
"""Execute the command to create a report."""
try:
report_data = {
'team_member_id': self.team_member_id,
'date': self.report_date,
'notes': self.notes
}
report = ReportRepository.create_with_items(
report_data,
self.service_ids,
self.project_ids
)
return CommandResult(
success=True,
data=report,
message="Report created successfully."
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to create report"
)
class UpdateReportCommand(Command):
"""
Command to update an existing report.
Args:
report_id: UUID of the report to update
team_member_id: Optional UUID of the team member
report_date: Optional date of the report
service_ids: Optional list of service IDs to associate
project_ids: Optional list of project IDs to associate
notes: Optional notes about the report
"""
def __init__(
self,
report_id: str,
team_member_id: Optional[str] = None,
report_date: Optional[date] = None,
service_ids: Optional[List[str]] = None,
project_ids: Optional[List[str]] = None,
notes: Optional[str] = None
):
self.report_id = report_id
self.team_member_id = team_member_id
self.report_date = report_date
self.service_ids = service_ids
self.project_ids = project_ids
self.notes = notes
def execute(self) -> CommandResult:
"""Execute the command to update a report."""
try:
# Get the report
report = ReportRepository.get_by_id(self.report_id)
if not report:
return CommandResult(
success=False,
message=f"Report with ID {self.report_id} not found."
)
# Update fields if provided
update_data = {}
if self.team_member_id is not None:
update_data['team_member_id'] = self.team_member_id
if self.report_date is not None:
update_data['date'] = self.report_date
if self.notes is not None:
update_data['notes'] = self.notes
# Update the report
if update_data:
report = ReportRepository.update(self.report_id, update_data)
# Update related services
if self.service_ids is not None:
services = Service.objects.filter(id__in=self.service_ids)
report.services.set(services)
# Update related projects
if self.project_ids is not None:
projects = Project.objects.filter(id__in=self.project_ids)
report.projects.set(projects)
return CommandResult(
success=True,
data=report,
message="Report updated successfully."
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to update report"
)
class DeleteReportCommand(Command):
"""
Command to delete a report.
Args:
report_id: UUID of the report to delete
"""
def __init__(self, report_id: str):
self.report_id = report_id
def execute(self) -> CommandResult:
"""Execute the command to delete a report."""
try:
report = ReportRepository.get_by_id(self.report_id)
if not report:
return CommandResult(
success=False,
message=f"Report with ID {self.report_id} not found."
)
ReportRepository.delete(self.report_id)
return CommandResult(
success=True,
message="Report deleted successfully."
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to delete report"
)
class GetTeamMemberReportsCommand(Command):
"""
Command to get reports for a team member within a date range.
Args:
team_member_id: UUID of the team member
start_date: Optional start date (inclusive)
end_date: Optional end date (inclusive)
"""
def __init__(
self,
team_member_id: str,
start_date: Optional[date] = None,
end_date: Optional[date] = None
):
self.team_member_id = team_member_id
self.start_date = start_date
self.end_date = end_date or timezone.now().date()
def execute(self) -> CommandResult:
"""Execute the command to get reports for a team member."""
try:
# First get reports by team member
reports = ReportRepository.get_by_team_member(self.team_member_id)
# Then filter by date range if provided
if self.start_date:
reports = reports.filter(date__gte=self.start_date)
if self.end_date:
reports = reports.filter(date__lte=self.end_date)
# Order by date descending
reports = reports.order_by('-date')
return CommandResult(
success=True,
data=list(reports),
message=f"Found {reports.count()} reports for the team member."
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to find reports for the team member."
)
class GetTeamMemberActivityCommand(Command):
"""
Command to get activity summary for a team member.
Args:
team_member_id: UUID of the team member
start_date: Optional start date (inclusive)
end_date: Optional end date (inclusive)
"""
def __init__(
self,
team_member_id: str,
start_date: Optional[date] = None,
end_date: Optional[date] = None
):
self.team_member_id = team_member_id
self.start_date = start_date
self.end_date = end_date
def execute(self) -> CommandResult:
"""Execute the command to get activity summary for a team member."""
try:
activity_summary = ReportRepository.get_team_member_activity(
self.team_member_id,
self.start_date,
self.end_date
)
return CommandResult(
success=True,
data=activity_summary,
message="Retrieved team member activity summary."
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to retrieve team member activity summary."
)
class GetTeamSummaryCommand(Command):
"""
Command to get activity summary for all team members.
Args:
start_date: Optional start date (inclusive)
end_date: Optional end date (inclusive)
"""
def __init__(
self,
start_date: Optional[date] = None,
end_date: Optional[date] = None
):
self.start_date = start_date
self.end_date = end_date
def execute(self) -> CommandResult:
"""Execute the command to get activity summary for all team members."""
try:
team_summary = ReportRepository.get_team_summary(
self.start_date,
self.end_date
)
return CommandResult(
success=True,
data=team_summary,
message="Retrieved team summary."
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to retrieve team summary."
)

View File

@ -0,0 +1,693 @@
"""
Commands for revenue-related operations.
"""
from typing import Any, Dict, List, Optional
from backend.core.models.revenues.revenues import Revenue
from backend.core.repositories.revenues.revenues import RevenueRepository
from backend.core.repositories.accounts.accounts import AccountRepository
from backend.core.utils.validators import (
is_valid_uuid, is_valid_date, validate_required_fields, validate_model_exists
)
from backend.core.utils.helpers import generate_uuid, parse_date
from backend.core.commands.base import Command, CommandResult
class CreateRevenueCommand(Command):
"""
Command to create a new revenue record.
"""
def __init__(
self,
revenue_repo: RevenueRepository,
account_repo: AccountRepository,
account_id: str,
amount: str,
start_date: str,
end_date: Optional[str] = None
):
"""
Initialize the create revenue command.
Args:
revenue_repo: Repository for revenue operations.
account_repo: Repository for account operations.
account_id: ID of the account this revenue belongs to.
amount: Amount of revenue.
start_date: Start date of the revenue (YYYY-MM-DD).
end_date: End date of the revenue (YYYY-MM-DD, optional).
"""
self.revenue_repo = revenue_repo
self.account_repo = account_repo
self.account_id = account_id
self.amount = amount
self.start_date = start_date
self.end_date = end_date
def validate(self) -> Dict[str, Any]:
"""
Validate the revenue creation data.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Check required fields
required_fields = ['account_id', 'amount', 'start_date']
field_values = {
'account_id': self.account_id,
'amount': self.amount,
'start_date': self.start_date
}
missing_fields = validate_required_fields(field_values, required_fields)
if missing_fields:
errors.append(f"Required fields missing: {', '.join(missing_fields)}")
# Validate account exists
if not errors and self.account_id:
account_validation = validate_model_exists(
self.account_id, 'account', self.account_repo.get_by_id
)
if not account_validation['valid']:
errors.append(account_validation['error'])
# Validate amount is a valid decimal
if not errors and self.amount:
try:
amount = float(self.amount)
if amount <= 0:
errors.append("Amount must be greater than zero.")
except ValueError:
errors.append("Amount must be a valid number.")
# Validate date formats
if not errors and self.start_date and not is_valid_date(self.start_date):
errors.append("Invalid start date format. Use YYYY-MM-DD.")
if not errors and self.end_date and not is_valid_date(self.end_date):
errors.append("Invalid end date format. Use YYYY-MM-DD.")
# Validate start date is before end date if both provided
if not errors and self.start_date and self.end_date:
start = parse_date(self.start_date)
end = parse_date(self.end_date)
if start and end and start > end:
errors.append("Start date must be before end date.")
# Check if there's an active revenue record for this account
if not errors:
active_revenue = self.revenue_repo.get_active_by_account(self.account_id)
if active_revenue:
errors.append(
f"Account already has an active revenue record. End the current record before creating a new one.")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[Revenue]:
"""
Execute the revenue creation command.
Returns:
CommandResult[Revenue]: Result of the command execution.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Create revenue data
revenue_id = generate_uuid()
# Create revenue data dictionary
revenue_data = {
'id': revenue_id,
'account_id': self.account_id,
'amount': self.amount,
'start_date': self.start_date,
'end_date': self.end_date
}
# Save to repository
created_revenue = self.revenue_repo.create(revenue_data)
return CommandResult.success_result(
created_revenue,
f"Revenue record created successfully for account {self.account_id}"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to create revenue record"
)
class UpdateRevenueCommand(Command):
"""
Command to update an existing revenue record.
"""
def __init__(
self,
revenue_repo: RevenueRepository,
account_repo: AccountRepository,
revenue_id: str,
amount: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None
):
"""
Initialize the update revenue command.
Args:
revenue_repo: Repository for revenue operations.
account_repo: Repository for account operations.
revenue_id: ID of the revenue to update.
amount: New amount for the revenue.
start_date: New start date for the revenue.
end_date: New end date for the revenue.
"""
self.revenue_repo = revenue_repo
self.account_repo = account_repo
self.revenue_id = revenue_id
self.amount = amount
self.start_date = start_date
self.end_date = end_date
def validate(self) -> Dict[str, Any]:
"""
Validate the revenue update data.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate revenue exists
if not is_valid_uuid(self.revenue_id):
errors.append("Invalid revenue ID format")
else:
revenue = self.revenue_repo.get_by_id(self.revenue_id)
if not revenue:
errors.append(f"Revenue with ID {self.revenue_id} not found")
# Validate amount is a valid decimal if provided
if not errors and self.amount is not None:
try:
amount = float(self.amount)
if amount <= 0:
errors.append("Amount must be greater than zero.")
except ValueError:
errors.append("Amount must be a valid number.")
# Validate date formats if provided
if not errors and self.start_date is not None and not is_valid_date(self.start_date):
errors.append("Invalid start date format. Use YYYY-MM-DD.")
if not errors and self.end_date is not None and not is_valid_date(self.end_date):
errors.append("Invalid end date format. Use YYYY-MM-DD.")
# Validate start date is before end date if both provided
if not errors and self.start_date and self.end_date:
start = parse_date(self.start_date)
end = parse_date(self.end_date)
if start and end and start > end:
errors.append("Start date must be before end date.")
# If only updating end_date, validate it's after the existing start_date
if not errors and self.end_date and not self.start_date:
revenue = self.revenue_repo.get_by_id(self.revenue_id)
if revenue:
end = parse_date(self.end_date)
start = parse_date(revenue.start_date)
if end and start and start > end:
errors.append("End date must be after the existing start date.")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[Revenue]:
"""
Execute the revenue update command.
Returns:
CommandResult[Revenue]: Result of the command execution.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Create a dictionary of fields to update
update_data = {}
# Add fields to update_data if they were provided
if self.amount is not None:
update_data['amount'] = self.amount
if self.start_date is not None:
update_data['start_date'] = self.start_date
if self.end_date is not None:
update_data['end_date'] = self.end_date
# Update the revenue with the data dictionary
updated_revenue = self.revenue_repo.update(self.revenue_id, update_data)
return CommandResult.success_result(
updated_revenue,
f"Revenue {self.revenue_id} updated successfully"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to update revenue"
)
class DeleteRevenueCommand(Command):
"""
Command to delete a revenue record.
"""
def __init__(
self,
revenue_repo: RevenueRepository,
revenue_id: str
):
"""
Initialize the delete revenue command.
Args:
revenue_repo: Repository for revenue operations.
revenue_id: ID of the revenue to delete.
"""
self.revenue_repo = revenue_repo
self.revenue_id = revenue_id
def validate(self) -> Dict[str, Any]:
"""
Validate the revenue deletion request.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate revenue exists
if not is_valid_uuid(self.revenue_id):
errors.append("Invalid revenue ID format")
else:
revenue = self.revenue_repo.get_by_id(self.revenue_id)
if not revenue:
errors.append(f"Revenue with ID {self.revenue_id} not found")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[bool]:
"""
Execute the revenue deletion command.
Returns:
CommandResult[bool]: Result of the command execution.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Delete the revenue
success = self.revenue_repo.delete(self.revenue_id)
if success:
return CommandResult.success_result(
True,
f"Revenue {self.revenue_id} deleted successfully"
)
else:
return CommandResult.failure_result(
"Failed to delete revenue",
f"Revenue {self.revenue_id} could not be deleted"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to delete revenue"
)
class EndRevenueCommand(Command):
"""
Command to end a revenue record by setting its end date.
"""
def __init__(
self,
revenue_repo: RevenueRepository,
revenue_id: str,
end_date: Optional[str] = None
):
"""
Initialize the end revenue command.
Args:
revenue_repo: Repository for revenue operations.
revenue_id: ID of the revenue to end.
end_date: End date for the revenue (defaults to today if not provided).
"""
self.revenue_repo = revenue_repo
self.revenue_id = revenue_id
self.end_date = end_date
def validate(self) -> Dict[str, Any]:
"""
Validate the end revenue request.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate revenue exists
if not is_valid_uuid(self.revenue_id):
errors.append("Invalid revenue ID format")
else:
revenue = self.revenue_repo.get_by_id(self.revenue_id)
if not revenue:
errors.append(f"Revenue with ID {self.revenue_id} not found")
elif revenue.end_date is not None:
errors.append(f"Revenue is already ended")
# Validate end date format if provided
if not errors and self.end_date and not is_valid_date(self.end_date):
errors.append("Invalid end date format. Use YYYY-MM-DD.")
# Validate end date is after start date if provided
if not errors and self.end_date:
revenue = self.revenue_repo.get_by_id(self.revenue_id)
if revenue:
end = parse_date(self.end_date)
start = parse_date(revenue.start_date)
if end and start and start > end:
errors.append("End date must be after the start date.")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[Revenue]:
"""Execute the end revenue command."""
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# End the revenue record
if self.end_date:
# Use provided end date
updated_revenue = self.revenue_repo.update(self.revenue_id, {'end_date': self.end_date})
else:
# Use repository method that sets end date to today
updated_revenue = self.revenue_repo.end_revenue(self.revenue_id)
if updated_revenue:
return CommandResult.success_result(
updated_revenue,
f"Revenue {self.revenue_id} ended successfully"
)
else:
return CommandResult.failure_result(
"Failed to end revenue record",
f"Revenue {self.revenue_id} could not be ended"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to end revenue record"
)
class GetRevenueByDateRangeCommand(Command):
"""
Command to get revenue records within a date range.
"""
def __init__(
self,
revenue_repo: RevenueRepository,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
account_id: Optional[str] = None
):
"""
Initialize the get revenue by date range command.
Args:
revenue_repo: Repository for revenue operations.
start_date: Start date for the range (YYYY-MM-DD).
end_date: End date for the range (YYYY-MM-DD).
account_id: Optional account ID to filter by.
"""
self.revenue_repo = revenue_repo
self.start_date = start_date
self.end_date = end_date
self.account_id = account_id
def validate(self) -> Dict[str, Any]:
"""
Validate the get revenue by date range request.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate date formats if provided
if self.start_date and not is_valid_date(self.start_date):
errors.append("Invalid start date format. Use YYYY-MM-DD.")
if self.end_date and not is_valid_date(self.end_date):
errors.append("Invalid end date format. Use YYYY-MM-DD.")
# Validate start date is before end date if both provided
if not errors and self.start_date and self.end_date:
start = parse_date(self.start_date)
end = parse_date(self.end_date)
if start and end and start > end:
errors.append("Start date must be before end date.")
# Validate account ID if provided
if not errors and self.account_id and not is_valid_uuid(self.account_id):
errors.append("Invalid account ID format")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[List[Revenue]]:
"""
Execute the get revenue by date range command.
Returns:
CommandResult[List[Revenue]]: Result of the command execution with revenue data.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Parse dates if provided
start_date_obj = parse_date(self.start_date) if self.start_date else None
end_date_obj = parse_date(self.end_date) if self.end_date else None
# Get revenues by date range
revenues = self.revenue_repo.get_by_date_range(start_date_obj, end_date_obj)
# Filter by account if provided
if self.account_id:
revenues = revenues.filter(account_id=self.account_id)
return CommandResult.success_result(
list(revenues),
f"Retrieved revenue data for specified date range"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to retrieve revenue data"
)
class CalculateTotalRevenueCommand(Command):
"""
Command to calculate total revenue for an account or all accounts within a date range.
"""
def __init__(
self,
revenue_repo: RevenueRepository,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
account_id: Optional[str] = None
):
self.revenue_repo = revenue_repo
self.start_date = start_date
self.end_date = end_date
self.account_id = account_id
def validate(self) -> Dict[str, Any]:
"""Validate the calculation request."""
errors = []
# Validate date formats if provided
if self.start_date and not is_valid_date(self.start_date):
errors.append("Invalid start date format. Use YYYY-MM-DD.")
if self.end_date and not is_valid_date(self.end_date):
errors.append("Invalid end date format. Use YYYY-MM-DD.")
# Validate date range if both dates provided
if self.start_date and self.end_date:
start = parse_date(self.start_date)
end = parse_date(self.end_date)
if start and end and start > end:
errors.append("Start date must be before end date.")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[float]:
"""Execute the revenue calculation command."""
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Convert string dates to date objects if provided
start_date_obj = parse_date(self.start_date) if self.start_date else None
end_date_obj = parse_date(self.end_date) if self.end_date else None
# Calculate total revenue using repository method
total_revenue = self.revenue_repo.get_total_revenue(
account_id=self.account_id,
start_date=start_date_obj,
end_date=end_date_obj
)
# Create success message
message = "Total revenue"
if self.account_id:
message += f" for account {self.account_id}"
if self.start_date or self.end_date:
message += " for period"
if self.start_date:
message += f" from {self.start_date}"
if self.end_date:
message += f" to {self.end_date}"
message += f": ${total_revenue:.2f}"
return CommandResult.success_result(
total_revenue,
message
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to calculate total revenue"
)
class GetActiveRevenuesCommand(Command):
"""
Command to get all active revenue records.
"""
def __init__(
self,
revenue_repo: RevenueRepository,
account_id: Optional[str] = None
):
"""
Initialize the get active revenues command.
Args:
revenue_repo: Repository for revenue operations.
account_id: Optional account ID to filter by.
"""
self.revenue_repo = revenue_repo
self.account_id = account_id
def validate(self) -> Dict[str, Any]:
"""
Validate the get active revenues request.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate account ID if provided
if self.account_id and not is_valid_uuid(self.account_id):
errors.append("Invalid account ID format")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[List[Revenue]]:
"""
Execute the get active revenues command.
Returns:
CommandResult[List[Revenue]]: Result of the command execution with active revenues.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Get active revenues
if self.account_id:
active_revenue = self.revenue_repo.get_active_by_account(self.account_id)
revenues = [active_revenue] if active_revenue else []
else:
revenues = list(self.revenue_repo.get_active())
return CommandResult.success_result(
revenues,
f"Retrieved active revenue records"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to retrieve active revenue records"
)

View File

@ -0,0 +1,848 @@
"""
Commands for schedule-related operations.
"""
from typing import Any, Dict, List, Optional
from datetime import datetime
from backend.core.models.schedules.schedules import Schedule
from backend.core.models.services.services import Service
from backend.core.repositories.schedules.schedules import ScheduleRepository
from backend.core.repositories.accounts.accounts import AccountRepository
from backend.core.utils.validators import (
is_valid_uuid, is_valid_date, validate_required_fields, validate_model_exists
)
from backend.core.utils.helpers import generate_uuid, parse_date
from backend.core.commands.base import Command, CommandResult
class CreateScheduleCommand(Command):
"""
Command to create a new schedule.
"""
def __init__(
self,
schedule_repo: ScheduleRepository,
account_repo: AccountRepository,
account_id: str,
start_date: str,
monday_service: bool = False,
tuesday_service: bool = False,
wednesday_service: bool = False,
thursday_service: bool = False,
friday_service: bool = False,
saturday_service: bool = False,
sunday_service: bool = False,
weekend_service: bool = False,
schedule_exception: Optional[str] = None,
end_date: Optional[str] = None
):
"""
Initialize the create schedule command.
Args:
schedule_repo: Repository for schedule operations.
account_repo: Repository for account operations.
account_id: ID of the account this schedule belongs to.
start_date: Start date of the schedule (YYYY-MM-DD).
monday_service: Whether service is scheduled for Monday.
tuesday_service: Whether service is scheduled for Tuesday.
wednesday_service: Whether service is scheduled for Wednesday.
thursday_service: Whether service is scheduled for Thursday.
friday_service: Whether service is scheduled for Friday.
saturday_service: Whether service is scheduled for Saturday.
sunday_service: Whether service is scheduled for Sunday.
weekend_service: Whether weekend service is enabled.
schedule_exception: Any exceptions to the schedule.
end_date: End date of the schedule (YYYY-MM-DD, optional).
"""
self.schedule_repo = schedule_repo
self.account_repo = account_repo
self.account_id = account_id
self.start_date = start_date
self.monday_service = monday_service
self.tuesday_service = tuesday_service
self.wednesday_service = wednesday_service
self.thursday_service = thursday_service
self.friday_service = friday_service
self.saturday_service = saturday_service
self.sunday_service = sunday_service
self.weekend_service = weekend_service
self.schedule_exception = schedule_exception
self.end_date = end_date
def validate(self) -> Dict[str, Any]:
"""
Validate the schedule creation data.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Check required fields
required_fields = ['account_id', 'start_date']
field_values = {
'account_id': self.account_id,
'start_date': self.start_date
}
missing_fields = validate_required_fields(field_values, required_fields)
if missing_fields:
errors.append(f"Required fields missing: {', '.join(missing_fields)}")
# Validate account exists
if not errors and self.account_id:
account_validation = validate_model_exists(
self.account_id, 'account', self.account_repo.get_by_id
)
if not account_validation['valid']:
errors.append(account_validation['error'])
# Validate date formats
if not errors and self.start_date and not is_valid_date(self.start_date):
errors.append("Invalid start date format. Use YYYY-MM-DD.")
if not errors and self.end_date and not is_valid_date(self.end_date):
errors.append("Invalid end date format. Use YYYY-MM-DD.")
# Validate start date is before end date if both provided
if not errors and self.start_date and self.end_date:
start = parse_date(self.start_date)
end = parse_date(self.end_date)
if start and end and start > end:
errors.append("Start date must be before end date.")
# Check if there's an active schedule for this account
if not errors:
active_schedule = self.schedule_repo.get_active_by_account(self.account_id)
if active_schedule:
errors.append(
f"Account already has an active schedule. End the current schedule before creating a new one.")
# Validate at least one service day is selected
if not errors and not any([
self.monday_service, self.tuesday_service, self.wednesday_service,
self.thursday_service, self.friday_service, self.saturday_service,
self.sunday_service, self.weekend_service
]):
errors.append("At least one service day must be selected.")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[Schedule]:
"""
Execute the schedule creation command.
Returns:
CommandResult[Schedule]: Result of the command execution.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Create schedule data
schedule_id = generate_uuid()
# Create schedule data dictionary
schedule_data = {
'id': schedule_id,
'account_id': self.account_id,
'monday_service': self.monday_service,
'tuesday_service': self.tuesday_service,
'wednesday_service': self.wednesday_service,
'thursday_service': self.thursday_service,
'friday_service': self.friday_service,
'saturday_service': self.saturday_service,
'sunday_service': self.sunday_service,
'weekend_service': self.weekend_service,
'schedule_exception': self.schedule_exception,
'start_date': self.start_date,
'end_date': self.end_date
}
# Save to repository
created_schedule = self.schedule_repo.create(schedule_data)
return CommandResult.success_result(
created_schedule,
f"Schedule created successfully for account {self.account_id}"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to create schedule"
)
class UpdateScheduleCommand(Command):
"""
Command to update an existing schedule.
"""
def __init__(
self,
schedule_repo: ScheduleRepository,
account_repo: AccountRepository,
schedule_id: str,
monday_service: Optional[bool] = None,
tuesday_service: Optional[bool] = None,
wednesday_service: Optional[bool] = None,
thursday_service: Optional[bool] = None,
friday_service: Optional[bool] = None,
saturday_service: Optional[bool] = None,
sunday_service: Optional[bool] = None,
weekend_service: Optional[bool] = None,
schedule_exception: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None
):
"""
Initialize the update schedule command.
Args:
schedule_repo: Repository for schedule operations.
account_repo: Repository for account operations.
schedule_id: ID of the schedule to update.
monday_service: Whether service is scheduled for Monday.
tuesday_service: Whether service is scheduled for Tuesday.
wednesday_service: Whether service is scheduled for Wednesday.
thursday_service: Whether service is scheduled for Thursday.
friday_service: Whether service is scheduled for Friday.
saturday_service: Whether service is scheduled for Saturday.
sunday_service: Whether service is scheduled for Sunday.
weekend_service: Whether weekend service is enabled.
schedule_exception: Any exceptions to the schedule.
start_date: Start date of the schedule.
end_date: End date of the schedule.
"""
self.schedule_repo = schedule_repo
self.account_repo = account_repo
self.schedule_id = schedule_id
self.monday_service = monday_service
self.tuesday_service = tuesday_service
self.wednesday_service = wednesday_service
self.thursday_service = thursday_service
self.friday_service = friday_service
self.saturday_service = saturday_service
self.sunday_service = sunday_service
self.weekend_service = weekend_service
self.schedule_exception = schedule_exception
self.start_date = start_date
self.end_date = end_date
def validate(self) -> Dict[str, Any]:
"""
Validate the schedule update data.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate schedule exists
if not is_valid_uuid(self.schedule_id):
errors.append("Invalid schedule ID format")
else:
schedule = self.schedule_repo.get_by_id(self.schedule_id)
if not schedule:
errors.append(f"Schedule with ID {self.schedule_id} not found")
# Validate date formats if provided
if not errors and self.start_date is not None and not is_valid_date(self.start_date):
errors.append("Invalid start date format. Use YYYY-MM-DD.")
if not errors and self.end_date is not None and not is_valid_date(self.end_date):
errors.append("Invalid end date format. Use YYYY-MM-DD.")
# Validate start date is before end date if both provided
if not errors and self.start_date and self.end_date:
start = parse_date(self.start_date)
end = parse_date(self.end_date)
if start and end and start > end:
errors.append("Start date must be before end date.")
# If only updating end_date, validate it's after the existing start_date
if not errors and self.end_date and not self.start_date:
schedule = self.schedule_repo.get_by_id(self.schedule_id)
if schedule:
end = parse_date(self.end_date)
start = parse_date(schedule.start_date)
if end and start and start > end:
errors.append("End date must be after the existing start date.")
# Check if at least one service day will be selected after update
if not errors:
schedule = self.schedule_repo.get_by_id(self.schedule_id)
if schedule:
monday = self.monday_service if self.monday_service is not None else schedule.monday_service
tuesday = self.tuesday_service if self.tuesday_service is not None else schedule.tuesday_service
wednesday = self.wednesday_service if self.wednesday_service is not None else schedule.wednesday_service
thursday = self.thursday_service if self.thursday_service is not None else schedule.thursday_service
friday = self.friday_service if self.friday_service is not None else schedule.friday_service
saturday = self.saturday_service if self.saturday_service is not None else schedule.saturday_service
sunday = self.sunday_service if self.sunday_service is not None else schedule.sunday_service
weekend = self.weekend_service if self.weekend_service is not None else schedule.weekend_service
if not any([monday, tuesday, wednesday, thursday, friday, saturday, sunday, weekend]):
errors.append("At least one service day must be selected.")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[Schedule]:
"""
Execute the schedule update command.
Returns:
CommandResult[Schedule]: Result of the command execution.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Create a dictionary of fields to update
update_data = {}
# Add fields to update_data if they were provided
if self.monday_service is not None:
update_data['monday_service'] = self.monday_service
if self.tuesday_service is not None:
update_data['tuesday_service'] = self.tuesday_service
if self.wednesday_service is not None:
update_data['wednesday_service'] = self.wednesday_service
if self.thursday_service is not None:
update_data['thursday_service'] = self.thursday_service
if self.friday_service is not None:
update_data['friday_service'] = self.friday_service
if self.saturday_service is not None:
update_data['saturday_service'] = self.saturday_service
if self.sunday_service is not None:
update_data['sunday_service'] = self.sunday_service
if self.weekend_service is not None:
update_data['weekend_service'] = self.weekend_service
if self.schedule_exception is not None:
update_data['schedule_exception'] = self.schedule_exception
if self.start_date is not None:
update_data['start_date'] = self.start_date
if self.end_date is not None:
update_data['end_date'] = self.end_date
# Update the schedule with the data dictionary
updated_schedule = self.schedule_repo.update(self.schedule_id, update_data)
return CommandResult.success_result(
updated_schedule,
f"Schedule {self.schedule_id} updated successfully"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to update schedule"
)
class DeleteScheduleCommand(Command):
"""
Command to delete a schedule.
"""
def __init__(
self,
schedule_repo: ScheduleRepository,
schedule_id: str
):
"""
Initialize the delete schedule command.
Args:
schedule_repo: Repository for schedule operations.
schedule_id: ID of the schedule to delete.
"""
self.schedule_repo = schedule_repo
self.schedule_id = schedule_id
def validate(self) -> Dict[str, Any]:
"""
Validate the schedule deletion request.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate schedule exists
if not is_valid_uuid(self.schedule_id):
errors.append("Invalid schedule ID format")
else:
schedule = self.schedule_repo.get_by_id(self.schedule_id)
if not schedule:
errors.append(f"Schedule with ID {self.schedule_id} not found")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[bool]:
"""
Execute the schedule deletion command.
Returns:
CommandResult[bool]: Result of the command execution.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Delete the schedule
success = self.schedule_repo.delete(self.schedule_id)
if success:
return CommandResult.success_result(
True,
f"Schedule {self.schedule_id} deleted successfully"
)
else:
return CommandResult.failure_result(
"Failed to delete schedule",
f"Schedule {self.schedule_id} could not be deleted"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to delete schedule"
)
class EndScheduleCommand(Command):
"""
Command to end a schedule by setting its end date.
"""
def __init__(
self,
schedule_repo: ScheduleRepository,
schedule_id: str,
end_date: Optional[str] = None
):
"""
Initialize the end schedule command.
Args:
schedule_repo: Repository for schedule operations.
schedule_id: ID of the schedule to end.
end_date: End date for the schedule (defaults to today if not provided).
"""
self.schedule_repo = schedule_repo
self.schedule_id = schedule_id
self.end_date = end_date
def validate(self) -> Dict[str, Any]:
"""
Validate the end schedule request.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate schedule exists
if not is_valid_uuid(self.schedule_id):
errors.append("Invalid schedule ID format")
else:
schedule = self.schedule_repo.get_by_id(self.schedule_id)
if not schedule:
errors.append(f"Schedule with ID {self.schedule_id} not found")
elif schedule.end_date is not None:
errors.append(f"Schedule is already ended")
# Validate end date format if provided
if not errors and self.end_date and not is_valid_date(self.end_date):
errors.append("Invalid end date format. Use YYYY-MM-DD.")
# Validate end date is after start date if provided
if not errors and self.end_date:
schedule = self.schedule_repo.get_by_id(self.schedule_id)
if schedule:
end = parse_date(self.end_date)
start = parse_date(schedule.start_date)
if end and start and start > end:
errors.append("End date must be after the start date.")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[Schedule]:
"""
Execute the end schedule command.
Returns:
CommandResult[Schedule]: Result of the command execution.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# End the schedule
end_date = self.end_date or datetime.now().strftime('%Y-%m-%d')
updated_schedule = self.schedule_repo.update(
self.schedule_id,
{'end_date': end_date}
)
if updated_schedule:
return CommandResult.success_result(
updated_schedule,
f"Schedule {self.schedule_id} ended successfully"
)
else:
return CommandResult.failure_result(
"Failed to end schedule",
f"Schedule {self.schedule_id} could not be ended"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to end schedule"
)
class GetActiveSchedulesCommand(Command):
"""
Command to get all active schedules.py.
"""
def __init__(
self,
schedule_repo: ScheduleRepository,
account_id: Optional[str] = None
):
"""
Initialize the get active schedules.py command.
Args:
schedule_repo: Repository for schedule operations.
account_id: Optional account ID to filter by.
"""
self.schedule_repo = schedule_repo
self.account_id = account_id
def validate(self) -> Dict[str, Any]:
"""
Validate the get active schedules.py request.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate account ID if provided
if self.account_id and not is_valid_uuid(self.account_id):
errors.append("Invalid account ID format")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[List[Schedule]]:
"""
Execute the get active schedules.py command.
Returns:
CommandResult[List[Schedule]]: Result of the command execution with active schedules.py.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Get active schedules.py
if self.account_id:
active_schedule = self.schedule_repo.get_active_by_account(self.account_id)
schedules = [active_schedule] if active_schedule else []
else:
schedules = list(self.schedule_repo.get_active())
return CommandResult.success_result(
schedules,
f"Retrieved active schedule records"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to retrieve active schedule records"
)
class GenerateServicesCommand(Command):
"""
Command to generate services based on a schedule.
"""
def __init__(
self,
schedule_repo: ScheduleRepository,
schedule_id: str,
start_date: str,
end_date: str
):
"""
Initialize the generate services command.
Args:
schedule_repo: Repository for schedule operations.
schedule_id: ID of the schedule to generate services from.
start_date: Start date for service generation (YYYY-MM-DD).
end_date: End date for service generation (YYYY-MM-DD).
"""
self.schedule_repo = schedule_repo
self.schedule_id = schedule_id
self.start_date = start_date
self.end_date = end_date
def validate(self) -> Dict[str, Any]:
"""
Validate the generate services request.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate schedule exists
if not is_valid_uuid(self.schedule_id):
errors.append("Invalid schedule ID format")
else:
schedule = self.schedule_repo.get_by_id(self.schedule_id)
if not schedule:
errors.append(f"Schedule with ID {self.schedule_id} not found")
# Validate date formats
if not errors and not is_valid_date(self.start_date):
errors.append("Invalid start date format. Use YYYY-MM-DD.")
if not errors and not is_valid_date(self.end_date):
errors.append("Invalid end date format. Use YYYY-MM-DD.")
# Validate start date is before end date
if not errors:
start = parse_date(self.start_date)
end = parse_date(self.end_date)
if start and end and start > end:
errors.append("Start date must be before end date.")
# Validate date range is not too large (e.g., limit to 3 months)
if not errors:
start = parse_date(self.start_date)
end = parse_date(self.end_date)
if start and end:
date_diff = (end - start).days
if date_diff > 90: # 3 months
errors.append("Date range too large. Maximum range is 90 days.")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[List[Service]]:
"""
Execute the generate services command.
Returns:
CommandResult[List[Service]]: Result of the command execution with generated services.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Parse dates
start_date_obj = parse_date(self.start_date)
end_date_obj = parse_date(self.end_date)
# Generate services
services = self.schedule_repo.generate_services(
self.schedule_id,
start_date_obj,
end_date_obj
)
return CommandResult.success_result(
services,
f"Generated {len(services)} services from schedule {self.schedule_id}"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to generate services from schedule"
)
class GetScheduleByAccountCommand(Command):
"""
Command to get schedules.py for a specific account.
"""
def __init__(
self,
schedule_repo: ScheduleRepository,
account_id: str
):
"""
Initialize the get schedule by account command.
Args:
schedule_repo: Repository for schedule operations.
account_id: ID of the account to get schedules.py for.
"""
self.schedule_repo = schedule_repo
self.account_id = account_id
def validate(self) -> Dict[str, Any]:
"""
Validate the get schedule by account request.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate account ID
if not is_valid_uuid(self.account_id):
errors.append("Invalid account ID format")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[List[Schedule]]:
"""
Execute the get schedule by account command.
Returns:
CommandResult[List[Schedule]]: Result of the command execution with account schedules.py.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Get schedules.py for the account
schedules = list(self.schedule_repo.get_by_account(self.account_id))
return CommandResult.success_result(
schedules,
f"Retrieved {len(schedules)} schedules.py for account {self.account_id}"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to retrieve schedules.py for account"
)
class SearchSchedulesCommand(Command):
"""
Command to search for schedules.py.
"""
def __init__(
self,
schedule_repo: ScheduleRepository,
search_term: str
):
"""
Initialize the search schedules.py command.
Args:
schedule_repo: Repository for schedule operations.
search_term: Term to search for.
"""
self.schedule_repo = schedule_repo
self.search_term = search_term
def validate(self) -> Dict[str, Any]:
"""
Validate the search schedules.py request.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate search term
if not self.search_term or len(self.search_term.strip()) < 2:
errors.append("Search term must be at least 2 characters long")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[List[Schedule]]:
"""
Execute the search schedules.py command.
Returns:
CommandResult[List[Schedule]]: Result of the command execution with matching schedules.py.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Search for schedules.py
schedules = list(self.schedule_repo.search(self.search_term))
return CommandResult.success_result(
schedules,
f"Found {len(schedules)} schedules.py matching '{self.search_term}'"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to search for schedules.py"
)

View File

@ -0,0 +1,7 @@
"""
Service commands for managing services.
"""
from backend.core.commands.services.bulk_schedule import BulkScheduleServicesCommand
__all__ = ['BulkScheduleServicesCommand']

View File

@ -0,0 +1,118 @@
"""
Command for bulk scheduling services.
"""
from typing import Any, Dict, List
from datetime import datetime
from backend.core.commands.base import Command, CommandResult
from backend.core.repositories.accounts.accounts import AccountRepository
from backend.core.repositories.schedules.schedules import ScheduleRepository
from backend.core.factories.services.services import ServiceFactory
from backend.core.utils.validators import validate_required_fields
class BulkScheduleServicesCommand(Command):
"""
Command to bulk schedule services for multiple accounts.
"""
def __init__(
self,
account_repo: AccountRepository,
schedule_repo: ScheduleRepository,
account_ids: List[str],
year: int,
month: int
):
"""
Initialize the bulk schedule services command.
Args:
account_repo: Repository for account operations.
schedule_repo: Repository for schedule operations.
account_ids: List of account IDs to schedule services for.
year: The year for scheduling.
month: The month for scheduling (1-12).
"""
self.account_repo = account_repo
self.schedule_repo = schedule_repo
self.account_ids = account_ids
self.year = year
self.month = month
def validate(self) -> Dict[str, Any]:
"""
Validate the bulk schedule services request.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate required fields
field_values = {
'account_ids': self.account_ids,
'year': self.year,
'month': self.month
}
missing_fields = validate_required_fields(field_values, ['account_ids', 'year', 'month'])
if missing_fields:
return {
'is_valid': False,
'errors': [f"Required fields missing: {', '.join(missing_fields)}"]
}
# Validate account IDs
if not self.account_ids:
errors.append("No account IDs provided")
# Validate year and month
current_year = datetime.now().year
if self.year < current_year or self.year > current_year + 5:
errors.append(f"Year must be between {current_year} and {current_year + 5}")
if self.month < 1 or self.month > 12:
errors.append("Month must be between 1 and 12")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[Dict[str, Any]]:
"""
Execute the bulk schedule services command.
Returns:
CommandResult[Dict[str, Any]]: Result of the command execution with scheduled services.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Get active schedules for the specified accounts
active_schedules = []
for account_id in self.account_ids:
active_schedule = self.schedule_repo.get_active_by_account(account_id)
if active_schedule:
active_schedules.append(active_schedule)
# Generate services for each account
result = ServiceFactory.generate_services_for_accounts(
active_schedules,
self.year,
self.month
)
return CommandResult.success_result(
result,
f"Bulk scheduled services for {len(active_schedules)} accounts for {self.month}/{self.year}"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to bulk schedule services"
)

View File

@ -0,0 +1,611 @@
"""
Commands for service-related operations.
"""
from typing import Any, Dict, List, Optional
from datetime import datetime
from backend.core.models.services.services import Service
from backend.core.repositories.services.services import ServiceRepository
from backend.core.repositories.accounts.accounts import AccountRepository
from backend.core.repositories.profiles.profiles import ProfileRepository
from backend.core.utils.validators import (
is_valid_uuid, is_valid_date, is_valid_datetime,
validate_required_fields, validate_model_exists
)
from backend.core.utils.helpers import generate_uuid, parse_date
from backend.core.commands.base import Command, CommandResult
class CreateServiceCommand(Command):
"""
Command to create a new service.
"""
def __init__(
self,
service_repo: ServiceRepository,
account_repo: AccountRepository,
profile_repo: ProfileRepository,
account_id: str,
date: str,
status: str = 'scheduled',
team_member_ids: Optional[List[str]] = None,
notes: Optional[str] = None,
deadline_start: Optional[str] = None,
deadline_end: Optional[str] = None
):
"""
Initialize the create service command.
Args:
service_repo: Repository for service operations.
account_repo: Repository for account operations.
profile_repo: Repository for profile operations.
account_id: ID of the account the service is for.
date: Date of the service (YYYY-MM-DD).
status: Status of the service ('scheduled', 'in_progress', 'completed', 'cancelled').
team_member_ids: List of profile IDs for team members assigned to the service.
notes: Additional notes about the service.
deadline_start: Start time of the service deadline (ISO format).
deadline_end: End time of the service deadline (ISO format).
"""
self.service_repo = service_repo
self.account_repo = account_repo
self.profile_repo = profile_repo
self.account_id = account_id
self.date = date
self.status = status
self.team_member_ids = team_member_ids or []
self.notes = notes
self.deadline_start = deadline_start
self.deadline_end = deadline_end
def validate(self) -> Dict[str, Any]:
"""
Validate the service creation data.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Check required fields
missing_fields = validate_required_fields(
{
'account_id': self.account_id,
'date': self.date
},
['account_id', 'date']
)
if missing_fields:
errors.append(f"Required fields missing: {', '.join(missing_fields)}")
# Validate account exists
if not errors and self.account_id:
account_validation = validate_model_exists(
self.account_id, 'account', self.account_repo.get_by_id
)
if not account_validation['valid']:
errors.append(account_validation['error'])
# Validate date format
if not errors and self.date and not is_valid_date(self.date):
errors.append("Invalid date format. Use YYYY-MM-DD.")
# Validate deadline formats
if not errors and self.deadline_start and not is_valid_datetime(self.deadline_start):
errors.append("Invalid deadline_start format. Use ISO format.")
if not errors and self.deadline_end and not is_valid_datetime(self.deadline_end):
errors.append("Invalid deadline_end format. Use ISO format.")
# Validate status
valid_statuses = ['scheduled', 'in_progress', 'completed', 'cancelled']
if not errors and self.status not in valid_statuses:
errors.append(f"Invalid status. Must be one of: {', '.join(valid_statuses)}")
# Validate team member IDs
if not errors and self.team_member_ids:
for member_id in self.team_member_ids:
if not is_valid_uuid(member_id):
errors.append(f"Invalid team member ID format: {member_id}")
elif not self.profile_repo.get_by_id(member_id):
errors.append(f"Team member with ID {member_id} not found")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[Service]:
"""
Execute the service creation command.
Returns:
CommandResult[Service]: Result of the command execution.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Create service data
service_id = generate_uuid()
# Get date objects
service_date = parse_date(self.date)
# Create default deadline times if not provided
deadline_start = self.deadline_start
deadline_end = self.deadline_end
if not deadline_start and service_date:
# Default to 9:00 AM on the service date
deadline_start = datetime.combine(
service_date,
datetime.min.time().replace(hour=9)
).isoformat()
if not deadline_end and service_date:
# Default to 5:00 PM on the service date
deadline_end = datetime.combine(
service_date,
datetime.min.time().replace(hour=17)
).isoformat()
# Create service data dictionary instead of Service object
service_data = {
'id': service_id,
'account_id': self.account_id,
'date': self.date,
'status': self.status,
'notes': self.notes,
'deadline_start': deadline_start,
'deadline_end': deadline_end
}
# Use create_with_team_members to handle both service creation and team member assignment
created_service = self.service_repo.create_with_team_members(
service_data,
self.team_member_ids or []
)
return CommandResult.success_result(
created_service,
f"Service for account {self.account_id} created successfully"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to create service"
)
class UpdateServiceCommand(Command):
"""
Command to update an existing service.
"""
def __init__(
self,
service_repo: ServiceRepository,
account_repo: AccountRepository,
profile_repo: ProfileRepository,
service_id: str,
status: Optional[str] = None,
date: Optional[str] = None,
team_member_ids: Optional[List[str]] = None,
notes: Optional[str] = None,
deadline_start: Optional[str] = None,
deadline_end: Optional[str] = None
):
"""
Initialize the update service command.
Args:
service_repo: Repository for service operations.
account_repo: Repository for account operations.
profile_repo: Repository for profile operations.
service_id: ID of the service to update.
status: New status for the service.
date: New date for the service.
team_member_ids: New list of team member IDs.
notes: New notes for the service.
deadline_start: New start deadline for the service.
deadline_end: New end deadline for the service.
"""
self.service_repo = service_repo
self.account_repo = account_repo
self.profile_repo = profile_repo
self.service_id = service_id
self.status = status
self.date = date
self.team_member_ids = team_member_ids
self.notes = notes
self.deadline_start = deadline_start
self.deadline_end = deadline_end
def validate(self) -> Dict[str, Any]:
"""
Validate the service update data.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate service exists
if not is_valid_uuid(self.service_id):
errors.append("Invalid service ID format")
else:
service = self.service_repo.get_by_id(self.service_id)
if not service:
errors.append(f"Service with ID {self.service_id} not found")
# Validate date format if provided
if not errors and self.date and not is_valid_date(self.date):
errors.append("Invalid date format. Use YYYY-MM-DD.")
# Validate deadline formats if provided
if not errors and self.deadline_start and not is_valid_datetime(self.deadline_start):
errors.append("Invalid deadline_start format. Use ISO format.")
if not errors and self.deadline_end and not is_valid_datetime(self.deadline_end):
errors.append("Invalid deadline_end format. Use ISO format.")
# Validate status if provided
if not errors and self.status:
valid_statuses = ['scheduled', 'in_progress', 'completed', 'cancelled']
if self.status not in valid_statuses:
errors.append(f"Invalid status. Must be one of: {', '.join(valid_statuses)}")
# Validate team member IDs if provided
if not errors and self.team_member_ids is not None:
for member_id in self.team_member_ids:
if not is_valid_uuid(member_id):
errors.append(f"Invalid team member ID format: {member_id}")
elif not self.profile_repo.get_by_id(member_id):
errors.append(f"Team member with ID {member_id} not found")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[Service]:
"""
Execute the service update command.
Returns:
CommandResult[Service]: Result of the command execution.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Create a dictionary of fields to update
update_data = {}
# Add fields to update_data if they were provided
if self.status is not None:
update_data['status'] = self.status
if self.date is not None:
update_data['date'] = self.date
if self.notes is not None:
update_data['notes'] = self.notes
if self.deadline_start is not None:
update_data['deadline_start'] = self.deadline_start
if self.deadline_end is not None:
update_data['deadline_end'] = self.deadline_end
# Update the service with the data dictionary
updated_service = self.service_repo.update(self.service_id, update_data)
# Update team members if provided
if self.team_member_ids is not None:
updated_service = self.service_repo.assign_team_members(
self.service_id,
self.team_member_ids
)
return CommandResult.success_result(
updated_service,
f"Service {self.service_id} updated successfully"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to update service"
)
class DeleteServiceCommand(Command):
"""
Command to delete a service.
"""
def __init__(self, service_repo: ServiceRepository, service_id: str):
"""
Initialize the delete service command.
Args:
service_repo: Repository for service operations.
service_id: ID of the service to delete.
"""
self.service_repo = service_repo
self.service_id = service_id
def validate(self) -> Dict[str, Any]:
"""
Validate the service deletion request.
Returns:
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
"""
errors = []
# Validate service exists
if not is_valid_uuid(self.service_id):
errors.append("Invalid service ID format")
else:
service = self.service_repo.get_by_id(self.service_id)
if not service:
errors.append(f"Service with ID {self.service_id} not found")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[bool]:
"""
Execute the service deletion command.
Returns:
CommandResult[bool]: Result of the command execution.
"""
# Validate command data
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
# Delete the service
success = self.service_repo.delete(self.service_id)
if success:
return CommandResult.success_result(
True,
f"Service {self.service_id} deleted successfully"
)
else:
return CommandResult.failure_result(
"Failed to delete service",
f"Service {self.service_id} could not be deleted"
)
except Exception as e:
return CommandResult.failure_result(
str(e),
"Failed to delete service"
)
class CompleteServiceCommand(Command):
"""Command to mark a service as complete."""
def __init__(
self,
service_repo: ServiceRepository,
service_id: str,
completion_notes: Optional[str] = None
):
self.service_repo = service_repo
self.service_id = service_id
self.completion_notes = completion_notes
def validate(self) -> Dict[str, Any]:
errors = []
if not is_valid_uuid(self.service_id):
errors.append("Invalid service ID format")
else:
service = self.service_repo.get_by_id(self.service_id)
if not service:
errors.append(f"Service with ID {self.service_id} not found")
elif service.status == 'completed':
errors.append("Service is already completed")
elif service.status == 'cancelled':
errors.append("Cancelled service cannot be completed")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[Service]:
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
update_data = {
'status': 'completed',
'notes': self.completion_notes
}
updated_service = self.service_repo.update(self.service_id, update_data)
return CommandResult.success_result(
updated_service,
f"Service {self.service_id} marked as completed"
)
except Exception as e:
return CommandResult.failure_result(str(e))
class CancelServiceCommand(Command):
"""Command to cancel a service."""
def __init__(
self,
service_repo: ServiceRepository,
service_id: str,
cancellation_reason: Optional[str] = None
):
self.service_repo = service_repo
self.service_id = service_id
self.cancellation_reason = cancellation_reason
def validate(self) -> Dict[str, Any]:
errors = []
if not is_valid_uuid(self.service_id):
errors.append("Invalid service ID format")
else:
service = self.service_repo.get_by_id(self.service_id)
if not service:
errors.append(f"Service with ID {self.service_id} not found")
elif service.status == 'cancelled':
errors.append("Service is already cancelled")
elif service.status == 'completed':
errors.append("Completed service cannot be cancelled")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[Service]:
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
update_data = {
'status': 'cancelled',
'notes': self.cancellation_reason
}
updated_service = self.service_repo.update(self.service_id, update_data)
return CommandResult.success_result(
updated_service,
f"Service {self.service_id} cancelled successfully"
)
except Exception as e:
return CommandResult.failure_result(str(e))
class AssignTeamMembersCommand(Command):
"""Command to assign team members to a service."""
def __init__(
self,
service_repo: ServiceRepository,
profile_repo: ProfileRepository,
service_id: str,
team_member_ids: List[str]
):
self.service_repo = service_repo
self.profile_repo = profile_repo
self.service_id = service_id
self.team_member_ids = team_member_ids
def validate(self) -> Dict[str, Any]:
errors = []
if not is_valid_uuid(self.service_id):
errors.append("Invalid service ID format")
else:
service = self.service_repo.get_by_id(self.service_id)
if not service:
errors.append(f"Service with ID {self.service_id} not found")
for member_id in self.team_member_ids:
if not is_valid_uuid(member_id):
errors.append(f"Invalid team member ID format: {member_id}")
elif not self.profile_repo.get_by_id(member_id):
errors.append(f"Team member with ID {member_id} not found")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[Service]:
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
updated_service = self.service_repo.assign_team_members(
self.service_id,
self.team_member_ids
)
return CommandResult.success_result(
updated_service,
f"Team members assigned to service {self.service_id} successfully"
)
except Exception as e:
return CommandResult.failure_result(str(e))
class GetServicesByDateRangeCommand(Command):
"""Command to get services within a date range."""
def __init__(
self,
service_repo: ServiceRepository,
start_date: str,
end_date: str,
account_id: Optional[str] = None,
team_member_id: Optional[str] = None
):
self.service_repo = service_repo
self.start_date = start_date
self.end_date = end_date
self.account_id = account_id
self.team_member_id = team_member_id
def validate(self) -> Dict[str, Any]:
errors = []
if not is_valid_date(self.start_date):
errors.append("Invalid start date format")
if not is_valid_date(self.end_date):
errors.append("Invalid end date format")
if self.account_id and not is_valid_uuid(self.account_id):
errors.append("Invalid account ID format")
if self.team_member_id and not is_valid_uuid(self.team_member_id):
errors.append("Invalid team member ID format")
return {
'is_valid': len(errors) == 0,
'errors': errors
}
def execute(self) -> CommandResult[List[Service]]:
validation = self.validate()
if not validation['is_valid']:
return CommandResult.failure_result(validation['errors'])
try:
start = parse_date(self.start_date)
end = parse_date(self.end_date)
services = self.service_repo.filter_services(
date_from=start,
date_to=end,
account_id=self.account_id,
team_member_id=self.team_member_id
)
return CommandResult.success_result(
list(services),
f"Found {services.count()} services in date range"
)
except Exception as e:
return CommandResult.failure_result(str(e))

View File

@ -0,0 +1,3 @@
"""
Factory module for creating domain objects.
"""

View File

@ -0,0 +1,7 @@
"""
Service factory module for creating service objects.
"""
from backend.core.factories.services.services import ServiceFactory
__all__ = ['ServiceFactory']

View File

@ -0,0 +1,202 @@
"""
Factory for creating Service objects.
"""
from datetime import datetime, date, timedelta
from typing import List, Dict, Any, Optional
from backend.core.models import Schedule, Service, Profile
from backend.core.utils.helpers import get_month_start_end, dict_to_json
class ServiceFactory:
"""
Factory for creating Service objects based on schedules.
"""
@staticmethod
def serialize_service(service: Service) -> Dict[str, Any]:
"""
Convert a Service object to a serializable dictionary.
Args:
service: The Service object to serialize
Returns:
Dict[str, Any]: A dictionary representation of the Service object
"""
return {
'id': str(service.id),
'account_id': str(service.account.id),
'account_name': service.account.name,
'date': service.date.isoformat(),
'status': service.status,
'notes': service.notes,
'deadline_start': service.deadline_start.isoformat(),
'deadline_end': service.deadline_end.isoformat(),
'created_at': service.created_at.isoformat(),
'updated_at': service.updated_at.isoformat(),
'completed_at': service.completed_at.isoformat() if service.completed_at else None,
'team_member_ids': [str(member.id) for member in service.team_members.all()],
'team_member_names': service.team_member_names
}
@classmethod
def create_service(cls, schedule: Schedule, service_date: date, is_weekend: bool = False) -> Service:
"""
Create a Service object for a specific date based on a schedule.
Args:
schedule: The schedule to base the service on
service_date: The date for the service
is_weekend: Whether this is a weekend service
Returns:
Service: The created service object
"""
# Set default deadline times (6:00pm the day of service to 6:00am the following day)
deadline_start = datetime.combine(service_date, datetime.min.time()).replace(hour=18) # 6:00pm
deadline_end = datetime.combine(service_date + timedelta(days=1), datetime.min.time()).replace(hour=6) # 6:00am next day
notes = None
if is_weekend:
notes = "Weekend Service"
# Create the service
service = Service.objects.create(
account=schedule.account,
date=service_date,
status='scheduled',
notes=notes,
deadline_start=deadline_start,
deadline_end=deadline_end
)
# Add admin profile to team_members by default
admin_profile_id = "7dc00b89-72d1-4dea-a1ed-4cbef220aa0c"
try:
admin_profile = Profile.objects.get(id=admin_profile_id)
service.team_members.add(admin_profile)
except Profile.DoesNotExist:
# If admin profile doesn't exist, continue without adding it
pass
return service
@classmethod
def generate_services_for_month(cls, schedule: Schedule, year: int, month: int) -> Dict[str, Any]:
"""
Generate services for a specific month based on a schedule.
Args:
schedule: The schedule to base the services on
year: The year
month: The month (1-12)
Returns:
Dict[str, Any]: Dictionary with generated services and any errors
"""
if schedule.schedule_exception:
return {
'services': [],
'success': False,
'errors': [f"Schedule exception: {schedule.schedule_exception}"],
'message': "Schedule has an exception and requires manual scheduling"
}
# Get the start and end dates of the month
month_start = date(year, month, 1)
month_end = get_month_start_end(datetime(year, month, 1))[1].date()
services = []
errors = []
# Iterate through each day in the month
current_date = month_start
while current_date <= month_end:
weekday = current_date.weekday() # Monday is 0, Sunday is 6
day_name = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'][weekday]
# Check if service is required for this day
is_service_day = schedule.has_service_on_day(day_name)
is_weekend = weekday >= 5 and schedule.weekend_service
# If it's a service day or a weekend with weekend_service enabled
if is_service_day or is_weekend:
# TODO: Check if it's a holiday (would need a holiday model/service)
# For now, we'll assume it's not a holiday
is_holiday = False
if not is_holiday:
try:
# For weekend services, use the actual date but mark as weekend service
service = cls.create_service(schedule, current_date, is_weekend=is_weekend)
# Serialize the service object to make it JSON serializable
serialized_service = cls.serialize_service(service)
services.append(serialized_service)
except Exception as e:
errors.append(f"Error creating service for {current_date}: {str(e)}")
current_date += timedelta(days=1)
return {
'services': services,
'success': len(services) > 0,
'errors': errors,
'message': f"Generated {len(services)} services for {month}/{year}"
}
@classmethod
def generate_services_for_accounts(cls, schedules: List[Schedule], year: int, month: int) -> Dict[str, Any]:
"""
Generate services for multiple accounts based on their schedules.
Args:
schedules: List of schedules to generate services for
year: The year
month: The month (1-12)
Returns:
Dict[str, Any]: Dictionary with results for each account
"""
results = {}
overall_success = True
overall_errors = []
for schedule in schedules:
account_id = str(schedule.account.id)
account_name = schedule.account.name
# Skip inactive schedules
if not schedule.is_active:
results[account_id] = {
'account_name': account_name,
'services': [],
'success': False,
'errors': ["Schedule is inactive"],
'message': "Schedule is inactive"
}
continue
# Generate services for this account
account_result = cls.generate_services_for_month(schedule, year, month)
# Add account name to the result
account_result['account_name'] = account_name
# Store the result
results[account_id] = account_result
# Update overall success and errors
if not account_result['success']:
overall_success = False
if account_result['errors']:
for error in account_result['errors']:
overall_errors.append(f"{account_name}: {error}")
return {
'account_results': results,
'success': overall_success,
'errors': overall_errors,
'message': f"Generated services for {len(results)} accounts for {month}/{year}"
}

View File

@ -0,0 +1,32 @@
"""
Models package initialization.
Import all models here to make them available when importing from core.models
"""
from django.contrib.auth.models import User
from backend.core.models.profiles.profiles import Profile
from backend.core.models.customers.customers import Customer
from backend.core.models.accounts.accounts import Account
from backend.core.models.revenues.revenues import Revenue
from backend.core.models.labor.labor import Labor
from backend.core.models.schedules.schedules import Schedule
from backend.core.models.services.services import Service
from backend.core.models.projects.projects import Project
from backend.core.models.invoices.invoices import Invoice
from backend.core.models.reports.reports import Report
from backend.core.models.punchlists.punchlists import Punchlist
__all__ = [
'User',
'Profile',
'Customer',
'Account',
'Revenue',
'Labor',
'Schedule',
'Service',
'Project',
'Invoice',
'Report',
'Punchlist',
]

View File

View File

@ -0,0 +1,89 @@
"""
Account models for managing customer accounts.
"""
import uuid
from django.db import models
from django.utils import timezone
from backend.core.models.revenues.revenues import Revenue
from backend.core.models.labor.labor import Labor
class Account(models.Model):
"""Account model belonging to a customer"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
customer = models.ForeignKey('Customer', on_delete=models.CASCADE, related_name='accounts')
name = models.CharField(max_length=200)
# Address
street_address = models.CharField(max_length=255)
city = models.CharField(max_length=100)
state = models.CharField(max_length=100)
zip_code = models.CharField(max_length=20)
# Contact
primary_contact_first_name = models.CharField(max_length=100)
primary_contact_last_name = models.CharField(max_length=100)
primary_contact_phone = models.CharField(max_length=20)
primary_contact_email = models.EmailField()
# Secondary contact (optional)
secondary_contact_first_name = models.CharField(max_length=100, blank=True, null=True)
secondary_contact_last_name = models.CharField(max_length=100, blank=True, null=True)
secondary_contact_phone = models.CharField(max_length=20, blank=True, null=True)
secondary_contact_email = models.EmailField(blank=True, null=True)
# Dates
start_date = models.DateField()
end_date = models.DateField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'core'
verbose_name = 'Account'
verbose_name_plural = 'Accounts'
ordering = ['name']
indexes = [
models.Index(fields=['customer']),
models.Index(fields=['name']),
]
def __str__(self):
return f"{self.name} ({self.customer.name})"
@property
def is_active(self):
"""Check if account is active based on end_date"""
return self.end_date is None or self.end_date > timezone.now().date()
@property
def primary_contact_full_name(self):
"""Get contact's full name"""
return f"{self.primary_contact_first_name} {self.primary_contact_last_name}"
@property
def secondary_contact_full_name(self):
"""Get contact's full name"""
return f"{self.secondary_contact_first_name} {self.secondary_contact_last_name}"
@property
def address(self):
"""Get formatted address"""
return f"{self.street_address}, {self.city}, {self.state} {self.zip_code}"
def current_revenue(self):
"""Get the current active revenue for this account"""
return Revenue.objects.filter(
account=self,
start_date__lte=timezone.now().date(),
end_date__isnull=True
).first()
def current_labor(self):
"""Get the current active labor for this account"""
return Labor.objects.filter(
account=self,
start_date__lte=timezone.now().date(),
end_date__isnull=True
).first()

View File

@ -0,0 +1,80 @@
"""
Customer models for managing customer information.
"""
import uuid
from django.db import models
from django.utils import timezone
class Customer(models.Model):
"""Customer model with contact information"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=200)
# Primary contact
primary_contact_first_name = models.CharField(max_length=100)
primary_contact_last_name = models.CharField(max_length=100)
primary_contact_phone = models.CharField(max_length=20)
primary_contact_email = models.EmailField()
# Secondary contact (optional)
secondary_contact_first_name = models.CharField(max_length=100, blank=True, null=True)
secondary_contact_last_name = models.CharField(max_length=100, blank=True, null=True)
secondary_contact_phone = models.CharField(max_length=20, blank=True, null=True)
secondary_contact_email = models.EmailField(blank=True, null=True)
# Billing information
billing_contact_first_name = models.CharField(max_length=100)
billing_contact_last_name = models.CharField(max_length=100)
billing_street_address = models.CharField(max_length=255)
billing_city = models.CharField(max_length=100)
billing_state = models.CharField(max_length=100)
billing_zip_code = models.CharField(max_length=20)
billing_email = models.EmailField()
billing_terms = models.TextField()
# Dates
start_date = models.DateField()
end_date = models.DateField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'core'
verbose_name = 'Customer'
verbose_name_plural = 'Customers'
ordering = ['name']
indexes = [
models.Index(fields=['name']),
models.Index(fields=['primary_contact_email']),
]
def __str__(self):
return self.name
@property
def is_active(self):
"""Check if customer is active based on end_date"""
return self.end_date is None or self.end_date > timezone.now().date()
@property
def primary_contact_full_name(self):
"""Get primary contact's full name"""
return f"{self.primary_contact_first_name} {self.primary_contact_last_name}"
@property
def secondary_contact_full_name(self):
"""Get secondary contact's full name"""
if self.secondary_contact_first_name and self.secondary_contact_last_name:
return f"{self.secondary_contact_first_name} {self.secondary_contact_last_name}"
return None
@property
def billing_contact_full_name(self):
"""Get billing contact's full name"""
return f"{self.billing_contact_first_name} {self.billing_contact_last_name}"
@property
def billing_address(self):
"""Get formatted billing address"""
return f"{self.billing_street_address}, {self.billing_city}, {self.billing_state} {self.billing_zip_code}"

View File

View File

@ -0,0 +1,83 @@
"""
Invoice models for tracking customer billing.
"""
import uuid
from django.db import models
from django.utils import timezone
class Invoice(models.Model):
"""Invoice records"""
STATUS_CHOICES = (
('draft', 'Draft'),
('sent', 'Sent'),
('paid', 'Paid'),
('overdue', 'Overdue'),
('cancelled', 'Cancelled'),
)
PAYMENT_CHOICES = (
('check', 'Check'),
('credit_card', 'Credit Card'),
('bank_transfer', 'Bank Transfer'),
('cash', 'Cash'),
)
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
customer = models.ForeignKey('Customer', on_delete=models.CASCADE, related_name='invoices')
date = models.DateField()
# Related items
accounts = models.ManyToManyField('Account', related_name='invoices', blank=True)
projects = models.ManyToManyField('Project', related_name='invoices', blank=True)
# Status and payment
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft')
date_paid = models.DateField(blank=True, null=True)
payment_type = models.CharField(max_length=20, choices=PAYMENT_CHOICES, blank=True, null=True)
# Financial
total_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
sent_at = models.DateTimeField(blank=True, null=True)
class Meta:
app_label = 'core'
verbose_name = 'Invoice'
verbose_name_plural = 'Invoices'
ordering = ['-date']
indexes = [
models.Index(fields=['customer']),
models.Index(fields=['date']),
models.Index(fields=['status']),
]
def __str__(self):
return f"Invoice for {self.customer.name} on {self.date}"
def save(self, *args, **kwargs):
"""Override save to update timestamps based on status"""
if self.status == 'sent' and not self.sent_at:
self.sent_at = timezone.now()
super().save(*args, **kwargs)
@property
def is_paid(self):
"""Check if invoice is paid"""
return self.status == 'paid'
@property
def is_overdue(self):
"""Check if invoice is overdue"""
# Normally you'd check against a due date field, but we'll use a simple 30 day rule
if self.status not in ['paid', 'cancelled'] and self.sent_at:
return (timezone.now().date() - self.date).days > 30
return False
@property
def days_outstanding(self):
"""Calculate days since invoice was sent"""
if not self.sent_at:
return 0
return (timezone.now().date() - self.sent_at.date()).days

View File

View File

@ -0,0 +1,42 @@
"""
Labor models for tracking account labor costs.
"""
import uuid
from django.db import models
from django.utils import timezone
class Labor(models.Model):
"""Labor records for accounts"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
account = models.ForeignKey('Account', on_delete=models.CASCADE, related_name='labors')
amount = models.DecimalField(max_digits=10, decimal_places=2)
start_date = models.DateField()
end_date = models.DateField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'core'
verbose_name = 'Labor'
verbose_name_plural = 'Labor'
ordering = ['-start_date']
indexes = [
models.Index(fields=['account']),
models.Index(fields=['start_date']),
]
def __str__(self):
return f"{self.account.name} - ${self.amount}"
@property
def is_active(self):
"""Check if labor record is active based on end_date"""
return self.end_date is None or self.end_date > timezone.now().date()
@property
def duration_days(self):
"""Calculate the duration in days"""
if not self.end_date:
return (timezone.now().date() - self.start_date).days
return (self.end_date - self.start_date).days

View File

View File

@ -0,0 +1,73 @@
"""
Profile models for user extensions.
"""
import uuid
from django.core.validators import RegexValidator
from django.db import models
from django.contrib.auth.models import User
from django.db.models.signals import post_save
from django.dispatch import receiver
class Profile(models.Model):
"""Extension of the Django User model"""
ROLE_CHOICES = (
('admin', 'Admin'),
('team_leader', 'Team Leader'),
('team_member', 'Team Member'),
)
phone_regex = RegexValidator(
regex=r'^\+?1?\d{9,15}$',
message="Phone number must be entered in the format: '+999999999'. Up to 15 digits allowed."
)
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
first_name = models.CharField(max_length=100)
last_name = models.CharField(max_length=100)
primary_phone = models.CharField(max_length=20)
secondary_phone = models.CharField(max_length=20, blank=True, null=True)
email = models.EmailField()
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='team_member')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'core'
verbose_name = 'Profile'
verbose_name_plural = 'Profiles'
ordering = ['first_name', 'last_name']
def __str__(self):
return f"{self.first_name} {self.last_name}"
@property
def full_name(self):
return f"{self.first_name} {self.last_name}"
@property
def is_admin(self):
return self.role == 'admin'
@property
def is_team_leader(self):
return self.role == 'team_leader'
@property
def is_team_member(self):
return self.role == 'team_member'
@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
"""Create a Profile when a new User is created"""
if created and not hasattr(instance, 'profile'):
Profile.objects.create(
user=instance,
first_name=instance.first_name or '',
last_name=instance.last_name or '',
email=instance.email or '',
primary_phone='' # This will need to be updated later
)

View File

View File

@ -0,0 +1,80 @@
"""
Project models for tracking customer projects.
"""
import uuid
from django.db import models
from django.utils import timezone
class Project(models.Model):
"""Project records for customers"""
STATUS_CHOICES = (
('planned', 'Planned'),
('in_progress', 'In Progress'),
('completed', 'Completed'),
('cancelled', 'Cancelled'),
)
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
customer = models.ForeignKey('Customer', on_delete=models.CASCADE, related_name='projects')
account = models.ForeignKey('Account', on_delete=models.CASCADE, related_name='projects', blank=True, null=True)
date = models.DateField()
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='planned')
team_members = models.ManyToManyField('Profile', related_name='projects')
notes = models.TextField(blank=True, null=True)
# Financial
labor = models.DecimalField(max_digits=10, decimal_places=2)
amount = models.DecimalField(max_digits=10, decimal_places=2, default=0)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
completed_at = models.DateTimeField(blank=True, null=True)
class Meta:
app_label = 'core'
verbose_name = 'Project'
verbose_name_plural = 'Projects'
ordering = ['-date']
indexes = [
models.Index(fields=['customer']),
models.Index(fields=['account']),
models.Index(fields=['date']),
models.Index(fields=['status']),
]
def __str__(self):
return f"Project for {self.customer.name} on {self.date}"
def save(self, *args, **kwargs):
"""Override save to set completed_at when status changes to completed"""
if self.status == 'completed' and not self.completed_at:
self.completed_at = timezone.now()
super().save(*args, **kwargs)
@property
def is_upcoming(self):
"""Check if project is upcoming"""
return self.date > timezone.now().date()
@property
def is_past_due(self):
"""Check if project is past due"""
return self.date < timezone.now().date() and self.status not in ['completed', 'cancelled']
@property
def team_member_names(self):
"""Get list of team member names"""
return [f"{member.first_name} {member.last_name}" for member in self.team_members.all()]
@property
def profit(self):
"""Calculate profit (amount - labor)"""
return self.amount - self.labor
@property
def profit_margin(self):
"""Calculate profit margin percentage"""
if self.amount == 0:
return 0
return (self.profit / self.amount) * 100

View File

@ -0,0 +1,3 @@
"""
Punchlist models for tracking project punchlists.
"""

View File

@ -0,0 +1,95 @@
"""
Punchlist models for tracking project punchlists.
This is a customizable punchlist model. Modify the fields to match your specific
service requirements. The current structure provides generic sections that can
be adapted for various industries.
"""
import uuid
from django.db import models
from django.utils import timezone
class Punchlist(models.Model):
"""
Punchlist records for projects.
Customize the fields below to match your service workflow.
The current structure provides generic sections for:
- Front area (customer-facing)
- Main work area
- Equipment
- Back area
- End of visit checklist
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
project = models.ForeignKey('Project', on_delete=models.CASCADE, related_name='punchlists')
account = models.ForeignKey('Account', on_delete=models.CASCADE, related_name='punchlists')
date = models.DateField()
second_visit = models.BooleanField(default=False)
second_date = models.DateTimeField(blank=True, null=True)
# Front area section
front_ceiling = models.BooleanField(default=False)
front_vents = models.BooleanField(default=False)
front_fixtures = models.BooleanField(default=False)
front_counter = models.BooleanField(default=False)
# Main work area section
main_equipment = models.CharField(max_length=20, blank=True)
main_equipment_disassemble = models.BooleanField(default=False)
main_equipment_reassemble = models.BooleanField(default=False)
main_equipment_alerts = models.BooleanField(default=False)
main_equipment_exterior = models.BooleanField(default=False)
main_walls = models.BooleanField(default=False)
main_fixtures = models.BooleanField(default=False)
main_ceiling = models.BooleanField(default=False)
main_vents = models.BooleanField(default=False)
main_floors = models.BooleanField(default=False)
# Equipment section
equip_primary = models.BooleanField(default=False)
equip_station_1 = models.BooleanField(default=False)
equip_station_2 = models.BooleanField(default=False)
equip_station_3 = models.BooleanField(default=False)
equip_storage = models.BooleanField(default=False)
equip_prep = models.BooleanField(default=False)
equip_delivery = models.BooleanField(default=False)
equip_office = models.BooleanField(default=False)
equip_sinks = models.BooleanField(default=False)
equip_dispensers = models.BooleanField(default=False)
equip_other = models.BooleanField(default=False)
# Back area section
back_ceiling = models.BooleanField(default=False)
back_vents = models.BooleanField(default=False)
# End of visit section
end_trash = models.BooleanField(default=False)
end_clean = models.BooleanField(default=False)
end_secure = models.BooleanField(default=False)
# Notes
notes = models.TextField(blank=True)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
exported_at = models.DateTimeField(blank=True, null=True)
# Google API results (optional integration)
sheet_url = models.URLField(blank=True, null=True)
pdf_url = models.URLField(blank=True, null=True)
class Meta:
app_label = 'core'
ordering = ['-date']
verbose_name_plural = "Punchlists"
indexes = [
models.Index(fields=['project']),
models.Index(fields=['account']),
models.Index(fields=['date']),
]
def __str__(self):
return f"Punchlist for {self.account.name} on {self.date}"

View File

View File

@ -0,0 +1,42 @@
"""
Report models for tracking team member reports.
"""
import uuid
from django.db import models
class Report(models.Model):
"""Report records"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
date = models.DateField()
team_member = models.ForeignKey('Profile', on_delete=models.CASCADE, related_name='reports')
services = models.ManyToManyField('Service', related_name='reports', blank=True)
projects = models.ManyToManyField('Project', related_name='reports', blank=True)
notes = models.TextField(blank=True, null=True)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'core'
verbose_name = 'Report'
verbose_name_plural = 'Reports'
ordering = ['-date']
indexes = [
models.Index(fields=['team_member']),
models.Index(fields=['date']),
]
def __str__(self):
return f"Report by {self.team_member.first_name} {self.team_member.last_name} on {self.date}"
@property
def service_count(self):
"""Count of services in this report"""
return self.services.count()
@property
def project_count(self):
"""Count of projects in this report"""
return self.projects.count()

View File

View File

@ -0,0 +1,42 @@
"""
Revenue models for tracking account revenue.
"""
import uuid
from django.db import models
from django.utils import timezone
class Revenue(models.Model):
"""Revenue records for accounts"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
account = models.ForeignKey('Account', on_delete=models.CASCADE, related_name='revenues')
amount = models.DecimalField(max_digits=10, decimal_places=2)
start_date = models.DateField()
end_date = models.DateField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'core'
verbose_name = 'Revenue'
verbose_name_plural = 'Revenues'
ordering = ['-start_date']
indexes = [
models.Index(fields=['account']),
models.Index(fields=['start_date']),
]
def __str__(self):
return f"{self.account.name} - ${self.amount}"
@property
def is_active(self):
"""Check if revenue record is active based on end_date"""
return self.end_date is None or self.end_date > timezone.now().date()
@property
def duration_days(self):
"""Calculate the duration in days"""
if not self.end_date:
return (timezone.now().date() - self.start_date).days
return (self.end_date - self.start_date).days

View File

@ -0,0 +1,81 @@
"""
Schedule models for managing service schedules.py.
"""
import uuid
from django.db import models
from django.utils import timezone
class Schedule(models.Model):
"""Service schedules.py for accounts"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
account = models.ForeignKey('Account', on_delete=models.CASCADE, related_name='schedules')
# Service days
monday_service = models.BooleanField(default=False)
tuesday_service = models.BooleanField(default=False)
wednesday_service = models.BooleanField(default=False)
thursday_service = models.BooleanField(default=False)
friday_service = models.BooleanField(default=False)
saturday_service = models.BooleanField(default=False)
sunday_service = models.BooleanField(default=False)
weekend_service = models.BooleanField(default=False)
# Exceptions
schedule_exception = models.TextField(blank=True, null=True)
# Dates
start_date = models.DateField()
end_date = models.DateField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'core'
verbose_name = 'Schedule'
verbose_name_plural = 'Schedules'
ordering = ['-start_date']
indexes = [
models.Index(fields=['account']),
]
def __str__(self):
return f"Schedule for {self.account.name}"
@property
def is_active(self):
"""Check if schedule is active based on end_date"""
return self.end_date is None or self.end_date > timezone.now().date()
@property
def service_days(self):
"""Return list of service days"""
days = []
if self.monday_service:
days.append('Monday')
if self.tuesday_service:
days.append('Tuesday')
if self.wednesday_service:
days.append('Wednesday')
if self.thursday_service:
days.append('Thursday')
if self.friday_service:
days.append('Friday')
if self.saturday_service:
days.append('Saturday')
if self.sunday_service:
days.append('Sunday')
return days
def has_service_on_day(self, day_name):
"""Check if there is service on a specific day"""
day_map = {
'monday': self.monday_service,
'tuesday': self.tuesday_service,
'wednesday': self.wednesday_service,
'thursday': self.thursday_service,
'friday': self.friday_service,
'saturday': self.saturday_service,
'sunday': self.sunday_service,
}
return day_map.get(day_name.lower(), False)

View File

View File

@ -0,0 +1,71 @@
"""
Service models for tracking service events.
"""
import uuid
from django.db import models
from django.utils import timezone
class Service(models.Model):
"""Service records for accounts"""
STATUS_CHOICES = (
('scheduled', 'Scheduled'),
('in_progress', 'In Progress'),
('completed', 'Completed'),
('cancelled', 'Cancelled'),
)
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
account = models.ForeignKey('Account', on_delete=models.CASCADE, related_name='services')
date = models.DateField()
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='scheduled')
team_members = models.ManyToManyField('Profile', related_name='services')
notes = models.TextField(blank=True, null=True)
# Service window
deadline_start = models.DateTimeField()
deadline_end = models.DateTimeField()
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
completed_at = models.DateTimeField(blank=True, null=True)
class Meta:
app_label = 'core'
verbose_name = 'Service'
verbose_name_plural = 'Services'
ordering = ['-date']
indexes = [
models.Index(fields=['account']),
models.Index(fields=['date']),
models.Index(fields=['status']),
]
def __str__(self):
return f"Service for {self.account.name} on {self.date}"
def save(self, *args, **kwargs):
"""Override save to set completed_at when status changes to completed"""
if self.status == 'completed' and not self.completed_at:
self.completed_at = timezone.now()
super().save(*args, **kwargs)
@property
def is_upcoming(self):
"""Check if service is upcoming"""
return self.date > timezone.now().date()
@property
def is_today(self):
"""Check if service is scheduled for today"""
return self.date == timezone.now().date()
@property
def is_past_due(self):
"""Check if service is past due"""
return self.date < timezone.now().date() and self.status not in ['completed', 'cancelled']
@property
def team_member_names(self):
"""Get list of team member names"""
return [f"{member.first_name} {member.last_name}" for member in self.team_members.all()]

View File

@ -0,0 +1,31 @@
"""
Repositories package initialization.
Import all repositories here to make them available when importing from core.repositories
"""
from backend.core.repositories.base import BaseRepository
from backend.core.repositories.profiles.profiles import ProfileRepository
from backend.core.repositories.customers.customers import CustomerRepository
from backend.core.repositories.accounts.accounts import AccountRepository
from backend.core.repositories.revenues.revenues import RevenueRepository
from backend.core.repositories.labor.labor import LaborRepository
from backend.core.repositories.schedules.schedules import ScheduleRepository
from backend.core.repositories.services.services import ServiceRepository
from backend.core.repositories.projects.projects import ProjectRepository
from backend.core.repositories.invoices.invoices import InvoiceRepository
from backend.core.repositories.reports.reports import ReportRepository
from backend.core.repositories.punchlists.punchlists import PunchlistRepository
__all__ = [
'BaseRepository',
'ProfileRepository',
'CustomerRepository',
'AccountRepository',
'RevenueRepository',
'LaborRepository',
'ScheduleRepository',
'ServiceRepository',
'ProjectRepository',
'InvoiceRepository',
'ReportRepository',
'PunchlistRepository',
]

View File

@ -0,0 +1,220 @@
"""
Repository for Account model operations.
"""
from typing import Optional
from django.db.models import Q, Prefetch
from django.utils import timezone
from backend.core.models import Account, Service
from backend.core.repositories.base import BaseRepository
class AccountRepository(BaseRepository[Account]):
"""
Repository for Account model operations.
"""
model = Account
@classmethod
def get_by_customer(cls, customer_id: str):
"""
Get accounts by customer.
Args:
customer_id: The customer ID
Returns:
QuerySet of accounts for the customer
"""
return Account.objects.filter(customer_id=customer_id)
@classmethod
def get_active(cls):
"""
Get all active accounts.
Returns:
QuerySet of active accounts
"""
return Account.objects.filter(
Q(end_date__isnull=True) | Q(end_date__gt=timezone.now().date())
)
@classmethod
def get_active_by_customer(cls, customer_id: str):
"""
Get active accounts by customer.
Args:
customer_id: The customer ID
Returns:
QuerySet of active accounts for the customer
"""
return Account.objects.filter(
customer_id=customer_id
).filter(
Q(end_date__isnull=True) | Q(end_date__gt=timezone.now().date())
)
@classmethod
def get_inactive(cls):
"""
Get all inactive accounts.
Returns:
QuerySet of inactive accounts
"""
return Account.objects.filter(
end_date__isnull=False,
end_date__lte=timezone.now().date()
)
@classmethod
def mark_inactive(cls, id: str) -> Optional[Account]:
"""
Mark an account as inactive.
Args:
id: The account ID
Returns:
The updated account or None if not found
"""
return cls.update(id, {'end_date': timezone.now().date()})
@classmethod
def search(cls, search_term: str, include_inactive: bool = False):
"""
Search accounts by name or contact info.
Args:
search_term: The search term
include_inactive: Whether to include inactive accounts
Returns:
QuerySet of matching accounts
"""
queryset = super().search(
search_term,
[
'name',
'primary_contact_first_name',
'primary_contact_last_name',
'primary_contact_email',
'secondary_contact_first_name',
'secondary_contact_last_name',
'secondary_contact_email',
'customer__name'
]
)
if not include_inactive:
queryset = queryset.filter(
Q(end_date__isnull=True) | Q(end_date__gt=timezone.now().date())
)
return queryset
@classmethod
def get_with_services(cls, id: str, upcoming_only: bool = False) -> Optional[Account]:
"""
Get an account with prefetched services.
Args:
id: The account ID
upcoming_only: Whether to include only upcoming services
Returns:
The account with services or None if not found
"""
services_queryset = Service.objects.all()
if upcoming_only:
services_queryset = services_queryset.filter(date__gte=timezone.now().date())
services_prefetch = Prefetch('services', queryset=services_queryset)
try:
return Account.objects.prefetch_related(services_prefetch).get(pk=id)
except Account.DoesNotExist:
return None
@classmethod
def get_with_revenues(cls, id: str) -> Optional[Account]:
"""
Get an account with prefetched revenues.
Args:
id: The account ID
Returns:
The account with revenues or None if not found
"""
try:
return Account.objects.prefetch_related('revenues').get(pk=id)
except Account.DoesNotExist:
return None
@classmethod
def get_with_labors(cls, id: str) -> Optional[Account]:
"""
Get an account with prefetched labors.
Args:
id: The account ID
Returns:
The account with labors or None if not found
"""
try:
return Account.objects.prefetch_related('labors').get(pk=id)
except Account.DoesNotExist:
return None
@classmethod
def get_with_schedules(cls, id: str) -> Optional[Account]:
"""
Get an account with prefetched schedules
Args:
id: The account ID
Returns:
The account with schedules or None if not found
"""
try:
return Account.objects.prefetch_related('schedules').get(pk=id)
except Account.DoesNotExist:
return None
@classmethod
def get_with_projects(cls, id: str) -> Optional[Account]:
"""
Get an account with prefetched projects.
Args:
id: The account ID
Returns:
The account with projects or None if not found
"""
try:
return Account.objects.prefetch_related('projects').get(pk=id)
except Account.DoesNotExist:
return None
@classmethod
def get_with_all_related(cls, id: str) -> Optional[Account]:
"""
Get an account with all related data prefetched.
Args:
id: The account ID
Returns:
The account with all related data or None if not found
"""
try:
return Account.objects.prefetch_related(
'services', 'revenues', 'labors', 'schedules.py', 'projects'
).select_related('customer').get(pk=id)
except Account.DoesNotExist:
return None

View File

@ -0,0 +1,209 @@
"""
Base repository class for data access.
"""
from typing import TypeVar, Generic, List, Dict, Any, Optional, Type
from django.db import models
from django.db.models import Q, QuerySet
T = TypeVar('T', bound=models.Model)
class BaseRepository(Generic[T]):
"""
Base repository class with common methods for data access.
"""
model: Type[T] = None
@classmethod
def get_by_id(cls, entity_id: str) -> Optional[T]:
"""
Get an entity by ID.
Args:
entity_id: The entity ID
Returns:
The entity or None if not found
"""
try:
return cls.model.objects.get(pk=entity_id)
except cls.model.DoesNotExist:
return None
@classmethod
def get_all(cls, **filters) -> QuerySet[T]:
"""
Get all entities with optional filtering.
Args:
**filters: Filter parameters
Returns:
QuerySet of matching entities
"""
queryset = cls.model.objects.all()
# Apply filters
for key, value in filters.items():
if value is not None:
# Handle special filter keys
if key.endswith('__in') and not value:
# Empty list for __in lookup should return empty queryset
return cls.model.objects.none()
# Apply the filter
queryset = queryset.filter(**{key: value})
return queryset
@classmethod
def create(cls, data: Dict[str, Any]) -> T:
"""
Create a new entity.
Args:
data: Entity data
Returns:
The created entity
"""
return cls.model.objects.create(**data)
@classmethod
def update(cls, entity_id: str, data: Dict[str, Any]) -> Optional[T]:
"""
Update an existing entity.
Args:
entity_id: The entity ID
data: Updated data
Returns:
The updated entity or None if not found
"""
obj = cls.get_by_id(entity_id)
if not obj:
return None
for key, value in data.items():
setattr(obj, key, value)
obj.save()
return obj
@classmethod
def delete(cls, entity_id: str) -> bool:
"""
Delete an entity.
Args:
entity_id: The entity ID
Returns:
True if deleted, False if not found
"""
obj = cls.get_by_id(entity_id)
if not obj:
return False
obj.delete()
return True
@classmethod
def bulk_create(cls, data_list: List[Dict[str, Any]]) -> List[T]:
"""
Create multiple entities.
Args:
data_list: List of entity data
Returns:
List of created entities
"""
objects = [cls.model(**data) for data in data_list]
return cls.model.objects.bulk_create(objects)
@classmethod
def bulk_update(cls, objects: List[T], fields: List[str]) -> int:
"""
Update multiple entities.
Args:
objects: List of entity objects
fields: List of fields to update
Returns:
Number of updated entities
"""
# Cast the objects to Any to bypass type checking for this call
return cls.model.objects.bulk_update(objects, fields) # type: ignore
@classmethod
def count(cls, **filters) -> int:
"""
Count entities with optional filtering.
Args:
**filters: Filter parameters
Returns:
Count of matching entities
"""
return cls.get_all(**filters).count()
@classmethod
def exists(cls, **filters) -> bool:
"""
Check if any entities exist with optional filtering.
Args:
**filters: Filter parameters
Returns:
True if entities exist, False otherwise
"""
return cls.get_all(**filters).exists()
@classmethod
def filter_by_date_range(cls, start_date=None, end_date=None, date_field='date') -> QuerySet[T]:
"""
Filter entities by date range.
Args:
start_date: Start date (inclusive)
end_date: End date (inclusive)
date_field: Name of the date field to filter on
Returns:
QuerySet of matching entities
"""
filters = {}
if start_date:
filters[f'{date_field}__gte'] = start_date
if end_date:
filters[f'{date_field}__lte'] = end_date
return cls.get_all(**filters)
@classmethod
def search(cls, search_term: str, search_fields: List[str]) -> QuerySet[T]:
"""
Search entities by term across multiple fields.
Args:
search_term: The search term
search_fields: List of fields to search in
Returns:
QuerySet of matching entities
"""
if not search_term:
return cls.model.objects.all()
q_objects = Q()
for field in search_fields:
q_objects |= Q(**{f'{field}__icontains': search_term})
return cls.model.objects.filter(q_objects)

View File

@ -0,0 +1,177 @@
"""
Repository for Customer model operations.
"""
from typing import Optional
from django.db.models import Q, QuerySet
from django.utils import timezone
from backend.core.repositories.base import BaseRepository
from backend.core.models import Customer
class CustomerRepository(BaseRepository[Customer]):
"""
Repository for Customer model operations.
"""
model = Customer
@classmethod
def get_active(cls):
"""
Get all active customers.
Returns:
QuerySet of active customers
"""
return Customer.objects.filter(
Q(end_date__isnull=True) | Q(end_date__gt=timezone.now().date())
)
@classmethod
def get_inactive(cls):
"""
Get all inactive customers.
Returns:
QuerySet of inactive customers
"""
return Customer.objects.filter(
end_date__isnull=False,
end_date__lte=timezone.now().date()
)
@classmethod
def mark_inactive(cls, customer_id: str) -> Optional[Customer]:
"""
Mark a customer as inactive.
Args:
customer_id: The customer ID
Returns:
The updated customer or None if not found
"""
return cls.update(customer_id, {'end_date': timezone.now().date()})
@classmethod
def search(cls, search_term: str, include_inactive: bool = False):
"""
Search customers by name or contact info.
Args:
search_term: The search term
include_inactive: Whether to include inactive customers
Returns:
QuerySet of matching customers
"""
queryset = super().search(
search_term,
[
'name',
'primary_contact_first_name',
'primary_contact_last_name',
'primary_contact_email',
'secondary_contact_first_name',
'secondary_contact_last_name',
'secondary_contact_email',
'billing_contact_first_name',
'billing_contact_last_name',
'billing_email'
]
)
if not include_inactive:
queryset = queryset.filter(
Q(end_date__isnull=True) | Q(end_date__gt=timezone.now().date())
)
return queryset
@classmethod
def get_by_email(cls, email: str) -> Optional[Customer]:
"""
Get a customer by email.
Args:
email: The email address
Returns:
The customer or None if not found
"""
return Customer.objects.filter(
Q(primary_contact_email=email) |
Q(secondary_contact_email=email) |
Q(billing_email=email)
).first()
@classmethod
def get_with_accounts(cls, customer_id: str) -> Optional[Customer]:
"""
Get a customer with prefetched accounts.
Args:
customer_id: The customer ID
Returns:
The customer with accounts or None if not found
"""
try:
return Customer.objects.prefetch_related('accounts').get(pk=customer_id)
except Customer.DoesNotExist:
return None
@classmethod
def get_with_projects(cls, customer_id: str) -> Optional[Customer]:
"""
Get a customer with prefetched projects.
Args:
customer_id: The customer ID
Returns:
The customer with projects or None if not found
"""
try:
return Customer.objects.prefetch_related('projects').get(pk=customer_id)
except Customer.DoesNotExist:
return None
@classmethod
def get_with_invoices(cls, customer_id: str) -> Optional[Customer]:
"""
Get a customer with prefetched invoices.
Args:
customer_id: The customer ID
Returns:
The customer with invoices or None if not found
"""
try:
return Customer.objects.prefetch_related('invoices').get(pk=customer_id)
except Customer.DoesNotExist:
return None
@classmethod
def filter_customers(cls, name=None, city=None, state=None, start_date=None, end_date=None) -> QuerySet[Customer]:
"""
Filter customers by multiple criteria.
"""
queryset = Customer.objects.all()
if name:
queryset = queryset.filter(name__icontains=name)
if city:
queryset = queryset.filter(billing_city__icontains=city)
if state:
queryset = queryset.filter(billing_state__iexact=state)
if start_date:
queryset = queryset.filter(start_date__gte=start_date)
if end_date:
queryset = queryset.filter(end_date__lte=end_date)
return queryset

View File

@ -0,0 +1,302 @@
"""
Repository for Invoice model operations.
"""
from typing import List, Optional, Dict, Any
from datetime import date, timedelta
from django.db.models import QuerySet, Sum
from django.utils import timezone
from backend.core.models import Invoice, Account, Project
from backend.core.repositories.base import BaseRepository
class InvoiceRepository(BaseRepository[Invoice]):
"""
Repository for Invoice model operations.
"""
model = Invoice
@classmethod
def get_by_customer(cls, customer_id: str) -> QuerySet[Invoice]:
"""
Get invoices by customer.
Args:
customer_id: The customer ID
Returns:
QuerySet of invoices for the customer
"""
return Invoice.objects.filter(customer_id=customer_id)
@classmethod
def get_by_status(cls, status: str) -> QuerySet[Invoice]:
"""
Get invoices by status.
Args:
status: The invoice status
Returns:
QuerySet of invoices with the specified status
"""
return Invoice.objects.filter(status=status)
@classmethod
def get_by_date_range(cls, start_date: date = None, end_date: date = None) -> QuerySet[Invoice]:
"""
Get invoices within a date range.
Args:
start_date: Start date (inclusive)
end_date: End date (inclusive)
Returns:
QuerySet of invoices within the date range
"""
return cls.filter_by_date_range(start_date, end_date)
@classmethod
def get_overdue(cls) -> QuerySet[Invoice]:
"""
Get overdue invoices.
Returns:
QuerySet of overdue invoices
"""
thirty_days_ago = timezone.now().date() - timedelta(days=30)
return Invoice.objects.filter(
status__in=['sent', 'overdue'], # Include both sent and already marked overdue
sent_at__isnull=False, # Must have been sent
).exclude(
status__in=['paid', 'cancelled'] # Not paid or cancelled
).filter(
sent_at__date__lt=thirty_days_ago # Sent more than 30 days ago
)
@classmethod
def get_unpaid(cls) -> QuerySet[Invoice]:
"""
Get unpaid invoices.
Returns:
QuerySet of unpaid invoices (sent but not paid)
"""
return Invoice.objects.filter(status='sent')
@classmethod
def search(cls, search_term: str, search_fields: List[str] = None) -> QuerySet[Invoice]:
"""
Search invoices by customer.
Args:
search_term: The search term
search_fields: Optional list of fields to search (ignored, using predefined fields)
Returns:
QuerySet of matching invoices
"""
return super().search(
search_term,
['customer__name']
)
@classmethod
def filter_invoices(
cls,
customer_id: str = None,
status: str = None,
date_from: date = None,
date_to: date = None,
account_id: str = None,
project_id: str = None
) -> QuerySet[Invoice]:
"""
Filter invoices by multiple criteria.
Args:
customer_id: Filter by customer ID
status: Filter by status
date_from: Filter by start date (inclusive)
date_to: Filter by end date (inclusive)
account_id: Filter by account ID
project_id: Filter by project ID
Returns:
QuerySet of matching invoices
"""
queryset = Invoice.objects.all()
if customer_id:
queryset = queryset.filter(customer_id=customer_id)
if status:
queryset = queryset.filter(status=status)
if date_from:
queryset = queryset.filter(date__gte=date_from)
if date_to:
queryset = queryset.filter(date__lte=date_to)
if account_id:
queryset = queryset.filter(accounts__id=account_id)
if project_id:
queryset = queryset.filter(projects__id=project_id)
return queryset
@classmethod
def create_with_items(
cls,
data: Dict[str, Any],
account_ids: List[str] = None,
project_ids: List[str] = None
) -> Invoice:
"""
Create an invoice with related items.
Args:
data: Invoice data
account_ids: List of account IDs
project_ids: List of project IDs
Returns:
The created invoice
"""
# Create the invoice
invoice = cls.create(data)
# Add accounts
if account_ids:
accounts = Account.objects.filter(id__in=account_ids)
invoice.accounts.set(accounts)
# Add projects
if project_ids:
projects = Project.objects.filter(id__in=project_ids)
invoice.projects.set(projects)
return invoice
@classmethod
def update_status(cls, invoice_id: str, status: str) -> Optional[Invoice]:
"""
Update invoice status.
Args:
invoice_id: The invoice ID
status: The new status
Returns:
The updated invoice or None if not found
"""
invoice = cls.get_by_id(invoice_id)
if not invoice:
return None
invoice.status = status
# Set sent_at if status is 'sent'
if status == 'sent' and not invoice.sent_at:
invoice.sent_at = timezone.now()
# Set date_paid if status is 'paid'
if status == 'paid' and not invoice.date_paid:
invoice.date_paid = timezone.now().date()
invoice.save()
return invoice
@classmethod
def mark_as_paid(cls, invoice_id: str, payment_type: str) -> Optional[Invoice]:
"""
Mark an invoice as paid.
Args:
invoice_id: The invoice ID
payment_type: The payment type
Returns:
The updated invoice or None if not found
"""
invoice = cls.get_by_id(invoice_id)
if not invoice:
return None
invoice.status = 'paid'
invoice.date_paid = timezone.now().date()
invoice.payment_type = payment_type
invoice.save()
return invoice
@classmethod
def get_total_paid(cls, customer_id: str = None, date_from: date = None, date_to: date = None) -> float:
"""
Get total paid invoice amount.
Args:
customer_id: Filter by customer ID
date_from: Filter by start date (inclusive)
date_to: Filter by end date (inclusive)
Returns:
Total paid amount
"""
queryset = Invoice.objects.filter(status='paid')
if customer_id:
queryset = queryset.filter(customer_id=customer_id)
if date_from:
queryset = queryset.filter(date_paid__gte=date_from)
if date_to:
queryset = queryset.filter(date_paid__lte=date_to)
result = queryset.aggregate(total=Sum('total_amount'))
return float(result['total'] or 0)
@classmethod
def get_total_outstanding(cls, customer_id: str = None) -> float:
"""
Get total outstanding invoice amount.
Args:
customer_id: Filter by customer ID
Returns:
Total outstanding amount
"""
queryset = Invoice.objects.filter(status__in=['sent', 'overdue'])
if customer_id:
queryset = queryset.filter(customer_id=customer_id)
result = queryset.aggregate(total=Sum('total_amount'))
return float(result['total'] or 0)
@classmethod
def mark_overdue(cls, invoice_id: str = None) -> int:
"""
Mark invoice(s) as overdue.
Args:
invoice_id: Optional invoice ID. If not provided, all overdue invoices will be marked.
Returns:
Number of invoices marked as overdue
"""
thirty_days_ago = timezone.now().date() - timedelta(days=30)
query = Invoice.objects.filter(
status='sent',
sent_at__isnull=False,
sent_at__date__lt=thirty_days_ago
)
if invoice_id:
query = query.filter(id=invoice_id)
count = query.update(status='overdue')
return count

View File

@ -0,0 +1,183 @@
"""
Repository for Labor model operations.
"""
from typing import Optional, List
from datetime import date
from django.db.models import QuerySet, Q
from django.utils import timezone
from backend.core.models import Labor
from backend.core.repositories.base import BaseRepository
class LaborRepository(BaseRepository[Labor]):
"""
Repository for Labor model operations.
"""
model = Labor
@classmethod
def get_by_account(cls, account_id: str) -> QuerySet[Labor]:
"""
Get labors by account.
Args:
account_id: The account ID
Returns:
QuerySet of labors for the account
"""
return Labor.objects.filter(account_id=account_id)
@classmethod
def get_active(cls) -> QuerySet[Labor]:
"""
Get active labors.
Returns:
QuerySet of active labors
"""
current_date = timezone.now().date()
return Labor.objects.filter(
start_date__lte=current_date
).filter(
Q(end_date__isnull=True) | Q(end_date__gte=current_date)
)
@classmethod
def get_active_by_account(cls, account_id: str) -> Optional[Labor]:
"""
Get active labor for an account.
Args:
account_id: The account ID
Returns:
Active labor for the account or None if not found
"""
current_date = timezone.now().date()
return Labor.objects.filter(
account_id=account_id,
start_date__lte=current_date
).filter(
Q(end_date__isnull=True) | Q(end_date__gte=current_date)
).first()
@classmethod
def end_labor(cls, labor_id: str) -> Optional[Labor]:
"""
End a labor record by setting its end date to today.
Args:
labor_id: The labor ID
Returns:
The updated labor or None if not found
"""
return cls.update(labor_id, {'end_date': timezone.now().date()})
@classmethod
def get_by_date_range(cls, start_date: date = None, end_date: date = None) -> QuerySet[Labor]:
"""
Get labors that were active during a date range.
Args:
start_date: Start date (inclusive)
end_date: End date (inclusive)
Returns:
QuerySet of labors active during the date range
"""
queryset = Labor.objects.all()
if start_date:
# Exclude labors that ended before the start date
queryset = queryset.exclude(
end_date__isnull=False,
end_date__lt=start_date
)
if end_date:
# Exclude labors that started after the end date
queryset = queryset.exclude(start_date__gt=end_date)
return queryset
@classmethod
def search(cls, search_term: str, search_fields: List[str] = None) -> QuerySet[Labor]:
"""
Search labor records.
Args:
search_term: The search term
search_fields: Optional list of fields to search (ignored, using predefined fields)
Returns:
QuerySet of matching labor records
"""
return super().search(
search_term,
['account__name']
)
@classmethod
def get_total_labor_cost(cls, account_id: str = None, start_date: date = None, end_date: date = None) -> float:
"""
Get total labor cost for an account or all accounts within a date range.
Args:
account_id: Optional account ID to filter by
start_date: Optional start date for the period
end_date: Optional end date for the period
Returns:
Total labor cost
"""
labors = cls.get_by_date_range(start_date, end_date)
if account_id:
labors = labors.filter(account_id=account_id)
# Calculate proportional costs for labors that span beyond the range
total_cost = 0
for labor in labors:
# Get the effective start and end dates for the calculation
# (intersection of labor period and requested period)
effective_start = max(labor.start_date, start_date) if start_date else labor.start_date
current_date = timezone.now().date()
if labor.end_date:
effective_end = min(labor.end_date, end_date) if end_date else labor.end_date
else:
effective_end = end_date if end_date else current_date
# Calculate days in range
days_in_range = (effective_end - effective_start).days + 1
# Calculate total days for labor period
if labor.end_date:
total_days = (labor.end_date - labor.start_date).days + 1
else:
total_days = (current_date - labor.start_date).days + 1
# Avoid division by zero
if total_days <= 0:
total_days = 1
# Calculate proportional cost
proportional_cost = labor.amount * (days_in_range / total_days)
total_cost += proportional_cost
return float(total_cost)
@classmethod
def get_inactive(cls) -> QuerySet[Labor]:
"""
Get inactive labors.
Returns:
QuerySet of inactive labors
"""
current_date = timezone.now().date()
return Labor.objects.filter(end_date__lt=current_date)

View File

@ -0,0 +1,103 @@
"""
Repository for Profile model operations.
"""
from typing import Optional, List
from django.contrib.auth.models import User
from django.db.models import QuerySet
from backend.core.models import Profile
from backend.core.repositories.base import BaseRepository
class ProfileRepository(BaseRepository[Profile]):
"""
Repository for Profile model operations.
"""
model = Profile
@classmethod
def get_by_user(cls, user: User) -> Optional[Profile]:
"""
Get a profile by user.
Args:
user: The user
Returns:
The profile or None if not found
"""
try:
return Profile.objects.get(user=user)
except Profile.DoesNotExist:
return None
@classmethod
def get_by_email(cls, email: str) -> Optional[Profile]:
"""
Get a profile by email.
Args:
email: The email address
Returns:
The profile or None if not found
"""
return Profile.objects.filter(email=email).first()
@classmethod
def get_by_role(cls, role: str) -> QuerySet[Profile]:
"""
Get profiles by role.
Args:
role: The role
Returns:
List of profiles with the specified role
"""
return Profile.objects.filter(role=role)
@classmethod
def get_admins(cls) -> QuerySet[Profile]:
"""
Get all admin profiles
Returns:
List of admin profiles
"""
return cls.get_by_role('admin')
@classmethod
def get_team_leaders(cls) -> QuerySet[Profile]:
"""
Get all team leader profiles
Returns:
List of team leader profiles
"""
return cls.get_by_role('team_leader')
@classmethod
def get_team_members(cls) -> QuerySet[Profile]:
"""
Get all team member profiles
Returns:
List of team member profiles
"""
return cls.get_by_role('team_member')
@classmethod
def search(cls, search_term: str, search_fields: List[str] = None) -> QuerySet[Profile]:
"""
Search profiles by name or email.
Args:
search_term: The search term
search_fields: List of fields to search in (optional, default fields will be used if not provided)
Returns:
QuerySet of matching profiles
"""
fields = search_fields or ['first_name', 'last_name', 'email', 'user__username', 'user__email']
return super().search(search_term, fields)

View File

@ -0,0 +1,266 @@
"""
Repository for Project model operations.
"""
from typing import List, Optional, Dict, Any
from datetime import date
from django.db.models import QuerySet
from django.utils import timezone
from backend.core.models import Project, Profile
from backend.core.repositories.base import BaseRepository
class ProjectRepository(BaseRepository[Project]):
"""
Repository for Project model operations.
"""
model = Project
@classmethod
def get_by_customer(cls, customer_id: str) -> QuerySet[Project]:
"""
Get projects by customer.
Args:
customer_id: The customer ID
Returns:
QuerySet of projects for the customer
"""
return Project.objects.filter(customer_id=customer_id)
@classmethod
def get_by_account(cls, account_id: str) -> QuerySet[Project]:
"""
Get projects by account.
Args:
account_id: The account ID
Returns:
QuerySet of projects for the account
"""
return Project.objects.filter(account_id=account_id)
@classmethod
def get_by_team_member(cls, profile_id: str) -> QuerySet[Project]:
"""
Get projects by team member.
Args:
profile_id: The profile ID
Returns:
QuerySet of projects assigned to the team member
"""
return Project.objects.filter(team_members__id=profile_id)
@classmethod
def get_by_status(cls, status: str) -> QuerySet[Project]:
"""
Get projects by status.
Args:
status: The project status
Returns:
QuerySet of projects with the specified status
"""
return Project.objects.filter(status=status)
@classmethod
def get_by_date_range(cls, start_date: date = None, end_date: date = None) -> QuerySet[Project]:
"""
Get projects within a date range.
Args:
start_date: Start date (inclusive)
end_date: End date (inclusive)
Returns:
QuerySet of projects within the date range
"""
return cls.filter_by_date_range(start_date, end_date)
@classmethod
def get_upcoming(cls) -> QuerySet[Project]:
"""
Get upcoming projects.
Returns:
QuerySet of upcoming projects
"""
return Project.objects.filter(date__gte=timezone.now().date())
@classmethod
def get_past_due(cls) -> QuerySet[Project]:
"""
Get past due projects.
Returns:
QuerySet of past due projects
"""
return Project.objects.filter(
date__lt=timezone.now().date(),
status__in=['planned', 'in_progress']
)
@classmethod
def search(cls, search_term: str, search_fields: List[str] = None) -> QuerySet[Project]:
"""
Search projects by customer, account, or notes.
Args:
search_term: The search term
search_fields: Optional list of fields to search (ignored, using predefined fields)
Returns:
QuerySet of matching projects
"""
return super().search(
search_term,
['customer__name', 'account__name', 'notes']
)
@classmethod
def filter_projects(
cls,
customer_id: str = None,
account_id: str = None,
status: str = None,
date_from: date = None,
date_to: date = None,
team_member_id: str = None
) -> QuerySet[Project]:
"""
Filter projects by multiple criteria.
Args:
customer_id: Filter by customer ID
account_id: Filter by account ID
status: Filter by status
date_from: Filter by start date (inclusive)
date_to: Filter by end date (inclusive)
team_member_id: Filter by team member ID
Returns:
QuerySet of matching projects
"""
queryset = Project.objects.all()
if customer_id:
queryset = queryset.filter(customer_id=customer_id)
if account_id:
queryset = queryset.filter(account_id=account_id)
if status:
queryset = queryset.filter(status=status)
if date_from:
queryset = queryset.filter(date__gte=date_from)
if date_to:
queryset = queryset.filter(date__lte=date_to)
if team_member_id:
queryset = queryset.filter(team_members__id=team_member_id)
return queryset
@classmethod
def create_with_team_members(cls, data: Dict[str, Any], team_member_ids: List[str]) -> Project:
"""
Create a project with team members.
Args:
data: Project data
team_member_ids: List of team member IDs
Returns:
The created project
"""
# Create the project
project = cls.create(data)
# Add team members
if team_member_ids:
team_members = Profile.objects.filter(id__in=team_member_ids)
project.team_members.set(team_members)
return project
@classmethod
def update_status(cls, project_id: str, status: str) -> Optional[Project]:
"""
Update project status.
Args:
project_id: The project ID
status: The new status
Returns:
The updated project or None if not found
"""
project = cls.get_by_id(project_id)
if not project:
return None
project.status = status
# Set completed_at if status is 'completed'
if status == 'completed' and not project.completed_at:
project.completed_at = timezone.now()
project.save()
return project
@classmethod
def assign_team_members(cls, project_id: str, team_member_ids: List[str]) -> Optional[Project]:
"""
Assign team members to a project.
Args:
project_id: The project ID
team_member_ids: List of team member IDs
Returns:
The updated project or None if not found
"""
project = cls.get_by_id(project_id)
if not project:
return None
team_members = Profile.objects.filter(id__in=team_member_ids)
project.team_members.set(team_members)
return project
@classmethod
def get_without_invoice(cls) -> QuerySet[Project]:
"""
Get projects that have not been invoiced.
Returns:
QuerySet of projects without invoices
"""
return Project.objects.filter(
status='completed',
invoices__isnull=True
)
@classmethod
def calculate_profit(cls, project_id: str) -> Optional[float]:
"""
Calculate profit for a project.
Args:
project_id: The project ID
Returns:
Project profit or None if not found
"""
project = cls.get_by_id(project_id)
if not project:
return None
return float(project.amount - project.labor)

View File

@ -0,0 +1,3 @@
"""
Punchlist repositories for managing punchlist data.
"""

View File

@ -0,0 +1,98 @@
"""
Repository for Punchlist model.
"""
from typing import Dict, Any
from datetime import date
from django.db.models import QuerySet
from django.utils import timezone
from backend.core.models import Punchlist, Project, Account
from backend.core.repositories.base import BaseRepository
class PunchlistRepository(BaseRepository[Punchlist]):
"""Repository for Punchlist model"""
model = Punchlist
@classmethod
def get_by_project(cls, project_id: str) -> QuerySet:
"""
Get punchlists by project ID
"""
return cls.model.objects.filter(project_id=project_id)
@classmethod
def get_by_account(cls, account_id: str) -> QuerySet:
"""
Get punchlists by account ID
"""
return cls.model.objects.filter(account_id=account_id)
@classmethod
def get_by_date_range(cls, start_date: date = None, end_date: date = None) -> QuerySet:
"""
Get punchlists by date range
"""
queryset = cls.model.objects.all()
if start_date:
queryset = queryset.filter(date__gte=start_date)
if end_date:
queryset = queryset.filter(date__lte=end_date)
return queryset
@classmethod
def filter_punchlists(cls, project_id: str = None, account_id: str = None,
date_from: date = None, date_to: date = None) -> QuerySet:
"""
Filter punchlists by multiple criteria
"""
queryset = cls.model.objects.all()
if project_id:
queryset = queryset.filter(project_id=project_id)
if account_id:
queryset = queryset.filter(account_id=account_id)
if date_from:
queryset = queryset.filter(date__gte=date_from)
if date_to:
queryset = queryset.filter(date__lte=date_to)
return queryset
@classmethod
def create_punchlist(cls, data: Dict[str, Any]) -> Punchlist:
"""
Create a punchlist for an account.
Add any custom validation logic here.
"""
return cls.create(data)
@classmethod
def update_punchlist(cls, punchlist_id: str, data: Dict[str, Any]) -> Punchlist:
"""
Update a punchlist
"""
return cls.update(punchlist_id, data)
@classmethod
def mark_exported(cls, punchlist_id: str, sheet_url: str = None, pdf_url: str = None) -> Punchlist:
"""
Mark a punchlist as exported
"""
punchlist = cls.get_by_id(punchlist_id)
punchlist.exported_at = timezone.now()
if sheet_url:
punchlist.sheet_url = sheet_url
if pdf_url:
punchlist.pdf_url = pdf_url
punchlist.save()
return punchlist

View File

@ -0,0 +1,235 @@
"""
Repository for Report model operations.
"""
from typing import List, Dict, Any, Optional
from datetime import date
from django.db.models import QuerySet
from django.utils import timezone
from backend.core.models import Report, Service, Project
from backend.core.repositories.base import BaseRepository
class ReportRepository(BaseRepository[Report]):
"""
Repository for Report model operations.
"""
model = Report
@classmethod
def get_by_team_member(cls, profile_id: str) -> QuerySet[Report]:
"""
Get reports by team member.
Args:
profile_id: The profile ID
Returns:
QuerySet of reports by the team member
"""
return Report.objects.filter(team_member_id=profile_id)
@classmethod
def get_by_date_range(cls, start_date: date = None, end_date: date = None) -> QuerySet[Report]:
"""
Get reports within a date range.
Args:
start_date: Start date (inclusive)
end_date: End date (inclusive)
Returns:
QuerySet of reports within the date range
"""
return cls.filter_by_date_range(start_date, end_date)
@classmethod
def create_with_items(
cls,
data: Dict[str, Any],
service_ids: List[str] = None,
project_ids: List[str] = None
) -> Report:
"""
Create a report with related items.
Args:
data: Report data
service_ids: List of service IDs
project_ids: List of project IDs
Returns:
The created report
"""
# Create the report
report = cls.create(data)
# Add services
if service_ids:
services = Service.objects.filter(id__in=service_ids)
report.services.set(services)
# Add projects
if project_ids:
projects = Project.objects.filter(id__in=project_ids)
report.projects.set(projects)
return report
@classmethod
def get_team_member_activity(
cls,
profile_id: str,
start_date: date = None,
end_date: date = None
) -> Dict[str, int]:
"""
Get activity summary for a team member.
Args:
profile_id: The profile ID
start_date: Start date (inclusive)
end_date: End date (inclusive)
Returns:
Dictionary with activity summary
"""
# Set default date range to current month if not provided
if not start_date:
today = timezone.now().date()
start_date = date(today.year, today.month, 1)
if not end_date:
end_date = timezone.now().date()
# Get services for the team member
services = Service.objects.filter(
team_members__id=profile_id,
date__gte=start_date,
date__lte=end_date
)
# Get projects for the team member
projects = Project.objects.filter(
team_members__id=profile_id,
date__gte=start_date,
date__lte=end_date
)
# Get reports for the team member
reports = Report.objects.filter(
team_member_id=profile_id,
date__gte=start_date,
date__lte=end_date
)
# Count by status
services_by_status = {
'scheduled': services.filter(status='scheduled').count(),
'in_progress': services.filter(status='in_progress').count(),
'completed': services.filter(status='completed').count(),
'cancelled': services.filter(status='cancelled').count()
}
projects_by_status = {
'planned': projects.filter(status='planned').count(),
'in_progress': projects.filter(status='in_progress').count(),
'completed': projects.filter(status='completed').count(),
'cancelled': projects.filter(status='cancelled').count()
}
return {
'total_services': services.count(),
'total_projects': projects.count(),
'total_reports': reports.count(),
'services_by_status': services_by_status,
'projects_by_status': projects_by_status
}
@classmethod
def search(cls, search_term: str, search_fields: List[str] = None) -> QuerySet[Report]:
"""
Search reports.
Args:
search_term: The search term
search_fields: Optional list of fields to search (ignored, using predefined fields)
Returns:
QuerySet of matching reports
"""
return super().search(
search_term,
[
'team_member__first_name',
'team_member__last_name',
'team_member__email',
'notes'
]
)
@classmethod
def get_with_all_related(cls, report_id: str) -> Optional[Report]:
"""
Get a report with all related data prefetched.
Args:
report_id: The report ID
Returns:
The report with all related data or None if not found
"""
try:
return Report.objects.prefetch_related(
'services', 'projects'
).select_related('team_member').get(pk=report_id)
except Report.DoesNotExist:
return None
@classmethod
def get_team_summary(cls, start_date: date = None, end_date: date = None) -> Dict[str, Any]:
"""
Get activity summary for all team members.
Args:
start_date: Start date (inclusive)
end_date: End date (inclusive)
Returns:
Dictionary with team activity summary
"""
# Set default date range to current month if not provided
if not start_date:
today = timezone.now().date()
start_date = date(today.year, today.month, 1)
if not end_date:
end_date = timezone.now().date()
# Get all team members with reports in the date range
reports = Report.objects.filter(
date__gte=start_date,
date__lte=end_date
).select_related('team_member')
# Summarize by team member
summary_by_member = {}
for report in reports:
member_id = str(report.team_member.id)
member_name = f"{report.team_member.first_name} {report.team_member.last_name}"
if member_id not in summary_by_member:
summary_by_member[member_id] = {
'name': member_name,
'report_count': 0,
'service_count': 0,
'project_count': 0
}
summary_by_member[member_id]['report_count'] += 1
summary_by_member[member_id]['service_count'] += report.service_count
summary_by_member[member_id]['project_count'] += report.project_count
return {
'total_reports': reports.count(),
'member_summaries': summary_by_member
}

View File

@ -0,0 +1,183 @@
"""
Repository for Revenue model operations.
"""
from typing import Optional, List
from datetime import date
from django.db.models import QuerySet, Q
from django.utils import timezone
from backend.core.models import Revenue
from backend.core.repositories.base import BaseRepository
class RevenueRepository(BaseRepository[Revenue]):
"""
Repository for Revenue model operations.
"""
model = Revenue
@classmethod
def get_by_account(cls, account_id: str) -> QuerySet[Revenue]:
"""
Get revenues by account.
Args:
account_id: The account ID
Returns:
QuerySet of revenues for the account
"""
return Revenue.objects.filter(account_id=account_id)
@classmethod
def get_active(cls) -> QuerySet[Revenue]:
"""
Get active revenues.
Returns:
QuerySet of active revenues
"""
current_date = timezone.now().date()
return Revenue.objects.filter(
start_date__lte=current_date
).filter(
Q(end_date__isnull=True) | Q(end_date__gte=current_date)
)
@classmethod
def get_active_by_account(cls, account_id: str) -> Optional[Revenue]:
"""
Get active revenue for an account.
Args:
account_id: The account ID
Returns:
Active revenue for the account or None if not found
"""
current_date = timezone.now().date()
return Revenue.objects.filter(
account_id=account_id,
start_date__lte=current_date
).filter(
Q(end_date__isnull=True) | Q(end_date__gte=current_date)
).first()
@classmethod
def end_revenue(cls, revenue_id: str) -> Optional[Revenue]:
"""
End a revenue record by setting its end date to today.
Args:
revenue_id: The revenue ID
Returns:
The updated revenue or None if not found
"""
return cls.update(revenue_id, {'end_date': timezone.now().date()})
@classmethod
def get_by_date_range(cls, start_date: date = None, end_date: date = None) -> QuerySet[Revenue]:
"""
Get revenues that were active during a date range.
Args:
start_date: Start date (inclusive)
end_date: End date (inclusive)
Returns:
QuerySet of revenues active during the date range
"""
queryset = Revenue.objects.all()
if start_date:
# Exclude revenues that ended before the start date
queryset = queryset.exclude(
end_date__isnull=False,
end_date__lt=start_date
)
if end_date:
# Exclude revenues that started after the end date
queryset = queryset.exclude(start_date__gt=end_date)
return queryset
@classmethod
def search(cls, search_term: str, search_fields: List[str] = None) -> QuerySet[Revenue]:
"""
Search revenue records.
Args:
search_term: The search term
search_fields: Optional list of fields to search (ignored, using predefined fields)
Returns:
QuerySet of matching revenue records
"""
return super().search(
search_term,
['account__name']
)
@classmethod
def get_total_revenue(cls, account_id: str = None, start_date: date = None, end_date: date = None) -> float:
"""
Get total revenue for an account or all accounts within a date range.
Args:
account_id: Optional account ID to filter by
start_date: Optional start date for the period
end_date: Optional end date for the period
Returns:
Total revenue
"""
revenues = cls.get_by_date_range(start_date, end_date)
if account_id:
revenues = revenues.filter(account_id=account_id)
# Calculate proportional revenue for records that span beyond the range
total_revenue = 0
for revenue in revenues:
# Get the effective start and end dates for the calculation
# (intersection of revenue period and requested period)
effective_start = max(revenue.start_date, start_date) if start_date else revenue.start_date
current_date = timezone.now().date()
if revenue.end_date:
effective_end = min(revenue.end_date, end_date) if end_date else revenue.end_date
else:
effective_end = end_date if end_date else current_date
# Calculate days in range
days_in_range = (effective_end - effective_start).days + 1
# Calculate total days for revenue period
if revenue.end_date:
total_days = (revenue.end_date - revenue.start_date).days + 1
else:
total_days = (current_date - revenue.start_date).days + 1
# Avoid division by zero
if total_days <= 0:
total_days = 1
# Calculate proportional revenue
proportional_revenue = revenue.amount * (days_in_range / total_days)
total_revenue += proportional_revenue
return float(total_revenue)
@classmethod
def get_inactive(cls) -> QuerySet[Revenue]:
"""
Get inactive revenues.
Returns:
QuerySet of inactive revenues
"""
current_date = timezone.now().date()
return Revenue.objects.filter(end_date__lt=current_date)

View File

@ -0,0 +1,174 @@
"""
Repository for Schedule model operations.
"""
from datetime import datetime, date, timedelta
from typing import Optional, List
from django.db.models import QuerySet, Q
from django.utils import timezone
from backend.core.models import Schedule, Service
from backend.core.repositories.base import BaseRepository
class ScheduleRepository(BaseRepository[Schedule]):
"""
Repository for Schedule model operations.
"""
model = Schedule
@classmethod
def get_by_account(cls, account_id: str) -> QuerySet[Schedule]:
"""
Get schedules.py by account.
Args:
account_id: The account ID
Returns:
QuerySet of schedules.py for the account
"""
return Schedule.objects.filter(account_id=account_id)
@classmethod
def get_active(cls) -> QuerySet[Schedule]:
"""
Get active schedules.py.
Returns:
QuerySet of active schedules.py
"""
current_date = timezone.now().date()
return Schedule.objects.filter(
start_date__lte=current_date
).filter(
Q(end_date__isnull=True) | Q(end_date__gte=current_date) # Use Q directly
)
@classmethod
def get_active_by_account(cls, account_id: str) -> Optional[Schedule]:
"""
Get active schedule for an account.
Args:
account_id: The account ID
Returns:
Active schedule for the account or None if not found
"""
current_date = timezone.now().date()
return Schedule.objects.filter(
account_id=account_id,
start_date__lte=current_date
).filter(
Q(end_date__isnull=True) | Q(end_date__gte=current_date) # Use Q directly
).first()
@classmethod
def search(cls, search_term: str, search_fields: List[str] = None) -> QuerySet[Schedule]:
"""
Search schedules
Args:
search_term: The search term
search_fields: Optional list of fields to search (ignored, using predefined fields)
Returns:
QuerySet of matching schedules
"""
return super().search(
search_term,
[
'account__name',
'account__customer__name'
]
)
@classmethod
def generate_services(cls, schedule_id: str, start_date: date, end_date: date) -> List[Service]:
"""
Generate services based on a schedule for a date range.
Args:
schedule_id: The schedule ID
start_date: Start date (inclusive)
end_date: End date (inclusive)
Returns:
List of generated services
"""
schedule = cls.get_by_id(schedule_id)
if not schedule:
return []
days_map = {
0: schedule.monday_service, # Monday is 0
1: schedule.tuesday_service, # Tuesday is 1
2: schedule.wednesday_service, # Wednesday is 2
3: schedule.thursday_service, # Thursday is 3
4: schedule.friday_service, # Friday is 4
5: schedule.saturday_service, # Saturday is 5
6: schedule.sunday_service # Sunday is 6
}
services = []
current_date = start_date
# Iterate through each day in the date range
while current_date <= end_date:
weekday = current_date.weekday()
# Check if service is scheduled for this day
if days_map[weekday] or (schedule.weekend_service and weekday >= 5):
# Create a service
service_data = {
'account': schedule.account, # Use the account object directly
'date': current_date,
'status': 'scheduled',
'deadline_start': datetime.combine(
current_date,
datetime.min.time()
).replace(hour=9), # Default to 9:00 AM
'deadline_end': datetime.combine(
current_date,
datetime.min.time()
).replace(hour=17) # Default to 5:00 PM
}
service = Service.objects.create(**service_data)
services.append(service)
current_date += timedelta(days=1)
return services
@classmethod
def get_inactive(cls) -> QuerySet[Schedule]:
"""
Get inactive schedules.
Returns:
QuerySet of inactive schedules
"""
current_date = timezone.now().date()
return Schedule.objects.filter(end_date__lt=current_date)
@classmethod
def get_by_date_range(cls, start_date: date = None, end_date: date = None) -> QuerySet[Schedule]:
"""
Get schedules within a date range.
Args:
start_date: Start date (inclusive)
end_date: End date (inclusive)
Returns:
QuerySet of schedules within the date range
"""
queryset = Schedule.objects.all()
if start_date:
queryset = queryset.filter(start_date__gte=start_date)
if end_date:
queryset = queryset.filter(end_date__lte=end_date)
return queryset

View File

@ -0,0 +1,228 @@
"""
Repository for Service model operations.
"""
from typing import List, Optional, Dict, Any
from datetime import date
from django.db.models import QuerySet
from django.utils import timezone
from backend.core.models import Service, Profile
from backend.core.repositories.base import BaseRepository
class ServiceRepository(BaseRepository[Service]):
"""
Repository for Service model operations.
"""
model = Service
@classmethod
def get_by_account(cls, account_id: str) -> QuerySet[Service]:
"""
Get services by account.
Args:
account_id: The account ID
Returns:
QuerySet of services for the account
"""
return Service.objects.filter(account_id=account_id)
@classmethod
def get_by_team_member(cls, profile_id: str) -> QuerySet[Service]:
"""
Get services by team member.
Args:
profile_id: The profile ID
Returns:
QuerySet of services assigned to the team member
"""
return Service.objects.filter(team_members__id=profile_id)
@classmethod
def get_by_status(cls, status: str) -> QuerySet[Service]:
"""
Get services by status.
Args:
status: The service status
Returns:
QuerySet of services with the specified status
"""
return Service.objects.filter(status=status)
@classmethod
def get_by_date_range(cls, start_date: date = None, end_date: date = None) -> QuerySet[Service]:
"""
Get services within a date range.
Args:
start_date: Start date (inclusive)
end_date: End date (inclusive)
Returns:
QuerySet of services within the date range
"""
return cls.filter_by_date_range(start_date, end_date)
@classmethod
def get_upcoming(cls) -> QuerySet[Service]:
"""
Get upcoming services.
Returns:
QuerySet of upcoming services
"""
return Service.objects.filter(date__gte=timezone.now().date())
@classmethod
def get_past_due(cls) -> QuerySet[Service]:
"""
Get past due services.
Returns:
QuerySet of past due services
"""
return Service.objects.filter(
date__lt=timezone.now().date(),
status__in=['scheduled', 'in_progress']
)
@classmethod
def get_for_today(cls) -> QuerySet[Service]:
"""
Get services scheduled for today.
Returns:
QuerySet of services scheduled for today
"""
return Service.objects.filter(date=timezone.now().date())
@classmethod
def search(cls, search_term: str, search_fields: List[str] = None) -> QuerySet[Service]:
"""
Search services by account or notes.
Args:
search_term: The search term
search_fields: Optional list of fields to search (ignored, using predefined fields)
Returns:
QuerySet of matching services
"""
return super().search(
search_term,
['account__name', 'notes']
)
@classmethod
def filter_services(
cls,
account_id: str = None,
status: str = None,
date_from: date = None,
date_to: date = None,
team_member_id: str = None
) -> QuerySet[Service]:
"""
Filter services by multiple criteria.
Args:
account_id: Filter by account ID
status: Filter by status
date_from: Filter by start date (inclusive)
date_to: Filter by end date (inclusive)
team_member_id: Filter by team member ID
Returns:
QuerySet of matching services
"""
queryset = Service.objects.all()
if account_id:
queryset = queryset.filter(account_id=account_id)
if status:
queryset = queryset.filter(status=status)
if date_from:
queryset = queryset.filter(date__gte=date_from)
if date_to:
queryset = queryset.filter(date__lte=date_to)
if team_member_id:
queryset = queryset.filter(team_members__id=team_member_id)
return queryset
@classmethod
def create_with_team_members(cls, data: Dict[str, Any], team_member_ids: List[str]) -> Service:
"""
Create a service with team members.
Args:
data: Service data
team_member_ids: List of team member IDs
Returns:
The created service
"""
# Create the service
service = cls.create(data)
# Add team members
if team_member_ids:
team_members = Profile.objects.filter(id__in=team_member_ids)
service.team_members.set(team_members)
return service
@classmethod
def update_status(cls, service_id: str, status: str) -> Optional[Service]:
"""
Update service status.
Args:
service_id: The service ID
status: The new status
Returns:
The updated service or None if not found
"""
service = cls.get_by_id(service_id)
if not service:
return None
service.status = status
# Set completed_at if status is 'completed'
if status == 'completed' and not service.completed_at:
service.completed_at = timezone.now()
service.save()
return service
@classmethod
def assign_team_members(cls, service_id: str, team_member_ids: List[str]) -> Optional[Service]:
"""
Assign team members to a service.
Args:
service_id: The service ID
team_member_ids: List of team member IDs
Returns:
The updated service or None if not found
"""
service = cls.get_by_id(service_id)
if not service:
return None
team_members = Profile.objects.filter(id__in=team_member_ids)
service.team_members.set(team_members)
return service

View File

@ -0,0 +1,16 @@
"""
Service layer for business logic.
"""
from backend.core.services.auth import AuthService
from backend.core.services.billing import BillingService
from backend.core.services.scheduling import SchedulingService
from backend.core.services.reporting import ReportingService
from backend.core.services.notifications import NotificationService
__all__ = [
'AuthService',
'BillingService',
'SchedulingService',
'ReportingService',
'NotificationService'
]

View File

@ -0,0 +1,98 @@
"""
Authentication and authorization services.
"""
from typing import Optional, Dict, Any
from django.contrib.auth.models import User
from django.contrib.auth import authenticate
from django.utils import timezone
from backend.core.models import Profile
from backend.core.repositories.profiles.profiles import ProfileRepository
class AuthService:
"""
Service for authentication and authorization operations.
"""
@staticmethod
def authenticate_user(username: str, password: str) -> Optional[User]:
"""
Authenticate a user with username and password.
Args:
username: Username
password: Password
Returns:
User object if authenticated, None otherwise
"""
return authenticate(username=username, password=password)
@staticmethod
def get_user_profile(user_id: int) -> Optional[Profile]:
"""
Get a user's profile.
Args:
user_id: User ID
Returns:
Profile object or None if not found
"""
try:
user = User.objects.get(id=user_id)
return ProfileRepository.get_by_user(user)
except User.DoesNotExist:
return None
@staticmethod
def check_permission(user: User, permission: str) -> bool:
"""
Check if a user has a specific permission.
Args:
user: User object
permission: Permission string (e.g., 'core.add_customer')
Returns:
True if user has permission, False otherwise
"""
return user.has_perm(permission)
@staticmethod
def log_login(user: User) -> None:
"""
Log a user login.
Args:
user: User object
"""
user.last_login = timezone.now()
user.save(update_fields=['last_login'])
@staticmethod
def create_user(user_data: Dict[str, Any], profile_data: Dict[str, Any]) -> User:
"""
Create a new user with profile.
Args:
user_data: User data (username, email, password, etc.)
profile_data: Profile data
Returns:
Created User object
"""
# Create the user
user = User.objects.create_user(
username=user_data['username'],
email=user_data.get('email', ''),
password=user_data['password'],
first_name=user_data.get('first_name', ''),
last_name=user_data.get('last_name', '')
)
# Create the profile
profile_data['user'] = user
ProfileRepository.create(profile_data)
return user

View File

@ -0,0 +1,145 @@
"""
Billing and invoice services.
"""
from typing import List, Optional
from datetime import date, timedelta
from django.utils import timezone
from django.db import transaction
from backend.core.models import Account, Project, Invoice
from backend.core.repositories.customers.customers import CustomerRepository
from backend.core.repositories.accounts.accounts import AccountRepository
from backend.core.repositories.projects.projects import ProjectRepository
from backend.core.repositories.invoices.invoices import InvoiceRepository
from backend.core.repositories.revenues.revenues import RevenueRepository
class BillingService:
"""
Service for billing and invoice operations.
"""
@staticmethod
@transaction.atomic
def generate_invoice_for_customer(
customer_id: str,
invoice_date: date = None,
include_accounts: bool = True,
include_projects: bool = True,
account_ids: List[str] = None,
project_ids: List[str] = None
) -> Optional[Invoice]:
"""
Generate an invoice for a customer.
Args:
customer_id: Customer ID
invoice_date: Invoice date (defaults to today)
include_accounts: Whether to include all accounts
include_projects: Whether to include all completed projects
account_ids: Specific account IDs to include (if not including all)
project_ids: Specific project IDs to include (if not including all)
Returns:
Generated invoice or None if no items to invoice
"""
# Get the customer
customer = CustomerRepository.get_by_id(customer_id)
if not customer:
return None
# Use today's date if not specified
if not invoice_date:
invoice_date = timezone.now().date()
# Get accounts to invoice
if include_accounts:
# Get all active accounts
accounts_to_invoice = AccountRepository.get_active_by_customer(customer_id)
elif account_ids:
# Get specific accounts
accounts_to_invoice = Account.objects.filter(id__in=account_ids)
else:
accounts_to_invoice = Account.objects.none()
# Get projects to invoice
if include_projects:
# Get all completed projects without invoices
projects_to_invoice = ProjectRepository.get_without_invoice()
elif project_ids:
# Get specific projects
projects_to_invoice = Project.objects.filter(id__in=project_ids)
else:
projects_to_invoice = Project.objects.none()
# Calculate total amount
total_amount = 0
# Add account revenue
for account in accounts_to_invoice:
active_revenue = RevenueRepository.get_active_by_account(account.id)
if active_revenue:
# For monthly billing, divide annual amount by 12 or use the full amount
total_amount += active_revenue.amount
# Add project amounts
for project in projects_to_invoice:
total_amount += project.amount
# Don't create an invoice if there's nothing to invoice
if total_amount <= 0:
return None
# Create the invoice
invoice_data = {
'customer': customer_id,
'date': invoice_date,
'status': 'draft',
'total_amount': total_amount
}
# Convert querysets to list of IDs
account_ids_list = [str(account.id) for account in accounts_to_invoice]
project_ids_list = [str(project.id) for project in projects_to_invoice]
# Create the invoice with items
invoice = InvoiceRepository.create_with_items(
invoice_data,
account_ids_list,
project_ids_list
)
return invoice
@staticmethod
def mark_overdue_invoices() -> int:
"""
Identify and mark overdue invoices.
Returns:
Number of invoices marked as overdue
"""
thirty_days_ago = timezone.now().date() - timedelta(days=30)
# Get sent invoices that are more than 30 days old
overdue_invoices = Invoice.objects.filter(
status='sent',
date__lt=thirty_days_ago
)
# Update them to overdue status
count = overdue_invoices.update(status='overdue')
return count
@staticmethod
def get_outstanding_balance(customer_id: str) -> float:
"""
Get outstanding balance for a customer.
Args:
customer_id: Customer ID
Returns:
Total outstanding amount
"""
return InvoiceRepository.get_total_outstanding(customer_id)

View File

@ -0,0 +1,157 @@
"""
Notification services for emails, alerts, and reminders.
"""
from typing import Dict, Any
from datetime import timedelta
from django.utils import timezone
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.conf import settings
from backend.core.repositories.services.services import ServiceRepository
from backend.core.repositories.invoices.invoices import InvoiceRepository
class NotificationService:
"""
Service for sending notifications and alerts.
"""
@staticmethod
def send_email(
recipient_email: str,
subject: str,
template_name: str,
context: Dict[str, Any]
) -> bool:
"""
Send an email using a template.
Args:
recipient_email: Recipient email address
subject: Email subject
template_name: Template name
context: Template context
Returns:
True if email was sent successfully, False otherwise
"""
try:
html_content = render_to_string(f"emails/{template_name}.html", context)
text_content = render_to_string(f"emails/{template_name}.txt", context)
send_mail(
subject=subject,
message=text_content,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[recipient_email],
html_message=html_content,
fail_silently=False
)
return True
except Exception as e:
# Log the error
print(f"Error sending email: {str(e)}")
return False
@staticmethod
def send_service_reminders() -> int:
"""
Send service reminders for tomorrow's services.
Returns:
Number of reminders sent
"""
tomorrow = timezone.now().date() + timedelta(days=1)
# Get services scheduled for tomorrow
services = ServiceRepository.get_all(date=tomorrow)
reminder_count = 0
for service in services:
# Get the account
account = service.account
# Skip if no account (shouldn't happen)
if not account:
continue
# Get team members
team_members = service.team_members.all()
# Send reminder to each team member
for member in team_members:
# Skip if no email
if not member.email:
continue
# Prepare context
context = {
'service': service,
'account': account,
'team_member': member,
'service_time': f"{service.deadline_start.strftime('%I:%M %p')} - {service.deadline_end.strftime('%I:%M %p')}" if service.deadline_start and service.deadline_end else "All day"
}
# Send email
success = NotificationService.send_email(
recipient_email=member.email,
subject=f"Service Reminder: {account.name} - {tomorrow.strftime('%m/%d/%Y')}",
template_name="service_reminder",
context=context
)
if success:
reminder_count += 1
return reminder_count
@staticmethod
def send_overdue_invoice_reminders() -> int:
"""
Send reminders for overdue invoices.
Returns:
Number of reminders sent
"""
# Get overdue invoices
overdue_invoices = InvoiceRepository.get_overdue()
reminder_count = 0
for invoice in overdue_invoices:
# Get the customer
customer = invoice.customer
# Skip if no customer (shouldn't happen)
if not customer:
continue
# Skip if no billing email
if not customer.billing_email:
continue
# Calculate days overdue
days_overdue = (timezone.now().date() - invoice.date).days - 30
# Prepare context
context = {
'invoice': invoice,
'customer': customer,
'days_overdue': days_overdue,
'total_amount': invoice.total_amount
}
# Send email
success = NotificationService.send_email(
recipient_email=customer.billing_email,
subject=f"Overdue Invoice Reminder: {invoice.id}",
template_name="overdue_invoice_reminder",
context=context
)
if success:
reminder_count += 1
return reminder_count

View File

@ -0,0 +1,160 @@
"""
Reporting services for analytics and business intelligence.
"""
from typing import Dict, Any, List
from datetime import date, timedelta
from django.utils import timezone
from backend.core.repositories.services.services import ServiceRepository
from backend.core.repositories.projects.projects import ProjectRepository
from backend.core.repositories.invoices.invoices import InvoiceRepository
from backend.core.repositories.reports.reports import ReportRepository
from backend.core.repositories.revenues.revenues import RevenueRepository
from backend.core.repositories.labor.labor import LaborRepository
class ReportingService:
"""
Service for generating business reports and analytics.
"""
@staticmethod
def get_monthly_summary(year: int, month: int) -> Dict[str, Any]:
"""
Get monthly business summary.
Args:
year: Year
month: Month (1-12)
Returns:
Dictionary with monthly summary data
"""
# Calculate date range for the month
start_date = date(year, month, 1)
if month == 12:
end_date = date(year + 1, 1, 1) - timedelta(days=1)
else:
end_date = date(year, month + 1, 1) - timedelta(days=1)
# Get services in the month
services = ServiceRepository.get_by_date_range(start_date, end_date)
# Get projects in the month
projects = ProjectRepository.get_by_date_range(start_date, end_date)
# Get invoices in the month
invoices = InvoiceRepository.get_by_date_range(start_date, end_date)
# Get paid invoices in the month
paid_invoices = invoices.filter(status='paid')
# Calculate total revenue and labor for the month
total_revenue = RevenueRepository.get_total_revenue(None, start_date, end_date)
total_labor = LaborRepository.get_total_labor_cost(None, start_date, end_date)
# Calculate profit
profit = total_revenue - total_labor
# Service statistics
service_stats = {
'total': services.count(),
'completed': services.filter(status='completed').count(),
'cancelled': services.filter(status='cancelled').count(),
'completion_rate': services.filter(
status='completed').count() / services.count() if services.count() > 0 else 0
}
# Project statistics
project_stats = {
'total': projects.count(),
'completed': projects.filter(status='completed').count(),
'cancelled': projects.filter(status='cancelled').count(),
'completion_rate': projects.filter(
status='completed').count() / projects.count() if projects.count() > 0 else 0,
'total_amount': sum(p.amount for p in projects),
'total_labor': sum(p.labor for p in projects),
'profit': sum(p.amount - p.labor for p in projects)
}
# Invoice statistics
invoice_stats = {
'total': invoices.count(),
'paid': paid_invoices.count(),
'payment_rate': paid_invoices.count() / invoices.count() if invoices.count() > 0 else 0,
'total_amount': sum(i.total_amount for i in invoices),
'paid_amount': sum(i.total_amount for i in paid_invoices)
}
return {
'period': {
'year': year,
'month': month,
'start_date': start_date.isoformat(),
'end_date': end_date.isoformat()
},
'financials': {
'revenue': total_revenue,
'labor': total_labor,
'profit': profit,
'profit_margin': (profit / total_revenue) * 100 if total_revenue > 0 else 0
},
'services': service_stats,
'projects': project_stats,
'invoices': invoice_stats
}
@staticmethod
def get_team_performance(start_date: date = None, end_date: date = None) -> List[Dict[str, Any]]:
"""
Get team member performance metrics.
Args:
start_date: Start date (inclusive)
end_date: End date (inclusive)
Returns:
List of team member performance data
"""
# Set default date range to current month if not provided
if not start_date:
today = timezone.now().date()
start_date = date(today.year, today.month, 1)
if not end_date:
end_date = timezone.now().date()
# Get team members with activity
team_activity = ReportRepository.get_team_summary(start_date, end_date)
team_performance = []
for member_id, summary in team_activity.get('member_summaries', {}).items():
# Get services completed by the team member
services_completed = ServiceRepository.get_by_team_member(member_id).filter(
status='completed',
date__gte=start_date,
date__lte=end_date
).count()
# Get projects completed by the team member
projects_completed = ProjectRepository.get_by_team_member(member_id).filter(
status='completed',
date__gte=start_date,
date__lte=end_date
).count()
# Calculate metrics
performance = {
'member_id': member_id,
'name': summary['name'],
'reports_submitted': summary['report_count'],
'services_completed': services_completed,
'projects_completed': projects_completed,
'total_work_items': services_completed + projects_completed
}
team_performance.append(performance)
# Sort by total work items descending
team_performance.sort(key=lambda x: x['total_work_items'], reverse=True)
return team_performance

View File

@ -0,0 +1,123 @@
"""
Scheduling services for services and projects.
"""
from typing import List, Dict, Any
from datetime import date
from django.utils import timezone
from django.db import transaction
from backend.core.models import Service
from backend.core.repositories.accounts.accounts import AccountRepository
from backend.core.repositories.schedules.schedules import ScheduleRepository
from backend.core.repositories.services.services import ServiceRepository
class SchedulingService:
"""
Service for scheduling operations.
"""
@staticmethod
@transaction.atomic
def generate_services_from_schedule(
schedule_id: str,
start_date: date,
end_date: date
) -> List[Service]:
"""
Generate services based on a schedule for a date range.
Args:
schedule_id: Schedule ID
start_date: Start date (inclusive)
end_date: End date (inclusive)
Returns:
List of generated services
"""
return ScheduleRepository.generate_services(schedule_id, start_date, end_date)
@staticmethod
@transaction.atomic
def generate_services_for_all_accounts(
start_date: date,
end_date: date
) -> Dict[str, List[Service]]:
"""
Generate services for all accounts with active schedules.py.
Args:
start_date: Start date (inclusive)
end_date: End date (inclusive)
Returns:
Dictionary mapping account IDs to lists of generated services
"""
# Get all active schedules.py
active_schedules = ScheduleRepository.get_active()
# Generate services for each schedule
result = {}
for schedule in active_schedules:
services = ScheduleRepository.generate_services(
str(schedule.id), # Convert UUID to string if needed
start_date,
end_date
)
# Access account.id instead of account_id
account_id = str(schedule.account.id)
result[account_id] = services
return result
@staticmethod
def get_daily_service_schedule(date_str: str = None) -> List[Dict[str, Any]]:
"""
Get schedule of services for a specific day.
Args:
date_str: Date string (YYYY-MM-DD) or today if None
Returns:
List of services with account and team member info
"""
# Parse date or use today
if date_str:
try:
target_date = date.fromisoformat(date_str)
except ValueError:
# Invalid date format, use today
target_date = timezone.now().date()
else:
target_date = timezone.now().date()
# Get services for the day
services = ServiceRepository.get_all(date=target_date)
# Format the result
result = []
for service in services:
# Access account.id instead of account_id
account = AccountRepository.get_by_id(str(service.account.id))
service_data = {
'id': str(service.id),
'account_name': account.name if account else 'Unknown',
'account_address': f"{account.street_address}, {account.city}, {account.state}" if account else 'Unknown',
'status': service.status,
'deadline_start': service.deadline_start.strftime('%H:%M') if service.deadline_start else None,
'deadline_end': service.deadline_end.strftime('%H:%M') if service.deadline_end else None,
'team_members': [
{
'id': str(tm.id),
'name': f"{tm.first_name} {tm.last_name}",
'role': tm.role
}
for tm in service.team_members.all()
]
}
result.append(service_data)
return result

View File

@ -0,0 +1,12 @@
from backend.core.utils.validators import is_valid_uuid, is_valid_email, validate_decimal_amount
from backend.core.utils.helpers import generate_uuid, format_date, format_datetime, parse_date
__all__ = [
'is_valid_uuid',
'is_valid_email',
'validate_decimal_amount',
'generate_uuid',
'format_date',
'format_datetime',
'parse_date',
]

View File

@ -0,0 +1,327 @@
"""
Helper functions for the API.
Provides utility functions for data transformation, processing, and other common tasks.
"""
import uuid
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
import json
def generate_uuid() -> str:
"""
Generate a new UUID string.
Returns:
str: A new UUID string.
"""
return str(uuid.uuid4())
def format_date(date_obj: datetime) -> str:
"""
Format a datetime object as a date string (YYYY-MM-DD).
Args:
date_obj: The datetime object to format.
Returns:
str: The formatted date string.
"""
return date_obj.strftime('%Y-%m-%d')
def format_datetime(datetime_obj: datetime) -> str:
"""
Format a datetime object as an ISO string.
Args:
datetime_obj: The datetime object to format.
Returns:
str: The formatted datetime string.
"""
return datetime_obj.isoformat()
def parse_date(date_str: str) -> Optional[datetime]:
"""
Parse a date string into a datetime object.
Args:
date_str: The date string to parse (YYYY-MM-DD).
Returns:
Optional[datetime]: The parsed datetime or None if invalid.
"""
if not date_str:
return None
try:
return datetime.fromisoformat(date_str)
except ValueError:
return None
def parse_datetime(datetime_str: str) -> Optional[datetime]:
"""
Parse a datetime string into a datetime object.
Args:
datetime_str: The datetime string to parse.
Returns:
Optional[datetime]: The parsed datetime or None if invalid.
"""
if not datetime_str:
return None
try:
return datetime.fromisoformat(datetime_str)
except ValueError:
return None
def to_camel_case(snake_str: str) -> str:
"""
Convert a snake_case string to camelCase.
Args:
snake_str: The snake_case string to convert.
Returns:
str: The camelCase string.
"""
components = snake_str.split('_')
return components[0] + ''.join(x.title() for x in components[1:])
def to_snake_case(camel_str: str) -> str:
"""
Convert a camelCase string to snake_case.
Args:
camel_str: The camelCase string to convert.
Returns:
str: The snake_case string.
"""
import re
# Use regex to find all capital letters and insert underscore before them
s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', camel_str)
return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower()
def convert_keys_to_camel_case(data: Dict[str, Any]) -> Dict[str, Any]:
"""
Convert all dictionary keys from snake_case to camelCase.
Args:
data: The dictionary with snake_case keys.
Returns:
Dict[str, Any]: A new dictionary with camelCase keys.
"""
if not isinstance(data, dict):
return data
result = {}
for key, value in data.items():
if isinstance(value, dict):
value = convert_keys_to_camel_case(value)
elif isinstance(value, list):
value = [
convert_keys_to_camel_case(item) if isinstance(item, dict) else item
for item in value
]
result[to_camel_case(key)] = value
return result
def convert_keys_to_snake_case(data: Dict[str, Any]) -> Dict[str, Any]:
"""
Convert all dictionary keys from camelCase to snake_case.
Args:
data: The dictionary with camelCase keys.
Returns:
Dict[str, Any]: A new dictionary with snake_case keys.
"""
if not isinstance(data, dict):
return data
result = {}
for key, value in data.items():
if isinstance(value, dict):
value = convert_keys_to_snake_case(value)
elif isinstance(value, list):
value = [
convert_keys_to_snake_case(item) if isinstance(item, dict) else item
for item in value
]
result[to_snake_case(key)] = value
return result
def filter_none_values(data: Dict[str, Any]) -> Dict[str, Any]:
"""
Remove all None values from a dictionary.
Args:
data: The dictionary to filter.
Returns:
Dict[str, Any]: A new dictionary without None values.
"""
return {k: v for k, v in data.items() if v is not None}
def get_week_start_end(date: datetime) -> tuple:
"""
Get the start and end dates of the week containing the given date.
Args:
date: The date to get the week for.
Returns:
tuple: (start_date, end_date) of the week.
"""
# Monday is 0 and Sunday is 6
start = date - timedelta(days=date.weekday())
end = start + timedelta(days=6)
return start, end
def get_month_start_end(date: datetime) -> tuple:
"""
Get the start and end dates of the month containing the given date.
Args:
date: The date to get the month for.
Returns:
tuple: (start_date, end_date) of the month.
"""
start = date.replace(day=1)
# Get the last day by going to next month and subtracting one day
if date.month == 12:
end = datetime(date.year + 1, 1, 1) - timedelta(days=1)
else:
end = datetime(date.year, date.month + 1, 1) - timedelta(days=1)
return start, end
def get_date_range(start_date: str, end_date: str) -> List[str]:
"""
Get a list of date strings between start_date and end_date (inclusive).
Args:
start_date: The start date string (YYYY-MM-DD).
end_date: The end date string (YYYY-MM-DD).
Returns:
List[str]: List of date strings in the range.
"""
start = parse_date(start_date)
end = parse_date(end_date)
if not start or not end:
return []
if start > end:
return []
date_list = []
current = start
while current <= end:
date_list.append(format_date(current))
current += timedelta(days=1)
return date_list
def date_diff_in_days(start_date: str, end_date: str) -> int:
"""
Calculate the difference in days between two date strings.
Args:
start_date: The start date string (YYYY-MM-DD).
end_date: The end date string (YYYY-MM-DD).
Returns:
int: The number of days between the dates. Returns 0 if dates are invalid.
"""
start = parse_date(start_date)
end = parse_date(end_date)
if not start or not end:
return 0
return (end - start).days
def dict_to_json(data: Dict[str, Any]) -> str:
"""
Convert a dictionary to a JSON string.
Args:
data: The dictionary to convert.
Returns:
str: The JSON string.
"""
return json.dumps(data, default=str)
def json_to_dict(json_str: str) -> Dict[str, Any]:
"""
Convert a JSON string to a dictionary.
Args:
json_str: The JSON string to convert.
Returns:
Dict[str, Any]: The dictionary. Returns empty dict if JSON is invalid.
"""
try:
return json.loads(json_str)
except (json.JSONDecodeError, TypeError):
return {}
def paginate_results(items: List[Any], page: int = 1, page_size: int = 10) -> Dict[str, Any]:
"""
Paginate a list of items.
Args:
items: The list of items to paginate.
page: The page number (1-based).
page_size: The number of items per page.
Returns:
Dict[str, Any]: A dictionary with pagination info and results.
"""
if page < 1:
page = 1
if page_size < 1:
page_size = 10
total_items = len(items)
total_pages = (total_items + page_size - 1) // page_size
start_idx = (page - 1) * page_size
end_idx = min(start_idx + page_size, total_items)
return {
'page': page,
'page_size': page_size,
'total_items': total_items,
'total_pages': total_pages,
'has_previous': page > 1,
'has_next': page < total_pages,
'items': items[start_idx:end_idx]
}

View File

@ -0,0 +1,199 @@
"""
Validators for API data validation.
Provides reusable validation functions for domain models data.
"""
import re
import uuid
from datetime import datetime, date
from typing import Any, Dict, List, Optional, Union
def is_valid_uuid(value: Any) -> bool:
"""
Check if a value is a valid UUID.
Args:
value: The value to check.
Returns:
bool: True if the value is a valid UUID, False otherwise.
"""
if isinstance(value, uuid.UUID):
return True
if not isinstance(value, str):
return False
try:
uuid.UUID(value)
return True
except (ValueError, AttributeError, TypeError):
return False
def is_valid_email(email: str) -> bool:
"""
Validate an email address format.
Args:
email: The email address to validate.
Returns:
bool: True if the email format is valid, False otherwise.
"""
if not email or not isinstance(email, str):
return False
# Simple regex for email validation
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return bool(re.match(email_pattern, email))
def is_valid_phone(phone: str) -> bool:
"""
Validate a phone number format.
Args:
phone: The phone number to validate.
Returns:
bool: True if the phone format is valid, False otherwise.
"""
if not phone or not isinstance(phone, str):
return False
# Remove common formatting characters
cleaned = re.sub(r'[\s\-\(\)\.]+', '', phone)
# Check if it's a valid format (allows international format)
phone_pattern = r'^(\+\d{1,3})?(\d{10,15})$'
return bool(re.match(phone_pattern, cleaned))
def is_valid_date(date_val: Any) -> bool:
"""
Validate a date string in ISO format (YYYY-MM-DD).
Args:
date_val: The date to validate.
Returns:
bool: True if the date is valid, False otherwise.
"""
return isinstance(date_val, date)
def is_valid_datetime(datetime_str: str) -> bool:
"""
Validate a datetime string in ISO format.
Args:
datetime_str: The datetime string to validate.
Returns:
bool: True if the datetime format is valid, False otherwise.
"""
if not datetime_str or not isinstance(datetime_str, str):
return False
try:
datetime.fromisoformat(datetime_str)
return True
except ValueError:
return False
def validate_required_fields(data: Dict[str, Any], required_fields: List[str]) -> List[str]:
"""
Validate that all required fields are present and not empty in the data.
Args:
data: Dictionary containing the data to validate.
required_fields: List of field names that are required.
Returns:
List[str]: List of missing field names. Empty if all required fields are present.
"""
missing_fields = []
for field in required_fields:
if field not in data or data[field] is None or (isinstance(data[field], str) and not data[field].strip()):
missing_fields.append(field)
return missing_fields
def validate_model_exists(model_id: str, model_type: str,
repository_method: callable, error_message: Optional[str] = None) -> Dict[str, Any]:
"""
Validate that a model with the given ID exists.
Args:
model_id: The ID of the model to check.
model_type: The type of model (e.g., 'customer', 'account').
repository_method: Repository method to fetch the model.
error_message: Custom error message. If None, a default message is used.
Returns:
Dict[str, Any]: Dictionary with 'valid' and 'error' keys.
"""
if not is_valid_uuid(model_id):
return {
'valid': False,
'error': f"Invalid {model_type} ID format."
}
model = repository_method(model_id)
if not model:
return {
'valid': False,
'error': error_message or f"{model_type.capitalize()} with ID {model_id} not found."
}
return {
'valid': True,
'model': model,
'error': None
}
def validate_decimal_amount(amount: Union[float, str, int], field_name: str = 'amount') -> Dict[str, Any]:
"""
Validate a decimal amount (e.g., for money).
Args:
amount: The amount to validate.
field_name: The name of the field being validated.
Returns:
Dict[str, Any]: Dictionary with 'valid' and 'error' keys.
"""
try:
# Convert to float if it's a string
if isinstance(amount, str):
amount = float(amount)
# Check if it's a number
if not isinstance(amount, (int, float)):
return {
'valid': False,
'error': f"{field_name} must be a number."
}
# Check if it's non-negative
if amount < 0:
return {
'valid': False,
'error': f"{field_name} cannot be negative."
}
return {
'valid': True,
'amount': float(amount),
'error': None
}
except ValueError:
return {
'valid': False,
'error': f"Invalid {field_name} format."
}

View File

Some files were not shown because too many files have changed in this diff Show More