public-ready-init
This commit is contained in:
commit
4b80fe28fe
70
.gitignore
vendored
Normal file
70
.gitignore
vendored
Normal 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
299
README.md
Normal 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
31
backend/.env.example
Normal 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
3
backend/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
.env
|
||||
db.sqlite3
|
||||
data/
|
||||
22
backend/Dockerfile
Normal file
22
backend/Dockerfile
Normal 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
0
backend/__init__.py
Normal file
0
backend/config/__init__.py
Normal file
0
backend/config/__init__.py
Normal file
10
backend/config/asgi.py
Normal file
10
backend/config/asgi.py
Normal 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()
|
||||
13
backend/config/settings/__init__.py
Normal file
13
backend/config/settings/__init__.py
Normal 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 *
|
||||
166
backend/config/settings/base.py
Normal file
166
backend/config/settings/base.py
Normal 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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
42
backend/config/settings/development.py
Normal file
42
backend/config/settings/development.py
Normal 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,
|
||||
}
|
||||
99
backend/config/settings/production.py
Normal file
99
backend/config/settings/production.py
Normal 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
19
backend/config/urls.py
Normal 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
10
backend/config/wsgi.py
Normal 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
0
backend/core/__init__.py
Normal file
13
backend/core/admin.py
Normal file
13
backend/core/admin.py
Normal 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
6
backend/core/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApiConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'backend.core'
|
||||
102
backend/core/commands/__init__.py
Normal file
102
backend/core/commands/__init__.py
Normal 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',
|
||||
]
|
||||
0
backend/core/commands/accounts/__init__.py
Normal file
0
backend/core/commands/accounts/__init__.py
Normal file
677
backend/core/commands/accounts/accounts.py
Normal file
677
backend/core/commands/accounts/accounts.py
Normal 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"
|
||||
)
|
||||
113
backend/core/commands/base.py
Normal file
113
backend/core/commands/base.py
Normal 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)
|
||||
0
backend/core/commands/customers/__init__.py
Normal file
0
backend/core/commands/customers/__init__.py
Normal file
629
backend/core/commands/customers/customers.py
Normal file
629
backend/core/commands/customers/customers.py
Normal 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"
|
||||
)
|
||||
0
backend/core/commands/invoices/__init__.py
Normal file
0
backend/core/commands/invoices/__init__.py
Normal file
445
backend/core/commands/invoices/invoices.py
Normal file
445
backend/core/commands/invoices/invoices.py
Normal 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"
|
||||
)
|
||||
0
backend/core/commands/labor/__init__.py
Normal file
0
backend/core/commands/labor/__init__.py
Normal file
550
backend/core/commands/labor/labor.py
Normal file
550
backend/core/commands/labor/labor.py
Normal 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"
|
||||
)
|
||||
0
backend/core/commands/profiles/__init__.py
Normal file
0
backend/core/commands/profiles/__init__.py
Normal file
429
backend/core/commands/profiles/profiles.py
Normal file
429
backend/core/commands/profiles/profiles.py
Normal 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"
|
||||
)
|
||||
0
backend/core/commands/projects/__init__.py
Normal file
0
backend/core/commands/projects/__init__.py
Normal file
444
backend/core/commands/projects/projects.py
Normal file
444
backend/core/commands/projects/projects.py
Normal 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"
|
||||
)
|
||||
0
backend/core/commands/reports/__init__.py
Normal file
0
backend/core/commands/reports/__init__.py
Normal file
296
backend/core/commands/reports/reports.py
Normal file
296
backend/core/commands/reports/reports.py
Normal 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."
|
||||
)
|
||||
0
backend/core/commands/revenues/__init__.py
Normal file
0
backend/core/commands/revenues/__init__.py
Normal file
693
backend/core/commands/revenues/revenues.py
Normal file
693
backend/core/commands/revenues/revenues.py
Normal 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"
|
||||
)
|
||||
0
backend/core/commands/schedules/__init__.py
Normal file
0
backend/core/commands/schedules/__init__.py
Normal file
848
backend/core/commands/schedules/schedules.py
Normal file
848
backend/core/commands/schedules/schedules.py
Normal 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"
|
||||
)
|
||||
7
backend/core/commands/services/__init__.py
Normal file
7
backend/core/commands/services/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
"""
|
||||
Service commands for managing services.
|
||||
"""
|
||||
|
||||
from backend.core.commands.services.bulk_schedule import BulkScheduleServicesCommand
|
||||
|
||||
__all__ = ['BulkScheduleServicesCommand']
|
||||
118
backend/core/commands/services/bulk_schedule.py
Normal file
118
backend/core/commands/services/bulk_schedule.py
Normal 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"
|
||||
)
|
||||
611
backend/core/commands/services/services.py
Normal file
611
backend/core/commands/services/services.py
Normal 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))
|
||||
|
||||
3
backend/core/factories/__init__.py
Normal file
3
backend/core/factories/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""
|
||||
Factory module for creating domain objects.
|
||||
"""
|
||||
7
backend/core/factories/services/__init__.py
Normal file
7
backend/core/factories/services/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
"""
|
||||
Service factory module for creating service objects.
|
||||
"""
|
||||
|
||||
from backend.core.factories.services.services import ServiceFactory
|
||||
|
||||
__all__ = ['ServiceFactory']
|
||||
202
backend/core/factories/services/services.py
Normal file
202
backend/core/factories/services/services.py
Normal 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}"
|
||||
}
|
||||
32
backend/core/models/__init__.py
Normal file
32
backend/core/models/__init__.py
Normal 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',
|
||||
]
|
||||
0
backend/core/models/accounts/__init__.py
Normal file
0
backend/core/models/accounts/__init__.py
Normal file
89
backend/core/models/accounts/accounts.py
Normal file
89
backend/core/models/accounts/accounts.py
Normal 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()
|
||||
0
backend/core/models/customers/__init__.py
Normal file
0
backend/core/models/customers/__init__.py
Normal file
80
backend/core/models/customers/customers.py
Normal file
80
backend/core/models/customers/customers.py
Normal 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}"
|
||||
0
backend/core/models/invoices/__init__.py
Normal file
0
backend/core/models/invoices/__init__.py
Normal file
83
backend/core/models/invoices/invoices.py
Normal file
83
backend/core/models/invoices/invoices.py
Normal 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
|
||||
0
backend/core/models/labor/__init__.py
Normal file
0
backend/core/models/labor/__init__.py
Normal file
42
backend/core/models/labor/labor.py
Normal file
42
backend/core/models/labor/labor.py
Normal 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
|
||||
0
backend/core/models/profiles/__init__.py
Normal file
0
backend/core/models/profiles/__init__.py
Normal file
73
backend/core/models/profiles/profiles.py
Normal file
73
backend/core/models/profiles/profiles.py
Normal 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
|
||||
)
|
||||
0
backend/core/models/projects/__init__.py
Normal file
0
backend/core/models/projects/__init__.py
Normal file
80
backend/core/models/projects/projects.py
Normal file
80
backend/core/models/projects/projects.py
Normal 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
|
||||
3
backend/core/models/punchlists/__init__.py
Normal file
3
backend/core/models/punchlists/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""
|
||||
Punchlist models for tracking project punchlists.
|
||||
"""
|
||||
95
backend/core/models/punchlists/punchlists.py
Normal file
95
backend/core/models/punchlists/punchlists.py
Normal 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}"
|
||||
0
backend/core/models/reports/__init__.py
Normal file
0
backend/core/models/reports/__init__.py
Normal file
42
backend/core/models/reports/reports.py
Normal file
42
backend/core/models/reports/reports.py
Normal 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()
|
||||
0
backend/core/models/revenues/__init__.py
Normal file
0
backend/core/models/revenues/__init__.py
Normal file
42
backend/core/models/revenues/revenues.py
Normal file
42
backend/core/models/revenues/revenues.py
Normal 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
|
||||
0
backend/core/models/schedules/__init__.py
Normal file
0
backend/core/models/schedules/__init__.py
Normal file
81
backend/core/models/schedules/schedules.py
Normal file
81
backend/core/models/schedules/schedules.py
Normal 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)
|
||||
0
backend/core/models/services/__init__.py
Normal file
0
backend/core/models/services/__init__.py
Normal file
71
backend/core/models/services/services.py
Normal file
71
backend/core/models/services/services.py
Normal 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()]
|
||||
31
backend/core/repositories/__init__.py
Normal file
31
backend/core/repositories/__init__.py
Normal 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',
|
||||
]
|
||||
0
backend/core/repositories/accounts/__init__.py
Normal file
0
backend/core/repositories/accounts/__init__.py
Normal file
220
backend/core/repositories/accounts/accounts.py
Normal file
220
backend/core/repositories/accounts/accounts.py
Normal 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
|
||||
209
backend/core/repositories/base.py
Normal file
209
backend/core/repositories/base.py
Normal 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)
|
||||
0
backend/core/repositories/customers/__init__.py
Normal file
0
backend/core/repositories/customers/__init__.py
Normal file
177
backend/core/repositories/customers/customers.py
Normal file
177
backend/core/repositories/customers/customers.py
Normal 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
|
||||
0
backend/core/repositories/invoices/__init__.py
Normal file
0
backend/core/repositories/invoices/__init__.py
Normal file
302
backend/core/repositories/invoices/invoices.py
Normal file
302
backend/core/repositories/invoices/invoices.py
Normal 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
|
||||
0
backend/core/repositories/labor/__init__.py
Normal file
0
backend/core/repositories/labor/__init__.py
Normal file
183
backend/core/repositories/labor/labor.py
Normal file
183
backend/core/repositories/labor/labor.py
Normal 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)
|
||||
0
backend/core/repositories/profiles/__init__.py
Normal file
0
backend/core/repositories/profiles/__init__.py
Normal file
103
backend/core/repositories/profiles/profiles.py
Normal file
103
backend/core/repositories/profiles/profiles.py
Normal 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)
|
||||
0
backend/core/repositories/projects/__init__.py
Normal file
0
backend/core/repositories/projects/__init__.py
Normal file
266
backend/core/repositories/projects/projects.py
Normal file
266
backend/core/repositories/projects/projects.py
Normal 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)
|
||||
3
backend/core/repositories/punchlists/__init__.py
Normal file
3
backend/core/repositories/punchlists/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""
|
||||
Punchlist repositories for managing punchlist data.
|
||||
"""
|
||||
98
backend/core/repositories/punchlists/punchlists.py
Normal file
98
backend/core/repositories/punchlists/punchlists.py
Normal 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
|
||||
0
backend/core/repositories/reports/__init__.py
Normal file
0
backend/core/repositories/reports/__init__.py
Normal file
235
backend/core/repositories/reports/reports.py
Normal file
235
backend/core/repositories/reports/reports.py
Normal 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
|
||||
}
|
||||
0
backend/core/repositories/revenues/__init__.py
Normal file
0
backend/core/repositories/revenues/__init__.py
Normal file
183
backend/core/repositories/revenues/revenues.py
Normal file
183
backend/core/repositories/revenues/revenues.py
Normal 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)
|
||||
0
backend/core/repositories/schedules/__init__.py
Normal file
0
backend/core/repositories/schedules/__init__.py
Normal file
174
backend/core/repositories/schedules/schedules.py
Normal file
174
backend/core/repositories/schedules/schedules.py
Normal 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
|
||||
0
backend/core/repositories/services/__init__.py
Normal file
0
backend/core/repositories/services/__init__.py
Normal file
228
backend/core/repositories/services/services.py
Normal file
228
backend/core/repositories/services/services.py
Normal 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
|
||||
16
backend/core/services/__init__.py
Normal file
16
backend/core/services/__init__.py
Normal 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'
|
||||
]
|
||||
98
backend/core/services/auth.py
Normal file
98
backend/core/services/auth.py
Normal 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
|
||||
145
backend/core/services/billing.py
Normal file
145
backend/core/services/billing.py
Normal 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)
|
||||
157
backend/core/services/notifications.py
Normal file
157
backend/core/services/notifications.py
Normal 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
|
||||
160
backend/core/services/reporting.py
Normal file
160
backend/core/services/reporting.py
Normal 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
|
||||
123
backend/core/services/scheduling.py
Normal file
123
backend/core/services/scheduling.py
Normal 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
|
||||
12
backend/core/utils/__init__.py
Normal file
12
backend/core/utils/__init__.py
Normal 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',
|
||||
]
|
||||
327
backend/core/utils/helpers.py
Normal file
327
backend/core/utils/helpers.py
Normal 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]
|
||||
}
|
||||
199
backend/core/utils/validators.py
Normal file
199
backend/core/utils/validators.py
Normal 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."
|
||||
}
|
||||
0
backend/graphql_api/__init__.py
Normal file
0
backend/graphql_api/__init__.py
Normal file
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user