public-ready-init
This commit is contained in:
commit
fd5d81304e
49
.env.example
Normal file
49
.env.example
Normal file
@ -0,0 +1,49 @@
|
||||
# Django Settings
|
||||
SECRET_KEY=your-secret-key-generate-a-strong-one
|
||||
DEBUG=False
|
||||
|
||||
# Database
|
||||
DB_NAME=nexus
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=your-database-password
|
||||
|
||||
# Database Admin (for migrations)
|
||||
DB_ADMIN_USER=postgres
|
||||
DB_ADMIN_PASSWORD=your-admin-password
|
||||
|
||||
# Redis/Valkey
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_USERNAME=
|
||||
REDIS_PASSWORD=your-redis-password
|
||||
|
||||
# Redis Cluster Mode (optional)
|
||||
REDIS_CLUSTER_MODE=False
|
||||
|
||||
# Redis Sentinel (optional - for high availability)
|
||||
# REDIS_SENTINEL_HOSTS=host1:26379,host2:26379,host3:26379
|
||||
# REDIS_SENTINEL_MASTER=valkey-ha
|
||||
# REDIS_SENTINEL_PASSWORD=
|
||||
|
||||
# Ory Oathkeeper
|
||||
OATHKEEPER_SECRET=your-oathkeeper-secret
|
||||
|
||||
# S3 Storage (Garage/MinIO compatible)
|
||||
AWS_ACCESS_KEY_ID=your-access-key
|
||||
AWS_SECRET_ACCESS_KEY=your-secret-key
|
||||
AWS_STORAGE_BUCKET_NAME=nexus-media
|
||||
AWS_S3_ENDPOINT_URL=http://localhost:3900
|
||||
|
||||
# AI Chat (Anthropic Claude)
|
||||
ANTHROPIC_API_KEY=your-anthropic-api-key
|
||||
ANTHROPIC_MODEL=claude-sonnet-4-20250514
|
||||
|
||||
# Emailer Microservice
|
||||
EMAILER_BASE_URL=https://email.example.com
|
||||
EMAILER_API_KEY=your-emailer-api-key
|
||||
EMAILER_DEFAULT_SENDER=noreply@example.com
|
||||
|
||||
# Dispatch Profile (for labor calculations)
|
||||
DISPATCH_TEAM_PROFILE_ID=
|
||||
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
# Python
|
||||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
|
||||
# Django
|
||||
db.sqlite3
|
||||
/staticfiles/
|
||||
/media/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# MCP configuration (machine-specific paths)
|
||||
.mcp.json
|
||||
|
||||
# Misc
|
||||
*.log
|
||||
.DS_Store
|
||||
9
.mcp.json.example
Normal file
9
.mcp.json.example
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"nexus": {
|
||||
"command": "/path/to/nexus-5/.venv/bin/python",
|
||||
"args": ["-m", "core.mcp.server"],
|
||||
"cwd": "/path/to/nexus-5"
|
||||
}
|
||||
}
|
||||
}
|
||||
56
Dockerfile
Normal file
56
Dockerfile
Normal file
@ -0,0 +1,56 @@
|
||||
# Use a slim, official Python image as the base
|
||||
FROM python:3.13-slim AS base
|
||||
# Set environment variables for Python and Poetry
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
POETRY_VIRTUALENVS_CREATE=false \
|
||||
POETRY_NO_INTERACTION=1
|
||||
# Install system dependencies
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
curl \
|
||||
libpq-dev \
|
||||
lsb-release \
|
||||
gnupg \
|
||||
ffmpeg \
|
||||
&& curl -fsSL https://apt.releases.hashicorp.com/gpg | gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg \
|
||||
&& echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | tee /etc/apt/sources.list.d/hashicorp.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y vault \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Install Poetry into a globally accessible location
|
||||
ENV POETRY_HOME=/opt/poetry
|
||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||
# Add Poetry to the system's PATH for all users
|
||||
ENV PATH="$POETRY_HOME/bin:$PATH"
|
||||
# Set the working directory for the application
|
||||
WORKDIR /app
|
||||
# Create a non-root user and group for security
|
||||
RUN addgroup --system app && adduser --system --group app
|
||||
# Copy only dependency files first to leverage Docker's layer cache
|
||||
COPY pyproject.toml poetry.lock* /app/
|
||||
# Copy the vault agent config and templates
|
||||
COPY /vault/vault-agent-config.hcl /etc/vault/agent-config.hcl
|
||||
COPY /vault/db-admin-template.hcl /etc/vault/admin-template.hcl
|
||||
COPY /vault/db-app-template.hcl /etc/vault/app-template.hcl
|
||||
COPY entrypoint.sh /app/entrypoint.sh
|
||||
# Install Python dependencies
|
||||
RUN poetry install --no-ansi --no-root
|
||||
# Copy the rest of the application source code
|
||||
COPY . /app
|
||||
# Set correct ownership and permissions for the application files WHILE STILL ROOT
|
||||
RUN chown -R app:app /app/
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
RUN chmod +x /app/setup.sh
|
||||
# Make sure the secrets dir is writable by the 'app' user
|
||||
RUN mkdir -p /vault/secrets && chown -R app:app /vault/secrets
|
||||
# --- Switch to the non-root user ---
|
||||
USER app
|
||||
# Run collectstatic to gather all static files
|
||||
RUN poetry run python manage.py collectstatic --no-input
|
||||
# Expose the application port
|
||||
EXPOSE 8000
|
||||
# Set the entrypoint script to run on container start
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
# The CMD is passed from docker-compose.yml to the entrypoint
|
||||
269
README.md
Normal file
269
README.md
Normal file
@ -0,0 +1,269 @@
|
||||
# Nexus 5
|
||||
|
||||
A modern, production-ready field service management API built with Django, Strawberry GraphQL, and Django Channels. Nexus 5 represents the culmination of lessons learned from previous iterations, combining the developer productivity of Django with enterprise-grade features.
|
||||
|
||||
## Improvements Over Previous Versions
|
||||
|
||||
### Evolution from Nexus 1-4
|
||||
|
||||
| Feature | Nexus 1-2 | Nexus 3 | Nexus 4 (Rust) | Nexus 5 |
|
||||
|---------|-----------|---------|----------------|---------|
|
||||
| **API** | REST (DRF) | GraphQL (Graphene) | GraphQL (async-graphql) | GraphQL (Strawberry) |
|
||||
| **Real-time** | None | None | None | WebSocket subscriptions |
|
||||
| **Auth** | JWT (DRF) | JWT (graphql-jwt) | JWT (jsonwebtoken) | Ory Kratos + Oathkeeper |
|
||||
| **Background Tasks** | None | None | None | Celery + Redis |
|
||||
| **File Storage** | Local | Local | None | S3-compatible (Garage) |
|
||||
| **Caching** | None | None | None | Valkey/Redis with Sentinel HA |
|
||||
| **Database Credentials** | Static .env | Static .env | Static .env | HashiCorp Vault (dynamic) |
|
||||
| **Chat/AI** | None | None | None | Claude AI integration |
|
||||
| **Email** | Django SMTP | Django SMTP | None | Rust microservice |
|
||||
|
||||
### Key Improvements in Nexus 5
|
||||
|
||||
1. **Strawberry GraphQL**: Modern, type-safe GraphQL with native Python type hints
|
||||
2. **Real-time Subscriptions**: WebSocket-based subscriptions for live updates via Django Channels
|
||||
3. **Ory Authentication Stack**: Enterprise-grade auth with Kratos (identity) + Oathkeeper (API gateway)
|
||||
4. **High-Availability Caching**: Valkey/Redis with Sentinel support for automatic failover
|
||||
5. **Dynamic Database Credentials**: HashiCorp Vault integration for rotating DB credentials
|
||||
6. **S3-Compatible Storage**: Garage cluster for distributed file storage
|
||||
7. **AI Chat Integration**: Claude-powered assistant for the application
|
||||
8. **MCP Server**: Model Context Protocol server for AI tool integration
|
||||
9. **Celery Beat Scheduling**: Automated monitoring and notification tasks
|
||||
10. **Session Tracking**: Detailed work sessions with images, videos, and notes
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Backend
|
||||
- Python 3.11+
|
||||
- Django 5.x
|
||||
- Strawberry GraphQL
|
||||
- Django Channels (WebSocket)
|
||||
- Celery + Redis/Valkey
|
||||
- PostgreSQL
|
||||
- S3 Storage (Garage/MinIO compatible)
|
||||
- HashiCorp Vault (optional)
|
||||
|
||||
### External Services
|
||||
- Ory Kratos (Identity Management)
|
||||
- Ory Oathkeeper (API Gateway)
|
||||
- Valkey/Redis (Caching & Pub/Sub)
|
||||
- Anthropic Claude (AI Chat)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
nexus-5/
|
||||
├── config/
|
||||
│ ├── settings.py # Django settings with env vars
|
||||
│ ├── celery.py # Celery configuration
|
||||
│ ├── asgi.py # ASGI with Channels
|
||||
│ ├── storage.py # S3 storage backend
|
||||
│ └── db_backend/ # Custom DB backend for Vault
|
||||
├── core/
|
||||
│ ├── models/ # Domain models
|
||||
│ ├── graphql/
|
||||
│ │ ├── types/ # Strawberry types
|
||||
│ │ ├── inputs/ # Input types
|
||||
│ │ ├── filters/ # Filter types
|
||||
│ │ ├── queries/ # Query resolvers
|
||||
│ │ ├── mutations/ # Mutation resolvers
|
||||
│ │ └── subscriptions/# WebSocket subscriptions
|
||||
│ ├── chat/ # AI chat with Channels
|
||||
│ ├── mcp/ # MCP server for AI tools
|
||||
│ ├── services/ # Business logic services
|
||||
│ ├── tasks/ # Celery tasks
|
||||
│ └── templates/ # Email templates
|
||||
├── vault/ # Vault configuration templates
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
└── pyproject.toml # Poetry dependencies
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Python 3.11+
|
||||
- PostgreSQL 15+
|
||||
- Redis/Valkey
|
||||
- Docker (recommended)
|
||||
|
||||
### Development Setup
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone <repository-url>
|
||||
cd nexus-5
|
||||
|
||||
# Create virtual environment
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
|
||||
# Install dependencies with Poetry
|
||||
pip install poetry
|
||||
poetry install
|
||||
|
||||
# Create .env file
|
||||
cp .env.example .env
|
||||
# Edit .env with your configuration
|
||||
|
||||
# Run migrations
|
||||
python manage.py migrate
|
||||
|
||||
# Create superuser
|
||||
python manage.py createsuperuser
|
||||
|
||||
# Start development server
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
### With Docker
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Required Environment Variables
|
||||
|
||||
```bash
|
||||
# Django
|
||||
SECRET_KEY=your-secret-key
|
||||
DEBUG=False
|
||||
|
||||
# Database
|
||||
DB_NAME=nexus
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=password
|
||||
|
||||
# Redis/Valkey
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=password
|
||||
|
||||
# Ory (if using)
|
||||
OATHKEEPER_SECRET=your-oathkeeper-secret
|
||||
```
|
||||
|
||||
### Optional Environment Variables
|
||||
|
||||
```bash
|
||||
# High Availability
|
||||
REDIS_SENTINEL_HOSTS=host1:26379,host2:26379,host3:26379
|
||||
REDIS_SENTINEL_MASTER=valkey-ha
|
||||
REDIS_CLUSTER_MODE=False
|
||||
|
||||
# S3 Storage
|
||||
AWS_ACCESS_KEY_ID=your-key
|
||||
AWS_SECRET_ACCESS_KEY=your-secret
|
||||
AWS_STORAGE_BUCKET_NAME=nexus-media
|
||||
AWS_S3_ENDPOINT_URL=http://localhost:3900
|
||||
|
||||
# AI Chat
|
||||
ANTHROPIC_API_KEY=your-api-key
|
||||
ANTHROPIC_MODEL=claude-sonnet-4-20250514
|
||||
|
||||
# Emailer Microservice
|
||||
EMAILER_BASE_URL=https://email.example.com
|
||||
EMAILER_API_KEY=your-api-key
|
||||
|
||||
# Dispatch Profile (for labor calculations)
|
||||
DISPATCH_TEAM_PROFILE_ID=uuid-here
|
||||
```
|
||||
|
||||
## GraphQL API
|
||||
|
||||
The GraphQL endpoint is available at `/graphql/` with the GraphiQL playground.
|
||||
|
||||
### Example Query
|
||||
|
||||
```graphql
|
||||
query GetServices($filter: ServiceFilter) {
|
||||
services(filter: $filter) {
|
||||
id
|
||||
date
|
||||
status
|
||||
account {
|
||||
name
|
||||
}
|
||||
teamMembers {
|
||||
firstName
|
||||
lastName
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example Subscription
|
||||
|
||||
```graphql
|
||||
subscription OnServiceUpdated {
|
||||
serviceUpdated {
|
||||
id
|
||||
status
|
||||
date
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Core Features
|
||||
|
||||
### Work Session Tracking
|
||||
- Start/stop time tracking for services and projects
|
||||
- Photo and video documentation
|
||||
- Internal and customer-visible notes
|
||||
- Task completion tracking with scopes
|
||||
|
||||
### Scope Management
|
||||
- Reusable scope templates
|
||||
- Area-based task organization
|
||||
- Frequency-based task scheduling (daily, weekly, monthly)
|
||||
- Completion tracking per service
|
||||
|
||||
### Real-time Messaging
|
||||
- Internal team conversations
|
||||
- Customer communication threads
|
||||
- Unread counts and read receipts
|
||||
- WebSocket-based live updates
|
||||
|
||||
### AI Chat Assistant
|
||||
- Claude-powered contextual help
|
||||
- MCP server for tool integration
|
||||
- Conversation history per user
|
||||
|
||||
## Deployment
|
||||
|
||||
### Production Checklist
|
||||
|
||||
1. Set `DEBUG=False`
|
||||
2. Configure strong `SECRET_KEY`
|
||||
3. Set up PostgreSQL with proper credentials
|
||||
4. Configure Valkey/Redis (consider Sentinel for HA)
|
||||
5. Set up Ory Kratos and Oathkeeper
|
||||
6. Configure S3 storage
|
||||
7. Set up Celery workers and beat scheduler
|
||||
8. Configure nginx reverse proxy
|
||||
9. Enable HTTPS
|
||||
|
||||
### Running Celery
|
||||
|
||||
```bash
|
||||
# Worker
|
||||
celery -A config worker -l INFO
|
||||
|
||||
# Beat scheduler
|
||||
celery -A config beat -l INFO
|
||||
```
|
||||
|
||||
## Related Services
|
||||
|
||||
- **nexus-5-auth**: Ory Kratos/Oathkeeper configuration and auth frontend
|
||||
- **nexus-5-emailer**: Rust-based email microservice
|
||||
- **nexus-5-scheduler**: Calendar integration service
|
||||
- **nexus-5-frontend-***: SvelteKit frontend applications
|
||||
|
||||
## License
|
||||
|
||||
MIT License - See LICENSE file for details.
|
||||
6
config/__init__.py
Normal file
6
config/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
# Django configuration module
|
||||
|
||||
# Import Celery app to ensure it's loaded when Django starts
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ('celery_app',)
|
||||
23
config/asgi.py
Normal file
23
config/asgi.py
Normal file
@ -0,0 +1,23 @@
|
||||
import os
|
||||
import django
|
||||
from django.core.asgi import get_asgi_application
|
||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||
from django.urls import path
|
||||
from strawberry.channels import GraphQLWSConsumer
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
django.setup()
|
||||
|
||||
from core.graphql.schema import schema
|
||||
from core.middleware import OryWebSocketAuthMiddleware
|
||||
from core.chat.consumers import ChatConsumer
|
||||
|
||||
application = ProtocolTypeRouter({
|
||||
'http': get_asgi_application(),
|
||||
'websocket': OryWebSocketAuthMiddleware(
|
||||
URLRouter([
|
||||
path('graphql/', GraphQLWSConsumer.as_asgi(schema=schema)),
|
||||
path('ws/chat/', ChatConsumer.as_asgi()),
|
||||
])
|
||||
),
|
||||
})
|
||||
51
config/celery.py
Normal file
51
config/celery.py
Normal file
@ -0,0 +1,51 @@
|
||||
"""
|
||||
Celery configuration for Nexus v5.
|
||||
Uses Redis as both broker and result backend (separate DB from Channels).
|
||||
"""
|
||||
import os
|
||||
from celery import Celery
|
||||
from celery.backends.redis import SentinelBackend
|
||||
|
||||
|
||||
class FixedSentinelBackend(SentinelBackend):
|
||||
"""
|
||||
Fixes Celery bug where SentinelBackend._params_from_url() doesn't copy
|
||||
'username' from URL params, breaking Redis/Valkey ACL authentication.
|
||||
|
||||
Celery only copies 'db' and 'password' but forgets 'username'.
|
||||
"""
|
||||
|
||||
def _params_from_url(self, url, defaults):
|
||||
connparams = super()._params_from_url(url, defaults)
|
||||
|
||||
# Fix: parent only copies 'db' and 'password', missing 'username'
|
||||
if connparams.get('hosts') and 'username' in connparams['hosts'][0]:
|
||||
connparams['username'] = connparams['hosts'][0]['username']
|
||||
|
||||
return connparams
|
||||
|
||||
# Set the default Django settings module for the 'celery' program.
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
|
||||
# Create Celery app
|
||||
app = Celery('nexus')
|
||||
|
||||
# Load configuration from Django settings, using a "CELERY_" prefix.
|
||||
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||
|
||||
# Load task modules from all registered Django apps.
|
||||
app.autodiscover_tasks()
|
||||
|
||||
# Import tasks after Django setup to ensure they're registered
|
||||
from django.conf import settings
|
||||
if settings.configured:
|
||||
try:
|
||||
from core.tasks import notifications, event_cleanup
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
@app.task(bind=True, ignore_result=True)
|
||||
def debug_task(self):
|
||||
"""Debug task for testing Celery setup"""
|
||||
print(f'Request: {self.request!r}')
|
||||
6
config/db_backend/__init__.py
Normal file
6
config/db_backend/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""
|
||||
Custom PostgreSQL database backend package for Vault credential rotation.
|
||||
|
||||
This package provides a Django database backend that automatically reloads
|
||||
credentials from Vault agent's rendered secret files.
|
||||
"""
|
||||
56
config/db_backend/base.py
Normal file
56
config/db_backend/base.py
Normal file
@ -0,0 +1,56 @@
|
||||
"""
|
||||
Custom PostgreSQL database backend that dynamically reloads credentials from Vault.
|
||||
|
||||
This wrapper ensures that Django picks up rotated database credentials from Vault
|
||||
without requiring a container restart. Credentials are re-read from the Vault agent's
|
||||
rendered secret files before each new connection is established.
|
||||
"""
|
||||
import os
|
||||
from django.db.backends.postgresql import base
|
||||
|
||||
|
||||
class DatabaseWrapper(base.DatabaseWrapper):
|
||||
"""PostgreSQL wrapper that reloads credentials from Vault secret files."""
|
||||
|
||||
def get_connection_params(self):
|
||||
"""
|
||||
Reload credentials from Vault files before connecting.
|
||||
|
||||
This method is called each time Django establishes a new database connection.
|
||||
It reads the latest credentials from /vault/secrets/app.env (maintained by
|
||||
Vault agent) and updates the connection parameters.
|
||||
|
||||
Falls back to environment variables if the Vault secret file is unavailable
|
||||
(e.g., in local development).
|
||||
"""
|
||||
params = super().get_connection_params()
|
||||
|
||||
# Determine which alias this is (default or admin)
|
||||
alias = getattr(self, 'alias', 'default')
|
||||
|
||||
if alias == 'admin':
|
||||
secret_file = '/vault/secrets/admin.env'
|
||||
user_var = 'DB_ADMIN_USER'
|
||||
password_var = 'DB_ADMIN_PASSWORD'
|
||||
else:
|
||||
secret_file = '/vault/secrets/app.env'
|
||||
user_var = 'DB_USER'
|
||||
password_var = 'DB_PASSWORD'
|
||||
|
||||
# Try to read fresh credentials from Vault agent's rendered file
|
||||
try:
|
||||
if os.path.exists(secret_file):
|
||||
with open(secret_file, 'r') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line.startswith(f'export {user_var}='):
|
||||
username = line.split('=', 1)[1].strip().strip('"').strip("'")
|
||||
params['user'] = username
|
||||
elif line.startswith(f'export {password_var}='):
|
||||
password = line.split('=', 1)[1].strip().strip('"').strip("'")
|
||||
params['password'] = password
|
||||
except (FileNotFoundError, PermissionError, IOError):
|
||||
# Fallback to environment variables (local development or error case)
|
||||
pass
|
||||
|
||||
return params
|
||||
385
config/settings.py
Normal file
385
config/settings.py
Normal file
@ -0,0 +1,385 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import dotenv
|
||||
|
||||
SITE_NAME = "Nexus v5"
|
||||
DISPATCH_TEAM_PROFILE_ID = os.getenv('DISPATCH_TEAM_PROFILE_ID')
|
||||
|
||||
# --- Security: Oathkeeper Verification ---
|
||||
OATHKEEPER_SECRET = os.getenv('OATHKEEPER_SECRET')
|
||||
|
||||
# --- AI Chat: Anthropic Claude API ---
|
||||
ANTHROPIC_API_KEY = os.getenv('ANTHROPIC_API_KEY', '')
|
||||
ANTHROPIC_MODEL = os.getenv('ANTHROPIC_MODEL', 'claude-sonnet-4-20250514')
|
||||
|
||||
# --- Initial Setup ---
|
||||
dotenv.load_dotenv()
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
SECRET_KEY = os.getenv('SECRET_KEY')
|
||||
DEBUG = os.getenv('DEBUG', 'False').lower() in ('true', '1', 't')
|
||||
ALLOWED_HOSTS = ['*']
|
||||
|
||||
# --- Unified Redis/Valkey Configuration ---
|
||||
REDIS_HOST = os.getenv('REDIS_HOST')
|
||||
REDIS_PORT = os.getenv('REDIS_PORT')
|
||||
REDIS_USERNAME = os.getenv('REDIS_USERNAME', '')
|
||||
REDIS_PASSWORD = os.getenv('REDIS_PASSWORD')
|
||||
REDIS_CLUSTER_MODE = os.getenv('REDIS_CLUSTER_MODE', 'False').lower() in ('true', '1', 't')
|
||||
# ACL auth format: username:password@ (username required for Valkey ACL)
|
||||
REDIS_AUTH = f"{REDIS_USERNAME}:{REDIS_PASSWORD}@" if REDIS_PASSWORD else ""
|
||||
|
||||
# Sentinel configuration (for HA failover)
|
||||
# Format: "host1:port1,host2:port2,host3:port3"
|
||||
REDIS_SENTINEL_HOSTS = os.getenv('REDIS_SENTINEL_HOSTS', '')
|
||||
REDIS_SENTINEL_MASTER = os.getenv('REDIS_SENTINEL_MASTER', 'valkey-ha')
|
||||
REDIS_SENTINEL_PASSWORD = os.getenv('REDIS_SENTINEL_PASSWORD', '') # Sentinel auth
|
||||
REDIS_SENTINEL_MODE = bool(REDIS_SENTINEL_HOSTS)
|
||||
|
||||
# Parse sentinel hosts into list of tuples [(host, port), ...]
|
||||
REDIS_SENTINELS = []
|
||||
if REDIS_SENTINEL_MODE:
|
||||
REDIS_SENTINELS = [
|
||||
(h.split(':')[0], int(h.split(':')[1]))
|
||||
for h in REDIS_SENTINEL_HOSTS.split(',')
|
||||
]
|
||||
|
||||
# --- Django Applications & Middleware ---
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'corsheaders',
|
||||
'daphne',
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.postgres',
|
||||
'core.apps.CoreConfig',
|
||||
'channels',
|
||||
'strawberry_django',
|
||||
'rest_framework',
|
||||
'storages',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'core.middleware.ConditionalCorsMiddleware',
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'core.middleware.OryHeaderAuthenticationMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
"http://localhost:5173",
|
||||
"https://app.example.com",
|
||||
]
|
||||
|
||||
CORS_ALLOWED_ORIGIN_REGEXES = [
|
||||
# Regex to allow any origin on the 192.168.100.x subnet
|
||||
r"^https?://192\.168\.100\.\d{1,3}(:\d+)?$",
|
||||
]
|
||||
|
||||
# CORS credentials support for cookie-based auth
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
|
||||
# Allow common headers for GraphQL
|
||||
CORS_ALLOW_HEADERS = [
|
||||
'accept',
|
||||
'accept-encoding',
|
||||
'authorization',
|
||||
'content-type',
|
||||
'dnt',
|
||||
'origin',
|
||||
'user-agent',
|
||||
'x-csrftoken',
|
||||
'x-requested-with',
|
||||
]
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
"https://api.example.com",
|
||||
"https://app.example.com",
|
||||
"https://local.example.com:5173"
|
||||
]
|
||||
|
||||
# --- Channels & ASGI ---
|
||||
ASGI_APPLICATION = 'config.asgi.application'
|
||||
if REDIS_SENTINEL_MODE:
|
||||
# Sentinel mode: use master discovery for HA failover
|
||||
_sentinel_host_config = {
|
||||
"sentinels": REDIS_SENTINELS,
|
||||
"master_name": REDIS_SENTINEL_MASTER,
|
||||
"password": REDIS_PASSWORD,
|
||||
"username": REDIS_USERNAME,
|
||||
"db": 0,
|
||||
}
|
||||
if REDIS_SENTINEL_PASSWORD:
|
||||
_sentinel_host_config["sentinel_kwargs"] = {"password": REDIS_SENTINEL_PASSWORD}
|
||||
CHANNEL_LAYERS = {
|
||||
'default': {
|
||||
'BACKEND': 'channels_valkey.core.ValkeyChannelLayer',
|
||||
'CONFIG': {
|
||||
"hosts": [_sentinel_host_config],
|
||||
"prefix": "nexus:channels",
|
||||
},
|
||||
},
|
||||
}
|
||||
elif REDIS_CLUSTER_MODE:
|
||||
# Use sharded pubsub for cluster mode
|
||||
CHANNEL_LAYERS = {
|
||||
'default': {
|
||||
'BACKEND': 'channels_valkey.pubsub.ValkeyPubSubChannelLayer',
|
||||
'CONFIG': {
|
||||
"hosts": [f"valkey://{REDIS_AUTH}{REDIS_HOST}:{REDIS_PORT}/0"],
|
||||
"prefix": "nexus:channels",
|
||||
},
|
||||
},
|
||||
}
|
||||
else:
|
||||
CHANNEL_LAYERS = {
|
||||
'default': {
|
||||
'BACKEND': 'channels_valkey.core.ValkeyChannelLayer',
|
||||
'CONFIG': {
|
||||
"hosts": [f"valkey://{REDIS_AUTH}{REDIS_HOST}:{REDIS_PORT}/0"],
|
||||
"prefix": "nexus:channels",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# --- Framework Settings ---
|
||||
STRAWBERRY_DJANGO = {
|
||||
'FIELD_DESCRIPTION_FROM_HELP_TEXT': True,
|
||||
'TYPE_DESCRIPTION_FROM_MODEL_DOCSTRING': True,
|
||||
'MUTATIONS_DEFAULT_HANDLE_ERRORS': True,
|
||||
}
|
||||
|
||||
# --- Security Settings ---
|
||||
USE_X_FORWARDED_HOST = True
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
|
||||
# --- Core Django Settings ---
|
||||
ROOT_URLCONF = 'config.urls'
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'APP_DIRS': True,
|
||||
'DIRS': [BASE_DIR / 'templates'],
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
# --- Databases & Caches ---
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'config.db_backend', # Custom backend for Vault credential reloading
|
||||
'NAME': os.getenv('DB_NAME'),
|
||||
'HOST': os.getenv('DB_HOST'),
|
||||
'PORT': os.getenv('DB_PORT'),
|
||||
'USER': os.environ.get('DB_USER'), # Fallback for local dev
|
||||
'PASSWORD': os.environ.get('DB_PASSWORD'), # Fallback for local dev
|
||||
'CONN_MAX_AGE': 600, # Keep connections for 10 minutes
|
||||
'CONN_HEALTH_CHECKS': True, # Verify connections before reuse
|
||||
},
|
||||
'admin': {
|
||||
'ENGINE': 'config.db_backend', # Custom backend for Vault credential reloading
|
||||
'NAME': os.getenv('DB_NAME'),
|
||||
'HOST': os.getenv('DB_HOST'),
|
||||
'PORT': os.getenv('DB_PORT'),
|
||||
'USER': os.environ.get('DB_ADMIN_USER'), # Fallback for local dev
|
||||
'PASSWORD': os.environ.get('DB_ADMIN_PASSWORD'), # Fallback for local dev
|
||||
'CONN_MAX_AGE': 600, # Keep connections for 10 minutes
|
||||
'CONN_HEALTH_CHECKS': True, # Verify connections before reuse
|
||||
}
|
||||
}
|
||||
if REDIS_SENTINEL_MODE:
|
||||
# Sentinel mode: use django-valkey with SentinelClient for HA failover
|
||||
_valkey_connection_kwargs = {"password": REDIS_PASSWORD}
|
||||
if REDIS_USERNAME:
|
||||
_valkey_connection_kwargs["username"] = REDIS_USERNAME
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_valkey.cache.ValkeyCache",
|
||||
"LOCATION": f"valkey://{REDIS_SENTINEL_MASTER}/0",
|
||||
"KEY_PREFIX": "nexus:cache",
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_valkey.client.SentinelClient",
|
||||
"SENTINELS": REDIS_SENTINELS,
|
||||
"CONNECTION_POOL_CLASS": "valkey.sentinel.SentinelConnectionPool",
|
||||
"CONNECTION_POOL_CLASS_KWARGS": _valkey_connection_kwargs,
|
||||
"SENTINEL_KWARGS": {"password": REDIS_SENTINEL_PASSWORD} if REDIS_SENTINEL_PASSWORD else {},
|
||||
},
|
||||
}
|
||||
}
|
||||
elif REDIS_CLUSTER_MODE:
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_valkey.cache.ValkeyCache",
|
||||
"LOCATION": f"valkey://{REDIS_AUTH}{REDIS_HOST}:{REDIS_PORT}/0",
|
||||
"KEY_PREFIX": "nexus:cache",
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_valkey.client.DefaultClient",
|
||||
"VALKEY_CLIENT_CLASS": "valkey.cluster.ValkeyCluster",
|
||||
"VALKEY_CLIENT_KWARGS": {
|
||||
"skip_full_coverage_check": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
else:
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_valkey.cache.ValkeyCache",
|
||||
"LOCATION": f"valkey://{REDIS_AUTH}{REDIS_HOST}:{REDIS_PORT}/0",
|
||||
"KEY_PREFIX": "nexus:cache",
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_valkey.client.DefaultClient",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
# --- Celery Configuration ---
|
||||
# All Redis usage on /0 with key prefixes for namespace isolation
|
||||
if REDIS_SENTINEL_MODE:
|
||||
# Sentinel mode: use master discovery for HA failover
|
||||
# Format: sentinel://user:pass@host1:port/db;sentinel://user:pass@host2:port/db;...
|
||||
# Each sentinel URL must include full credentials (for master connection after discovery)
|
||||
if REDIS_USERNAME and REDIS_PASSWORD:
|
||||
sentinel_urls = ';'.join([
|
||||
f"sentinel://{REDIS_USERNAME}:{REDIS_PASSWORD}@{h}:{p}/0"
|
||||
for h, p in REDIS_SENTINELS
|
||||
])
|
||||
elif REDIS_PASSWORD:
|
||||
sentinel_urls = ';'.join([
|
||||
f"sentinel://:{REDIS_PASSWORD}@{h}:{p}/0"
|
||||
for h, p in REDIS_SENTINELS
|
||||
])
|
||||
else:
|
||||
sentinel_urls = ';'.join([
|
||||
f"sentinel://{h}:{p}/0"
|
||||
for h, p in REDIS_SENTINELS
|
||||
])
|
||||
CELERY_BROKER_URL = sentinel_urls
|
||||
# Use custom backend class that fixes Celery's missing 'username' param for ACL auth
|
||||
CELERY_RESULT_BACKEND = f"config.celery.FixedSentinelBackend+{sentinel_urls}"
|
||||
CELERY_BROKER_TRANSPORT_OPTIONS = {
|
||||
'master_name': REDIS_SENTINEL_MASTER,
|
||||
'global_keyprefix': 'nexus:celery:',
|
||||
}
|
||||
CELERY_RESULT_BACKEND_TRANSPORT_OPTIONS = {
|
||||
'master_name': REDIS_SENTINEL_MASTER,
|
||||
'global_keyprefix': 'nexus:celery:',
|
||||
}
|
||||
# Sentinel authentication (if Sentinel itself requires auth, separate from master)
|
||||
if REDIS_SENTINEL_PASSWORD:
|
||||
CELERY_BROKER_TRANSPORT_OPTIONS['sentinel_kwargs'] = {'password': REDIS_SENTINEL_PASSWORD}
|
||||
CELERY_RESULT_BACKEND_TRANSPORT_OPTIONS['sentinel_kwargs'] = {'password': REDIS_SENTINEL_PASSWORD}
|
||||
elif REDIS_CLUSTER_MODE:
|
||||
# Celery 5.3+ supports cluster mode natively
|
||||
CELERY_BROKER_URL = f"redis://{REDIS_AUTH}{REDIS_HOST}:{REDIS_PORT}/0"
|
||||
CELERY_RESULT_BACKEND = f"redis://{REDIS_AUTH}{REDIS_HOST}:{REDIS_PORT}/0"
|
||||
CELERY_BROKER_TRANSPORT_OPTIONS = {
|
||||
'global_keyprefix': 'nexus:celery:',
|
||||
'fanout_prefix': True,
|
||||
'fanout_patterns': True,
|
||||
}
|
||||
CELERY_RESULT_BACKEND_TRANSPORT_OPTIONS = {'global_keyprefix': 'nexus:celery:'}
|
||||
CELERY_BROKER_USE_SSL = False
|
||||
CELERY_REDIS_BACKEND_USE_CLUSTER = True
|
||||
else:
|
||||
CELERY_BROKER_URL = f"redis://{REDIS_AUTH}{REDIS_HOST}:{REDIS_PORT}/0"
|
||||
CELERY_RESULT_BACKEND = f"redis://{REDIS_AUTH}{REDIS_HOST}:{REDIS_PORT}/0"
|
||||
CELERY_BROKER_TRANSPORT_OPTIONS = {'global_keyprefix': 'nexus:celery:'}
|
||||
CELERY_RESULT_BACKEND_TRANSPORT_OPTIONS = {'global_keyprefix': 'nexus:celery:'}
|
||||
CELERY_ACCEPT_CONTENT = ['json']
|
||||
CELERY_TASK_SERIALIZER = 'json'
|
||||
CELERY_RESULT_SERIALIZER = 'json'
|
||||
CELERY_TIMEZONE = 'America/New_York'
|
||||
CELERY_TASK_TRACK_STARTED = True
|
||||
CELERY_TASK_TIME_LIMIT = 30 * 60 # 30 minutes
|
||||
CELERY_TASK_SOFT_TIME_LIMIT = 25 * 60 # 25 minutes
|
||||
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
|
||||
|
||||
# Celery Beat Schedule (periodic tasks)
|
||||
from celery.schedules import crontab
|
||||
|
||||
CELERY_BEAT_SCHEDULE = {
|
||||
'cleanup-old-events': {
|
||||
'task': 'core.tasks.event_cleanup.cleanup_old_events',
|
||||
'schedule': crontab(hour=2, minute=0), # Run daily at 2 AM Eastern
|
||||
},
|
||||
'monitoring-incomplete-work-reminder': {
|
||||
'task': 'core.tasks.monitoring.run_monitoring_command',
|
||||
'schedule': crontab(hour=8, minute=0), # 8 AM Eastern
|
||||
'args': ['incomplete_work_reminder'],
|
||||
},
|
||||
'monitoring-nightly-assignments': {
|
||||
'task': 'core.tasks.monitoring.run_monitoring_command',
|
||||
'schedule': crontab(hour=18, minute=0), # 6 PM Eastern
|
||||
'args': ['nightly_assignments'],
|
||||
},
|
||||
}
|
||||
|
||||
# --- Emailer Microservice Configuration ---
|
||||
# Emailer is a Rust-based REST API for sending emails via Gmail API
|
||||
EMAILER_BASE_URL = os.getenv('EMAILER_BASE_URL', 'https://email.example.com')
|
||||
EMAILER_API_KEY = os.getenv('EMAILER_API_KEY', '')
|
||||
EMAILER_DEFAULT_SENDER = os.getenv('EMAILER_DEFAULT_SENDER', 'noreply@example.com')
|
||||
|
||||
# --- Security & Static Files ---
|
||||
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'},
|
||||
]
|
||||
STATIC_URL = '/static/'
|
||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
|
||||
# --- Media Files & File Upload ---
|
||||
MEDIA_URL = '/api/media/'
|
||||
|
||||
# S3 Storage Configuration (Garage S3-compatible cluster)
|
||||
# boto3/django-storages use AWS_* naming convention but connect to Garage
|
||||
AWS_ACCESS_KEY_ID = os.getenv('AWS_ACCESS_KEY_ID')
|
||||
AWS_SECRET_ACCESS_KEY = os.getenv('AWS_SECRET_ACCESS_KEY')
|
||||
AWS_STORAGE_BUCKET_NAME = os.getenv('AWS_STORAGE_BUCKET_NAME', 'nexus-media')
|
||||
AWS_S3_ENDPOINT_URL = os.getenv('AWS_S3_ENDPOINT_URL', 'http://10.10.10.39:3900')
|
||||
AWS_S3_REGION_NAME = 'garage' # Garage ignores this but boto3 requires it
|
||||
AWS_DEFAULT_ACL = None # Use bucket default
|
||||
AWS_QUERYSTRING_AUTH = False # Nginx handles auth, not pre-signed URLs
|
||||
AWS_S3_FILE_OVERWRITE = False # Preserve unique filenames
|
||||
|
||||
# Legacy MEDIA_ROOT for local dev fallback (not used in production with S3)
|
||||
MEDIA_ROOT = BASE_DIR / 'media'
|
||||
|
||||
# Django 4.2+ STORAGES configuration (replaces deprecated DEFAULT_FILE_STORAGE)
|
||||
# Uses custom GarageS3Storage that returns nginx-proxied URLs instead of direct S3 URLs
|
||||
STORAGES = {
|
||||
"default": {
|
||||
"BACKEND": "config.storage.GarageS3Storage",
|
||||
},
|
||||
"staticfiles": {
|
||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
},
|
||||
}
|
||||
|
||||
# Increased limits for video uploads (250 MB max)
|
||||
DATA_UPLOAD_MAX_MEMORY_SIZE = 250 * 1024 * 1024 # 250 MB
|
||||
FILE_UPLOAD_MAX_MEMORY_SIZE = 250 * 1024 * 1024 # 250 MB
|
||||
|
||||
# --- Internationalization ---
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
TIME_ZONE = 'UTC'
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
27
config/storage.py
Normal file
27
config/storage.py
Normal file
@ -0,0 +1,27 @@
|
||||
"""
|
||||
Custom S3 storage backend for Garage that returns nginx-proxied URLs.
|
||||
|
||||
Instead of returning direct S3 URLs like:
|
||||
http://10.10.10.39:3900/nexus-media/uploads/...
|
||||
|
||||
Returns relative URLs that go through nginx:
|
||||
/api/media/uploads/...
|
||||
|
||||
Nginx then handles auth and proxies to Garage's website mode.
|
||||
"""
|
||||
from django.conf import settings
|
||||
from storages.backends.s3boto3 import S3Boto3Storage
|
||||
|
||||
|
||||
class GarageS3Storage(S3Boto3Storage):
|
||||
"""
|
||||
S3Boto3Storage subclass that returns URLs through nginx proxy.
|
||||
"""
|
||||
|
||||
def url(self, name, parameters=None, expire=None, http_method=None):
|
||||
"""
|
||||
Return a URL that goes through our nginx proxy instead of direct S3.
|
||||
"""
|
||||
# Return relative URL that nginx will proxy to S3
|
||||
# MEDIA_URL is '/api/media/' so this becomes '/api/media/uploads/...'
|
||||
return f"{settings.MEDIA_URL}{name}"
|
||||
40
config/urls.py
Normal file
40
config/urls.py
Normal file
@ -0,0 +1,40 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import path, re_path
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.http import HttpResponseForbidden
|
||||
from strawberry.django.views import AsyncGraphQLView
|
||||
from core.graphql.schema import schema
|
||||
from core.views import (
|
||||
upload_service_session_image,
|
||||
upload_project_session_image,
|
||||
upload_service_session_video,
|
||||
upload_project_session_video,
|
||||
serve_protected_media,
|
||||
media_auth_check,
|
||||
)
|
||||
|
||||
|
||||
class AdminOnlyGraphQLView(AsyncGraphQLView):
|
||||
"""GraphQL view that restricts GraphiQL IDE to ADMIN role only."""
|
||||
|
||||
async def render_graphql_ide(self, request):
|
||||
profile = getattr(request, 'profile', None)
|
||||
if profile and hasattr(profile, 'role') and profile.role == 'ADMIN':
|
||||
return await super().render_graphql_ide(request)
|
||||
return HttpResponseForbidden("GraphiQL is only available to administrators")
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path(
|
||||
"graphql/",
|
||||
csrf_exempt(AdminOnlyGraphQLView.as_view(schema=schema, graphiql=True))
|
||||
),
|
||||
path("api/upload/photo/service/", csrf_exempt(upload_service_session_image), name="upload_service_session_image"),
|
||||
path("api/upload/photo/project/", csrf_exempt(upload_project_session_image), name="upload_project_session_image"),
|
||||
path("api/upload/video/service/", csrf_exempt(upload_service_session_video), name="upload_service_session_video"),
|
||||
path("api/upload/video/project/", csrf_exempt(upload_project_session_video), name="upload_project_session_video"),
|
||||
re_path(r"^api/media/(?P<path>.*)$", serve_protected_media, name="serve_protected_media"),
|
||||
# Auth check endpoint for nginx auth_request (S3 media proxy)
|
||||
re_path(r"^api/media-auth/(?P<path>.*)$", media_auth_check, name="media_auth_check"),
|
||||
]
|
||||
0
core/__init__.py
Normal file
0
core/__init__.py
Normal file
645
core/admin.py
Normal file
645
core/admin.py
Normal file
@ -0,0 +1,645 @@
|
||||
from django.contrib import admin
|
||||
from core.models import (
|
||||
Customer,
|
||||
CustomerAddress,
|
||||
CustomerContact,
|
||||
Account,
|
||||
AccountAddress,
|
||||
AccountContact,
|
||||
Service,
|
||||
Project,
|
||||
Report,
|
||||
Revenue,
|
||||
Labor,
|
||||
Schedule,
|
||||
Invoice,
|
||||
AccountPunchlist,
|
||||
ProjectPunchlist,
|
||||
CustomerProfile,
|
||||
TeamProfile,
|
||||
Scope,
|
||||
Area,
|
||||
Task,
|
||||
TaskCompletion,
|
||||
ScopeTemplate,
|
||||
AreaTemplate,
|
||||
TaskTemplate,
|
||||
ProjectScope,
|
||||
ProjectScopeCategory,
|
||||
ProjectScopeTask,
|
||||
ProjectScopeTaskCompletion,
|
||||
ProjectScopeTemplate,
|
||||
ProjectAreaTemplate,
|
||||
ProjectTaskTemplate,
|
||||
ServiceSession,
|
||||
ProjectSession,
|
||||
ServiceSessionNote,
|
||||
ProjectSessionNote,
|
||||
# Events & Notifications
|
||||
Event,
|
||||
NotificationRule,
|
||||
Notification,
|
||||
NotificationDelivery,
|
||||
# Messaging
|
||||
Conversation,
|
||||
ConversationParticipant,
|
||||
Message,
|
||||
MessageReadReceipt,
|
||||
# Session Media
|
||||
ServiceSessionImage,
|
||||
ProjectSessionImage,
|
||||
ServiceSessionVideo,
|
||||
ProjectSessionVideo,
|
||||
# Chat
|
||||
ChatConversation,
|
||||
ChatMessage,
|
||||
)
|
||||
|
||||
|
||||
@admin.register(Customer)
|
||||
class CustomerAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "status", "start_date", "end_date")
|
||||
list_filter = ("status",)
|
||||
search_fields = ("name",)
|
||||
|
||||
|
||||
@admin.register(Account)
|
||||
class AccountAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "customer", "status", "start_date", "end_date")
|
||||
list_filter = ("status", "customer")
|
||||
search_fields = ("name", "customer__name")
|
||||
|
||||
|
||||
@admin.register(CustomerAddress)
|
||||
class CustomerAddressAdmin(admin.ModelAdmin):
|
||||
list_display = ("customer", "address_type", "is_primary", "is_active")
|
||||
list_filter = ("address_type", "is_primary", "is_active")
|
||||
search_fields = ("customer__name", "street_address", "city")
|
||||
|
||||
|
||||
@admin.register(CustomerContact)
|
||||
class CustomerContactAdmin(admin.ModelAdmin):
|
||||
list_display = ("full_name", "customer", "email", "phone", "is_primary", "is_active")
|
||||
list_filter = ("is_primary", "is_active")
|
||||
search_fields = ("first_name", "last_name", "customer__name", "email", "phone")
|
||||
|
||||
|
||||
@admin.register(AccountAddress)
|
||||
class AccountAddressAdmin(admin.ModelAdmin):
|
||||
list_display = ("account", "street_address", "city", "is_primary", "is_active")
|
||||
list_filter = ("is_primary", "is_active")
|
||||
search_fields = ("account__name", "street_address", "city")
|
||||
|
||||
|
||||
@admin.register(AccountContact)
|
||||
class AccountContactAdmin(admin.ModelAdmin):
|
||||
list_display = ("full_name", "account", "email", "phone", "is_primary", "is_active")
|
||||
list_filter = ("is_primary", "is_active")
|
||||
search_fields = ("first_name", "last_name", "account__name", "email", "phone")
|
||||
|
||||
|
||||
@admin.register(Service)
|
||||
class ServiceAdmin(admin.ModelAdmin):
|
||||
list_display = ("account_address", "date", "status")
|
||||
list_filter = ("status", "date")
|
||||
search_fields = ("account_address__account__name",)
|
||||
|
||||
|
||||
@admin.register(Project)
|
||||
class ProjectAdmin(admin.ModelAdmin):
|
||||
list_display = ("customer", "account_address", "date", "status", "labor", "amount")
|
||||
list_filter = ("status", "date", "customer")
|
||||
search_fields = (
|
||||
"customer__name",
|
||||
"account_address__account__name",
|
||||
"street_address",
|
||||
"city",
|
||||
"state",
|
||||
"zip_code",
|
||||
)
|
||||
|
||||
|
||||
@admin.register(Report)
|
||||
class ReportAdmin(admin.ModelAdmin):
|
||||
list_display = ("team_member", "date")
|
||||
list_filter = ("date",)
|
||||
search_fields = ("team_member__first_name", "team_member__last_name")
|
||||
|
||||
|
||||
@admin.register(Revenue)
|
||||
class RevenueAdmin(admin.ModelAdmin):
|
||||
list_display = ("account", "amount", "start_date", "end_date")
|
||||
list_filter = ("start_date",)
|
||||
search_fields = ("account__name",)
|
||||
|
||||
|
||||
@admin.register(Labor)
|
||||
class LaborAdmin(admin.ModelAdmin):
|
||||
list_display = ("account_address", "amount", "start_date", "end_date")
|
||||
list_filter = ("start_date",)
|
||||
search_fields = ("account_address__account__name",)
|
||||
|
||||
|
||||
@admin.register(Schedule)
|
||||
class ScheduleAdmin(admin.ModelAdmin):
|
||||
list_display = ("account_address", "start_date", "end_date", "weekend_service")
|
||||
list_filter = ("weekend_service",)
|
||||
search_fields = ("account_address__account__name",)
|
||||
|
||||
|
||||
@admin.register(Invoice)
|
||||
class InvoiceAdmin(admin.ModelAdmin):
|
||||
list_display = ("customer", "date", "status", "date_paid", "payment_type")
|
||||
list_filter = ("status", "date")
|
||||
search_fields = ("customer__name",)
|
||||
|
||||
|
||||
@admin.register(AccountPunchlist)
|
||||
class AccountPunchlistAdmin(admin.ModelAdmin):
|
||||
list_display = ("account", "date")
|
||||
list_filter = ("date",)
|
||||
search_fields = ("account__name",)
|
||||
|
||||
|
||||
@admin.register(ProjectPunchlist)
|
||||
class ProjectPunchlistAdmin(admin.ModelAdmin):
|
||||
list_display = ("project", "date")
|
||||
list_filter = ("date",)
|
||||
search_fields = (
|
||||
"project__account_address__account__name",
|
||||
"project__street_address",
|
||||
"project__city",
|
||||
"project__state",
|
||||
"project__zip_code",
|
||||
"project__customer__name",
|
||||
)
|
||||
|
||||
|
||||
@admin.register(CustomerProfile)
|
||||
class CustomerProfileAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "get_customers", "status")
|
||||
list_filter = ("status",)
|
||||
search_fields = ("user__username", "first_name", "last_name", "email")
|
||||
filter_horizontal = ("customers",)
|
||||
|
||||
def get_customers(self, obj):
|
||||
"""Display comma-separated list of customers"""
|
||||
return ", ".join([c.name for c in obj.customers.all()])
|
||||
get_customers.short_description = "Customers"
|
||||
|
||||
|
||||
@admin.register(TeamProfile)
|
||||
class TeamProfileAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "first_name", "last_name", "status")
|
||||
list_filter = ("status",)
|
||||
search_fields = ("user__username", "first_name", "last_name")
|
||||
|
||||
|
||||
@admin.register(Scope)
|
||||
class ScopeAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "account", "account_address", "is_active")
|
||||
list_filter = ("is_active", "account")
|
||||
search_fields = ("name", "account__name", "account_address__street_address")
|
||||
|
||||
|
||||
class TaskTemplateInline(admin.TabularInline):
|
||||
model = TaskTemplate
|
||||
extra = 1
|
||||
fields = ("description", "frequency", "order", "is_conditional", "estimated_minutes")
|
||||
ordering = ("order",)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class AreaTemplateInline(admin.TabularInline):
|
||||
model = AreaTemplate
|
||||
extra = 1
|
||||
fields = ("name", "order")
|
||||
ordering = ("order",)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
@admin.register(ScopeTemplate)
|
||||
class ScopeTemplateAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "is_active")
|
||||
list_filter = ("is_active",)
|
||||
search_fields = ("name", "description")
|
||||
inlines = (AreaTemplateInline,)
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
@admin.register(AreaTemplate)
|
||||
class AreaTemplateAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "scope_template", "order")
|
||||
list_filter = ("scope_template",)
|
||||
search_fields = ("name", "scope_template__name")
|
||||
inlines = (TaskTemplateInline,)
|
||||
ordering = ("scope_template", "order", "name")
|
||||
|
||||
|
||||
@admin.register(TaskTemplate)
|
||||
class TaskTemplateAdmin(admin.ModelAdmin):
|
||||
list_display = ("short_description", "area_template", "frequency", "order", "is_conditional")
|
||||
list_filter = ("frequency", "is_conditional", "area_template__scope_template")
|
||||
search_fields = ("description", "area_template__name", "area_template__scope_template__name")
|
||||
ordering = ("area_template", "order")
|
||||
|
||||
def short_description(self, obj):
|
||||
return (obj.description or "")[:60]
|
||||
|
||||
short_description.short_description = "Description"
|
||||
|
||||
|
||||
@admin.register(ServiceSession)
|
||||
class ServiceSessionAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"service",
|
||||
"account",
|
||||
"account_address",
|
||||
"scope",
|
||||
"start",
|
||||
"end",
|
||||
"created_by",
|
||||
"closed_by",
|
||||
"is_active",
|
||||
)
|
||||
list_filter = ("start", "end", "account", "scope")
|
||||
search_fields = (
|
||||
"service__account_address__account__name",
|
||||
"account_address__street_address",
|
||||
"account_address__city",
|
||||
"created_by__first_name",
|
||||
"created_by__last_name",
|
||||
)
|
||||
ordering = ("-start",)
|
||||
readonly_fields = ("duration_seconds",)
|
||||
filter_horizontal = ("completed_tasks",)
|
||||
|
||||
|
||||
@admin.register(ProjectScope)
|
||||
class ProjectScopeAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "project", "account", "account_address", "is_active")
|
||||
list_filter = ("is_active", "project", "account")
|
||||
search_fields = (
|
||||
"name",
|
||||
"project__customer__name",
|
||||
"project__account_address__account__name",
|
||||
"account__name",
|
||||
"account_address__street_address",
|
||||
)
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
@admin.register(ProjectScopeCategory)
|
||||
class ProjectScopeCategoryAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "scope", "order")
|
||||
list_filter = ("scope",)
|
||||
search_fields = ("name", "scope__name")
|
||||
ordering = ("scope", "order", "name")
|
||||
|
||||
|
||||
@admin.register(ProjectScopeTask)
|
||||
class ProjectScopeTaskAdmin(admin.ModelAdmin):
|
||||
list_display = ("short_description", "category", "order", "estimated_minutes")
|
||||
list_filter = ("category__scope",)
|
||||
search_fields = ("description", "category__name", "category__scope__name")
|
||||
ordering = ("category", "order")
|
||||
|
||||
def short_description(self, obj):
|
||||
return (obj.description or "")[:60]
|
||||
|
||||
short_description.short_description = "Description"
|
||||
|
||||
|
||||
class ProjectTaskTemplateInline(admin.TabularInline):
|
||||
model = ProjectTaskTemplate
|
||||
extra = 1
|
||||
fields = ("description", "order", "estimated_minutes")
|
||||
ordering = ("order",)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class ProjectAreaTemplateInline(admin.TabularInline):
|
||||
model = ProjectAreaTemplate
|
||||
extra = 1
|
||||
fields = ("name", "order")
|
||||
ordering = ("order",)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
@admin.register(ProjectScopeTemplate)
|
||||
class ProjectScopeTemplateAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "is_active")
|
||||
list_filter = ("is_active",)
|
||||
search_fields = ("name", "description")
|
||||
inlines = (ProjectAreaTemplateInline,)
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
@admin.register(ProjectAreaTemplate)
|
||||
class ProjectAreaTemplateAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "scope_template", "order")
|
||||
list_filter = ("scope_template",)
|
||||
search_fields = ("name", "scope_template__name")
|
||||
inlines = (ProjectTaskTemplateInline,)
|
||||
ordering = ("scope_template", "order", "name")
|
||||
|
||||
|
||||
@admin.register(ProjectTaskTemplate)
|
||||
class ProjectTaskTemplateAdmin(admin.ModelAdmin):
|
||||
list_display = ("short_description", "area_template", "order", "estimated_minutes")
|
||||
list_filter = ("area_template__scope_template",)
|
||||
search_fields = ("description", "area_template__name", "area_template__scope_template__name")
|
||||
ordering = ("area_template", "order")
|
||||
|
||||
def short_description(self, obj):
|
||||
return (obj.description or "")[:60]
|
||||
|
||||
short_description.short_description = "Description"
|
||||
|
||||
|
||||
@admin.register(ProjectSession)
|
||||
class ProjectSessionAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"project",
|
||||
"account",
|
||||
"account_address",
|
||||
"scope",
|
||||
"start",
|
||||
"end",
|
||||
"created_by",
|
||||
"closed_by",
|
||||
"is_active",
|
||||
)
|
||||
list_filter = ("start", "end", "account", "scope")
|
||||
search_fields = (
|
||||
"project__account_address__account__name",
|
||||
"account_address__street_address",
|
||||
"account_address__city",
|
||||
"created_by__first_name",
|
||||
"created_by__last_name",
|
||||
)
|
||||
ordering = ("-start",)
|
||||
readonly_fields = ("duration_seconds",)
|
||||
|
||||
|
||||
# Admin registrations for Area, Task, TaskCompletion, and ProjectScopeTaskCompletion
|
||||
class TaskInline(admin.TabularInline):
|
||||
model = Task
|
||||
extra = 1
|
||||
fields = ("description", "frequency", "order", "is_conditional", "estimated_minutes")
|
||||
ordering = ("order",)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
@admin.register(Area)
|
||||
class AreaAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "scope", "order")
|
||||
list_filter = ("scope",)
|
||||
search_fields = ("name", "scope__name")
|
||||
ordering = ("scope", "order", "name")
|
||||
inlines = (TaskInline,)
|
||||
|
||||
|
||||
@admin.register(Task)
|
||||
class TaskAdmin(admin.ModelAdmin):
|
||||
list_display = ("short_description", "area", "frequency", "order", "is_conditional")
|
||||
list_filter = ("frequency", "is_conditional", "area__scope")
|
||||
search_fields = ("description", "area__name", "area__scope__name")
|
||||
ordering = ("area", "order")
|
||||
|
||||
def short_description(self, obj):
|
||||
return (obj.description or "")[:60]
|
||||
|
||||
short_description.short_description = "Description"
|
||||
|
||||
|
||||
@admin.register(TaskCompletion)
|
||||
class TaskCompletionAdmin(admin.ModelAdmin):
|
||||
list_display = ("task", "service", "account_address", "completed_by", "completed_at", "year", "month")
|
||||
list_filter = ("completed_at", "completed_by", "task__area__scope")
|
||||
search_fields = (
|
||||
"task__description",
|
||||
"task__area__name",
|
||||
"task__area__scope__name",
|
||||
"service__account_address__account__name",
|
||||
"service__account_address__street_address",
|
||||
)
|
||||
ordering = ("-completed_at",)
|
||||
|
||||
|
||||
@admin.register(ProjectScopeTaskCompletion)
|
||||
class ProjectScopeTaskCompletionAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"task",
|
||||
"project",
|
||||
"account",
|
||||
"account_address",
|
||||
"completed_by",
|
||||
"completed_at",
|
||||
)
|
||||
list_filter = ("completed_at", "completed_by", "task__category__scope", "project", "account")
|
||||
search_fields = (
|
||||
"task__description",
|
||||
"task__category__name",
|
||||
"task__category__scope__name",
|
||||
"project__customer__name",
|
||||
"project__account_address__account__name",
|
||||
"account__name",
|
||||
"account_address__street_address",
|
||||
)
|
||||
ordering = ("-completed_at",)
|
||||
|
||||
|
||||
@admin.register(ServiceSessionNote)
|
||||
class ServiceSessionNoteAdmin(admin.ModelAdmin):
|
||||
list_display = ("session", "short_content", "author", "internal", "created_at")
|
||||
list_filter = ("internal", "created_at", "author")
|
||||
search_fields = (
|
||||
"content",
|
||||
"session__service__account_address__account__name",
|
||||
"author__first_name",
|
||||
"author__last_name",
|
||||
)
|
||||
ordering = ("-created_at",)
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
def short_content(self, obj):
|
||||
return (obj.content or "")[:60]
|
||||
|
||||
short_content.short_description = "Content"
|
||||
|
||||
|
||||
@admin.register(ProjectSessionNote)
|
||||
class ProjectSessionNoteAdmin(admin.ModelAdmin):
|
||||
list_display = ("session", "short_content", "author", "internal", "created_at")
|
||||
list_filter = ("internal", "created_at", "author")
|
||||
search_fields = (
|
||||
"content",
|
||||
"session__project__customer__name",
|
||||
"author__first_name",
|
||||
"author__last_name",
|
||||
)
|
||||
ordering = ("-created_at",)
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
def short_content(self, obj):
|
||||
return (obj.content or "")[:60]
|
||||
|
||||
short_content.short_description = "Content"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Events & Notifications
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@admin.register(Event)
|
||||
class EventAdmin(admin.ModelAdmin):
|
||||
list_display = ("event_type", "entity_type", "entity_id", "created_at")
|
||||
list_filter = ("event_type", "entity_type", "created_at")
|
||||
search_fields = ("entity_type", "entity_id")
|
||||
ordering = ("-created_at",)
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
|
||||
@admin.register(NotificationRule)
|
||||
class NotificationRuleAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "is_active", "get_channels", "created_at")
|
||||
list_filter = ("is_active",)
|
||||
search_fields = ("name", "description")
|
||||
filter_horizontal = ("target_team_profiles", "target_customer_profiles")
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
def get_channels(self, obj):
|
||||
return ", ".join(obj.channels) if obj.channels else ""
|
||||
|
||||
get_channels.short_description = "Channels"
|
||||
|
||||
|
||||
@admin.register(Notification)
|
||||
class NotificationAdmin(admin.ModelAdmin):
|
||||
list_display = ("subject", "event", "status", "read_at", "created_at")
|
||||
list_filter = ("status", "created_at")
|
||||
search_fields = ("subject", "body")
|
||||
ordering = ("-created_at",)
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
|
||||
@admin.register(NotificationDelivery)
|
||||
class NotificationDeliveryAdmin(admin.ModelAdmin):
|
||||
list_display = ("notification", "channel", "status", "attempts", "sent_at")
|
||||
list_filter = ("channel", "status")
|
||||
ordering = ("-created_at",)
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Messaging
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@admin.register(Conversation)
|
||||
class ConversationAdmin(admin.ModelAdmin):
|
||||
list_display = ("subject", "conversation_type", "last_message_at", "is_archived")
|
||||
list_filter = ("conversation_type", "is_archived")
|
||||
search_fields = ("subject",)
|
||||
ordering = ("-last_message_at",)
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
|
||||
@admin.register(ConversationParticipant)
|
||||
class ConversationParticipantAdmin(admin.ModelAdmin):
|
||||
list_display = ("conversation", "unread_count", "is_muted", "is_archived", "joined_at")
|
||||
list_filter = ("is_muted", "is_archived")
|
||||
ordering = ("-joined_at",)
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
|
||||
@admin.register(Message)
|
||||
class MessageAdmin(admin.ModelAdmin):
|
||||
list_display = ("conversation", "short_body", "is_system_message", "created_at")
|
||||
list_filter = ("is_system_message", "created_at")
|
||||
search_fields = ("body",)
|
||||
ordering = ("-created_at",)
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
def short_body(self, obj):
|
||||
return (obj.body or "")[:60]
|
||||
|
||||
short_body.short_description = "Body"
|
||||
|
||||
|
||||
@admin.register(MessageReadReceipt)
|
||||
class MessageReadReceiptAdmin(admin.ModelAdmin):
|
||||
list_display = ("message", "read_at")
|
||||
ordering = ("-read_at",)
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Session Media
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@admin.register(ServiceSessionImage)
|
||||
class ServiceSessionImageAdmin(admin.ModelAdmin):
|
||||
list_display = ("service_session", "title", "created_at")
|
||||
list_filter = ("created_at",)
|
||||
search_fields = ("title",)
|
||||
ordering = ("-created_at",)
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
|
||||
@admin.register(ProjectSessionImage)
|
||||
class ProjectSessionImageAdmin(admin.ModelAdmin):
|
||||
list_display = ("project_session", "title", "created_at")
|
||||
list_filter = ("created_at",)
|
||||
search_fields = ("title",)
|
||||
ordering = ("-created_at",)
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
|
||||
@admin.register(ServiceSessionVideo)
|
||||
class ServiceSessionVideoAdmin(admin.ModelAdmin):
|
||||
list_display = ("service_session", "title", "duration_seconds", "created_at")
|
||||
list_filter = ("created_at",)
|
||||
search_fields = ("title",)
|
||||
ordering = ("-created_at",)
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
|
||||
@admin.register(ProjectSessionVideo)
|
||||
class ProjectSessionVideoAdmin(admin.ModelAdmin):
|
||||
list_display = ("project_session", "title", "duration_seconds", "created_at")
|
||||
list_filter = ("created_at",)
|
||||
search_fields = ("title",)
|
||||
ordering = ("-created_at",)
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Chat
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@admin.register(ChatConversation)
|
||||
class ChatConversationAdmin(admin.ModelAdmin):
|
||||
list_display = ("team_profile", "title", "is_active", "created_at", "updated_at")
|
||||
list_filter = ("is_active", "created_at")
|
||||
search_fields = ("title", "team_profile__first_name", "team_profile__last_name")
|
||||
ordering = ("-updated_at",)
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
|
||||
@admin.register(ChatMessage)
|
||||
class ChatMessageAdmin(admin.ModelAdmin):
|
||||
list_display = ("conversation", "role", "short_content", "created_at")
|
||||
list_filter = ("role", "created_at")
|
||||
search_fields = ("content",)
|
||||
ordering = ("-created_at",)
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
def short_content(self, obj):
|
||||
return (obj.content or "")[:60]
|
||||
|
||||
short_content.short_description = "Content"
|
||||
25
core/apps.py
Normal file
25
core/apps.py
Normal file
@ -0,0 +1,25 @@
|
||||
import logging
|
||||
from django.apps import AppConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# HEIF/HEIC image format support for iOS photo uploads
|
||||
try:
|
||||
import pillow_heif as _pillow_heif
|
||||
except ImportError:
|
||||
_pillow_heif = None # type: ignore
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'core'
|
||||
|
||||
def ready(self):
|
||||
# Register HEIF/HEIC image format support for iOS photo uploads
|
||||
if _pillow_heif is not None:
|
||||
_pillow_heif.register_heif_opener()
|
||||
logger.info("HEIF image format support registered successfully")
|
||||
else:
|
||||
logger.warning("pillow-heif not installed, HEIC/HEIF images from iOS devices will not be supported")
|
||||
|
||||
logger.info("Core is ready.")
|
||||
1
core/chat/__init__.py
Normal file
1
core/chat/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Chat module for AI assistant integration
|
||||
261
core/chat/consumers.py
Normal file
261
core/chat/consumers.py
Normal file
@ -0,0 +1,261 @@
|
||||
"""
|
||||
WebSocket consumer for AI chat.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
from channels.db import database_sync_to_async
|
||||
|
||||
from core.models import TeamProfile
|
||||
from core.models.chat import ChatConversation
|
||||
from core.chat.service import ChatService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChatConsumer(AsyncWebsocketConsumer):
|
||||
"""
|
||||
WebSocket consumer for AI chat with Claude.
|
||||
|
||||
Handles:
|
||||
- Connection authentication (via OryWebSocketAuthMiddleware)
|
||||
- Message streaming
|
||||
- Conversation history
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.profile: Optional[TeamProfile] = None
|
||||
self.chat_service: Optional[ChatService] = None
|
||||
|
||||
async def connect(self):
|
||||
"""Handle WebSocket connection."""
|
||||
# Get profile from scope (set by OryWebSocketAuthMiddleware)
|
||||
self.profile = self.scope.get('profile')
|
||||
|
||||
if not self.profile:
|
||||
logger.warning("Chat connection rejected - no profile")
|
||||
await self.close(code=4401)
|
||||
return
|
||||
|
||||
# Only allow team profiles
|
||||
if not isinstance(self.profile, TeamProfile):
|
||||
logger.warning("Chat connection rejected - not a team profile")
|
||||
await self.close(code=4403)
|
||||
return
|
||||
|
||||
# Initialize chat service
|
||||
self.chat_service = ChatService(self.profile)
|
||||
|
||||
await self.accept()
|
||||
|
||||
# Send welcome message
|
||||
await self.send_json({
|
||||
"type": "connected",
|
||||
"user": {
|
||||
"id": str(self.profile.id),
|
||||
"name": f"{self.profile.first_name} {self.profile.last_name}".strip(),
|
||||
"email": self.profile.email,
|
||||
}
|
||||
})
|
||||
|
||||
# Send role-based intro message
|
||||
await self.send_json({
|
||||
"type": "intro",
|
||||
"content": self._get_intro_message()
|
||||
})
|
||||
|
||||
def _get_intro_message(self) -> str:
|
||||
"""Get intro message based on user role."""
|
||||
first_name = self.profile.first_name or "there"
|
||||
role = getattr(self.profile, 'role', None)
|
||||
|
||||
if role == 'ADMIN':
|
||||
return (
|
||||
f"Hey {first_name}! I'm your Nexus assistant. As an admin, I can help you with:\n\n"
|
||||
"• **View & manage** all services, projects, and team assignments\n"
|
||||
"• **Create & schedule** new services and projects\n"
|
||||
"• **Access reports** and system statistics\n"
|
||||
"• **Manage notifications** and team settings\n\n"
|
||||
"What would you like to do today?"
|
||||
)
|
||||
elif role == 'TEAM_LEADER':
|
||||
return (
|
||||
f"Hey {first_name}! I'm your Nexus assistant. As a team leader, I can help you with:\n\n"
|
||||
"• **View schedules** for you and your team\n"
|
||||
"• **Check service & project details** across accounts\n"
|
||||
"• **Track work sessions** and task completion\n"
|
||||
"• **Access customer and account information**\n\n"
|
||||
"What can I help you with?"
|
||||
)
|
||||
else: # TEAM_MEMBER
|
||||
return (
|
||||
f"Hey {first_name}! I'm your Nexus assistant. I can help you with:\n\n"
|
||||
"• **View your schedule** and assigned work\n"
|
||||
"• **Check service & project details** for your assignments\n"
|
||||
"• **Manage work sessions** and mark tasks complete\n"
|
||||
"• **Track your notifications**\n\n"
|
||||
"What do you need help with?"
|
||||
)
|
||||
|
||||
async def disconnect(self, close_code):
|
||||
"""Handle WebSocket disconnection."""
|
||||
logger.info(f"Chat disconnected: {close_code}")
|
||||
|
||||
async def receive(self, text_data):
|
||||
"""Handle incoming WebSocket messages."""
|
||||
try:
|
||||
data = json.loads(text_data)
|
||||
except json.JSONDecodeError:
|
||||
await self.send_json({"type": "error", "error": "Invalid JSON"})
|
||||
return
|
||||
|
||||
message_type = data.get("type")
|
||||
|
||||
if message_type == "chat":
|
||||
await self.handle_chat(data)
|
||||
elif message_type == "history":
|
||||
await self.handle_history(data)
|
||||
elif message_type == "conversations":
|
||||
await self.handle_list_conversations()
|
||||
elif message_type == "new_conversation":
|
||||
await self.handle_new_conversation()
|
||||
else:
|
||||
await self.send_json({"type": "error", "error": f"Unknown message type: {message_type}"})
|
||||
|
||||
async def handle_chat(self, data):
|
||||
"""Handle a chat message."""
|
||||
content = data.get("content", "").strip()
|
||||
conversation_id = data.get("conversation_id")
|
||||
|
||||
if not content:
|
||||
await self.send_json({"type": "error", "error": "Message content is required"})
|
||||
return
|
||||
|
||||
try:
|
||||
# Get or create conversation
|
||||
conversation = await self.chat_service.get_or_create_conversation(conversation_id)
|
||||
|
||||
# If new conversation, send conversation_created event
|
||||
if not conversation_id:
|
||||
await self.send_json({
|
||||
"type": "conversation_created",
|
||||
"conversation": {
|
||||
"id": str(conversation.id),
|
||||
"title": conversation.title or "New Conversation",
|
||||
"created_at": conversation.created_at.isoformat(),
|
||||
}
|
||||
})
|
||||
|
||||
# Stream response
|
||||
async for event in self.chat_service.stream_response(conversation, content):
|
||||
await self.send_json(event)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Error handling chat message")
|
||||
await self.send_json({"type": "error", "error": str(e)})
|
||||
|
||||
async def handle_history(self, data):
|
||||
"""Handle request for conversation history."""
|
||||
conversation_id = data.get("conversation_id")
|
||||
|
||||
if not conversation_id:
|
||||
await self.send_json({"type": "error", "error": "conversation_id is required"})
|
||||
return
|
||||
|
||||
try:
|
||||
@database_sync_to_async
|
||||
def get_conversation_with_messages():
|
||||
try:
|
||||
conv = ChatConversation.objects.prefetch_related('messages').get(
|
||||
id=conversation_id,
|
||||
team_profile=self.profile,
|
||||
is_active=True
|
||||
)
|
||||
return {
|
||||
"id": str(conv.id),
|
||||
"title": conv.title or "New Conversation",
|
||||
"created_at": conv.created_at.isoformat(),
|
||||
"messages": [
|
||||
{
|
||||
"id": str(msg.id),
|
||||
"role": msg.role,
|
||||
"content": msg.content,
|
||||
"tool_calls": msg.tool_calls,
|
||||
"tool_results": msg.tool_results,
|
||||
"created_at": msg.created_at.isoformat(),
|
||||
}
|
||||
for msg in conv.messages.all().order_by('created_at')
|
||||
]
|
||||
}
|
||||
except ChatConversation.DoesNotExist:
|
||||
return None
|
||||
|
||||
conversation = await get_conversation_with_messages()
|
||||
|
||||
if conversation:
|
||||
await self.send_json({
|
||||
"type": "history",
|
||||
"conversation": conversation
|
||||
})
|
||||
else:
|
||||
await self.send_json({"type": "error", "error": "Conversation not found"})
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Error fetching history")
|
||||
await self.send_json({"type": "error", "error": str(e)})
|
||||
|
||||
async def handle_list_conversations(self):
|
||||
"""Handle request to list all conversations."""
|
||||
try:
|
||||
@database_sync_to_async
|
||||
def get_conversations():
|
||||
convs = ChatConversation.objects.filter(
|
||||
team_profile=self.profile,
|
||||
is_active=True
|
||||
).order_by('-updated_at')[:50]
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(conv.id),
|
||||
"title": conv.title or "New Conversation",
|
||||
"created_at": conv.created_at.isoformat(),
|
||||
"updated_at": conv.updated_at.isoformat(),
|
||||
}
|
||||
for conv in convs
|
||||
]
|
||||
|
||||
conversations = await get_conversations()
|
||||
|
||||
await self.send_json({
|
||||
"type": "conversations",
|
||||
"conversations": conversations
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Error listing conversations")
|
||||
await self.send_json({"type": "error", "error": str(e)})
|
||||
|
||||
async def handle_new_conversation(self):
|
||||
"""Handle request to create a new conversation."""
|
||||
try:
|
||||
conversation = await self.chat_service.get_or_create_conversation()
|
||||
|
||||
await self.send_json({
|
||||
"type": "conversation_created",
|
||||
"conversation": {
|
||||
"id": str(conversation.id),
|
||||
"title": conversation.title or "New Conversation",
|
||||
"created_at": conversation.created_at.isoformat(),
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Error creating conversation")
|
||||
await self.send_json({"type": "error", "error": str(e)})
|
||||
|
||||
async def send_json(self, data):
|
||||
"""Send JSON data to the WebSocket."""
|
||||
await self.send(text_data=json.dumps(data))
|
||||
627
core/chat/service.py
Normal file
627
core/chat/service.py
Normal file
@ -0,0 +1,627 @@
|
||||
"""
|
||||
Chat service that integrates Claude with Nexus MCP tools.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from typing import AsyncGenerator, Optional, List, Dict, Any
|
||||
|
||||
import anthropic
|
||||
from django.conf import settings
|
||||
|
||||
from core.models import TeamProfile
|
||||
from core.models.chat import ChatConversation, ChatMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Tools that require confirmation before execution
|
||||
DESTRUCTIVE_ACTIONS = {
|
||||
'delete_service',
|
||||
'delete_project',
|
||||
'create_services_bulk',
|
||||
}
|
||||
|
||||
# System prompt for the assistant
|
||||
SYSTEM_PROMPT = """You are a helpful assistant for Nexus, a field service management system used by your organization.
|
||||
|
||||
You have access to tools to query and manage:
|
||||
- Customers and their accounts
|
||||
- Services (scheduled cleaning visits)
|
||||
- Projects (one-time work)
|
||||
- Team member schedules
|
||||
- Session tracking and task completion
|
||||
- Notifications
|
||||
|
||||
Be concise and helpful. When asked about data, use the appropriate tools to fetch current information.
|
||||
When performing destructive actions like deletion or bulk creation, clearly confirm what will be affected.
|
||||
|
||||
Format responses in markdown when appropriate for better readability."""
|
||||
|
||||
|
||||
def get_mcp_tools() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get the list of MCP tools as Anthropic tool definitions.
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
from core.mcp.tools.auth import set_active_profile, get_my_profile
|
||||
from core.mcp.tools.dashboard import get_my_schedule, get_system_stats
|
||||
from core.mcp.tools.customers import list_customers, get_customer, list_accounts, get_account
|
||||
from core.mcp.tools.services import list_services, get_service, create_service, update_service, delete_service, create_services_bulk
|
||||
from core.mcp.tools.projects import list_projects, get_project, create_project, update_project, delete_project
|
||||
from core.mcp.tools.sessions import get_active_session, open_session, close_session, revert_session, add_task_completion, remove_task_completion
|
||||
from core.mcp.tools.notifications import get_my_notifications, get_unread_notification_count, mark_notification_read, mark_all_notifications_read
|
||||
|
||||
# Map function to tool definition
|
||||
tools = [
|
||||
# Dashboard
|
||||
{
|
||||
"name": "get_my_schedule",
|
||||
"description": "Get your assigned services and projects for a date range.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"start_date": {"type": "string", "description": "Start date in YYYY-MM-DD format"},
|
||||
"end_date": {"type": "string", "description": "End date in YYYY-MM-DD format"},
|
||||
"status": {"type": "string", "description": "Optional status filter"}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_system_stats",
|
||||
"description": "Get high-level system statistics. Requires ADMIN or TEAM_LEADER role.",
|
||||
"input_schema": {"type": "object", "properties": {}}
|
||||
},
|
||||
# Customers
|
||||
{
|
||||
"name": "list_customers",
|
||||
"description": "List customers with optional filtering. Requires ADMIN or TEAM_LEADER role.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {"type": "integer", "description": "Maximum customers to return (default 25)"},
|
||||
"search": {"type": "string", "description": "Search term for customer name"},
|
||||
"is_active": {"type": "boolean", "description": "Filter by active status"}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_customer",
|
||||
"description": "Get detailed customer information including accounts.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"customer_id": {"type": "string", "description": "UUID of the customer"}
|
||||
},
|
||||
"required": ["customer_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_accounts",
|
||||
"description": "List accounts with optional filtering.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {"type": "integer", "description": "Maximum accounts to return"},
|
||||
"customer_id": {"type": "string", "description": "Filter by customer UUID"},
|
||||
"search": {"type": "string", "description": "Search term"},
|
||||
"is_active": {"type": "boolean", "description": "Filter by active status"}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_account",
|
||||
"description": "Get detailed account information.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"account_id": {"type": "string", "description": "UUID of the account"}
|
||||
},
|
||||
"required": ["account_id"]
|
||||
}
|
||||
},
|
||||
# Services
|
||||
{
|
||||
"name": "list_services",
|
||||
"description": "List services with optional filters.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {"type": "integer", "description": "Maximum services to return"},
|
||||
"customer_id": {"type": "string", "description": "Filter by customer UUID"},
|
||||
"account_id": {"type": "string", "description": "Filter by account UUID"},
|
||||
"status": {"type": "string", "description": "Status filter (SCHEDULED, IN_PROGRESS, COMPLETED, CANCELLED)"},
|
||||
"date": {"type": "string", "description": "Exact date in YYYY-MM-DD format"},
|
||||
"start_date": {"type": "string", "description": "Range start date"},
|
||||
"end_date": {"type": "string", "description": "Range end date"}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_service",
|
||||
"description": "Get detailed service information including scope and tasks.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"service_id": {"type": "string", "description": "UUID of the service"}
|
||||
},
|
||||
"required": ["service_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "create_service",
|
||||
"description": "Create a new service. Requires ADMIN role.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"account_address_id": {"type": "string", "description": "UUID of the account address"},
|
||||
"date": {"type": "string", "description": "Service date in YYYY-MM-DD format"},
|
||||
"status": {"type": "string", "description": "Status (default SCHEDULED)"},
|
||||
"team_member_ids": {"type": "string", "description": "Comma-separated team member UUIDs"},
|
||||
"notes": {"type": "string", "description": "Optional notes"}
|
||||
},
|
||||
"required": ["account_address_id", "date"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "update_service",
|
||||
"description": "Update an existing service. Requires ADMIN role.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"service_id": {"type": "string", "description": "UUID of the service"},
|
||||
"date": {"type": "string", "description": "New date"},
|
||||
"status": {"type": "string", "description": "New status"},
|
||||
"team_member_ids": {"type": "string", "description": "Comma-separated team member UUIDs"},
|
||||
"notes": {"type": "string", "description": "Updated notes"}
|
||||
},
|
||||
"required": ["service_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "delete_service",
|
||||
"description": "Delete a service. Requires ADMIN role. WARNING: This is destructive.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"service_id": {"type": "string", "description": "UUID of the service to delete"}
|
||||
},
|
||||
"required": ["service_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "create_services_bulk",
|
||||
"description": "Create multiple services at once. Requires ADMIN role. Max 500 services.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"services_json": {"type": "string", "description": "JSON array of service objects with account_address_id, date, status, notes"}
|
||||
},
|
||||
"required": ["services_json"]
|
||||
}
|
||||
},
|
||||
# Projects
|
||||
{
|
||||
"name": "list_projects",
|
||||
"description": "List projects with optional filters.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {"type": "integer", "description": "Maximum projects to return"},
|
||||
"customer_id": {"type": "string", "description": "Filter by customer UUID"},
|
||||
"status": {"type": "string", "description": "Status filter"},
|
||||
"date": {"type": "string", "description": "Exact date"},
|
||||
"start_date": {"type": "string", "description": "Range start"},
|
||||
"end_date": {"type": "string", "description": "Range end"}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_project",
|
||||
"description": "Get detailed project information.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "UUID of the project"}
|
||||
},
|
||||
"required": ["project_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "create_project",
|
||||
"description": "Create a new project. Requires ADMIN role.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"customer_id": {"type": "string", "description": "UUID of the customer"},
|
||||
"name": {"type": "string", "description": "Project name"},
|
||||
"date": {"type": "string", "description": "Project date"},
|
||||
"labor": {"type": "number", "description": "Labor cost"},
|
||||
"amount": {"type": "number", "description": "Total amount"},
|
||||
"account_address_id": {"type": "string", "description": "UUID of account address"},
|
||||
"street_address": {"type": "string", "description": "Freeform street address"},
|
||||
"city": {"type": "string", "description": "City"},
|
||||
"state": {"type": "string", "description": "State"},
|
||||
"zip_code": {"type": "string", "description": "Zip code"},
|
||||
"team_member_ids": {"type": "string", "description": "Comma-separated UUIDs"},
|
||||
"notes": {"type": "string", "description": "Notes"}
|
||||
},
|
||||
"required": ["customer_id", "name", "date", "labor"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "update_project",
|
||||
"description": "Update an existing project. Requires ADMIN role.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "UUID of the project"},
|
||||
"name": {"type": "string"},
|
||||
"date": {"type": "string"},
|
||||
"status": {"type": "string"},
|
||||
"labor": {"type": "number"},
|
||||
"amount": {"type": "number"},
|
||||
"team_member_ids": {"type": "string"},
|
||||
"notes": {"type": "string"}
|
||||
},
|
||||
"required": ["project_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "delete_project",
|
||||
"description": "Delete a project. Requires ADMIN role. WARNING: This is destructive.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "UUID of the project to delete"}
|
||||
},
|
||||
"required": ["project_id"]
|
||||
}
|
||||
},
|
||||
# Sessions
|
||||
{
|
||||
"name": "get_active_session",
|
||||
"description": "Get the active session for a service or project.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entity_type": {"type": "string", "description": "Either 'service' or 'project'"},
|
||||
"entity_id": {"type": "string", "description": "UUID of the service or project"}
|
||||
},
|
||||
"required": ["entity_type", "entity_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "open_session",
|
||||
"description": "Start a work session for a service or project.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entity_type": {"type": "string", "description": "Either 'service' or 'project'"},
|
||||
"entity_id": {"type": "string", "description": "UUID"}
|
||||
},
|
||||
"required": ["entity_type", "entity_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "close_session",
|
||||
"description": "Complete a work session and mark tasks as done.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entity_type": {"type": "string"},
|
||||
"entity_id": {"type": "string"},
|
||||
"completed_task_ids": {"type": "string", "description": "Comma-separated task UUIDs"}
|
||||
},
|
||||
"required": ["entity_type", "entity_id"]
|
||||
}
|
||||
},
|
||||
# Notifications
|
||||
{
|
||||
"name": "get_my_notifications",
|
||||
"description": "Get your notifications.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"unread_only": {"type": "boolean"},
|
||||
"limit": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_unread_notification_count",
|
||||
"description": "Get count of unread notifications.",
|
||||
"input_schema": {"type": "object", "properties": {}}
|
||||
},
|
||||
{
|
||||
"name": "mark_all_notifications_read",
|
||||
"description": "Mark all notifications as read.",
|
||||
"input_schema": {"type": "object", "properties": {}}
|
||||
}
|
||||
]
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
async def execute_tool(tool_name: str, tool_input: Dict[str, Any], profile: TeamProfile) -> str:
|
||||
"""
|
||||
Execute an MCP tool and return the result as a string.
|
||||
"""
|
||||
# Import tool functions
|
||||
from core.mcp.tools import dashboard, customers, services, projects, sessions, notifications
|
||||
from core.mcp.auth import MCPContext
|
||||
|
||||
# Set the active profile for the MCP context
|
||||
MCPContext.set_profile(profile)
|
||||
|
||||
# Map tool names to functions
|
||||
tool_map = {
|
||||
# Dashboard
|
||||
"get_my_schedule": dashboard.get_my_schedule,
|
||||
"get_system_stats": dashboard.get_system_stats,
|
||||
# Customers
|
||||
"list_customers": customers.list_customers,
|
||||
"get_customer": customers.get_customer,
|
||||
"list_accounts": customers.list_accounts,
|
||||
"get_account": customers.get_account,
|
||||
# Services
|
||||
"list_services": services.list_services,
|
||||
"get_service": services.get_service,
|
||||
"create_service": services.create_service,
|
||||
"update_service": services.update_service,
|
||||
"delete_service": services.delete_service,
|
||||
"create_services_bulk": services.create_services_bulk,
|
||||
# Projects
|
||||
"list_projects": projects.list_projects,
|
||||
"get_project": projects.get_project,
|
||||
"create_project": projects.create_project,
|
||||
"update_project": projects.update_project,
|
||||
"delete_project": projects.delete_project,
|
||||
# Sessions
|
||||
"get_active_session": sessions.get_active_session,
|
||||
"open_session": sessions.open_session,
|
||||
"close_session": sessions.close_session,
|
||||
"revert_session": sessions.revert_session,
|
||||
"add_task_completion": sessions.add_task_completion,
|
||||
"remove_task_completion": sessions.remove_task_completion,
|
||||
# Notifications
|
||||
"get_my_notifications": notifications.get_my_notifications,
|
||||
"get_unread_notification_count": notifications.get_unread_notification_count,
|
||||
"mark_notification_read": notifications.mark_notification_read,
|
||||
"mark_all_notifications_read": notifications.mark_all_notifications_read,
|
||||
}
|
||||
|
||||
func = tool_map.get(tool_name)
|
||||
if not func:
|
||||
return json.dumps({"error": f"Unknown tool: {tool_name}"})
|
||||
|
||||
try:
|
||||
result = await func(**tool_input)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.exception(f"Error executing tool {tool_name}")
|
||||
return json.dumps({"error": str(e)})
|
||||
|
||||
|
||||
class ChatService:
|
||||
"""
|
||||
Service for handling chat conversations with Claude.
|
||||
"""
|
||||
|
||||
def __init__(self, profile: TeamProfile):
|
||||
self.profile = profile
|
||||
self.client = anthropic.AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY)
|
||||
self.tools = get_mcp_tools()
|
||||
|
||||
async def get_or_create_conversation(self, conversation_id: Optional[str] = None) -> ChatConversation:
|
||||
"""Get existing conversation or create a new one."""
|
||||
from channels.db import database_sync_to_async
|
||||
|
||||
if conversation_id:
|
||||
@database_sync_to_async
|
||||
def get_conv():
|
||||
return ChatConversation.objects.filter(
|
||||
id=conversation_id,
|
||||
team_profile=self.profile,
|
||||
is_active=True
|
||||
).first()
|
||||
|
||||
conv = await get_conv()
|
||||
if conv:
|
||||
return conv
|
||||
|
||||
# Create new conversation
|
||||
@database_sync_to_async
|
||||
def create_conv():
|
||||
return ChatConversation.objects.create(
|
||||
team_profile=self.profile,
|
||||
title=""
|
||||
)
|
||||
|
||||
return await create_conv()
|
||||
|
||||
async def get_conversation_messages(self, conversation: ChatConversation) -> List[Dict[str, Any]]:
|
||||
"""Get message history for Claude API format."""
|
||||
from channels.db import database_sync_to_async
|
||||
|
||||
@database_sync_to_async
|
||||
def fetch_messages():
|
||||
messages = []
|
||||
for msg in conversation.messages.all().order_by('created_at'):
|
||||
messages.append({
|
||||
"role": msg.role,
|
||||
"content": msg.content
|
||||
})
|
||||
return messages
|
||||
|
||||
return await fetch_messages()
|
||||
|
||||
async def save_message(
|
||||
self,
|
||||
conversation: ChatConversation,
|
||||
role: str,
|
||||
content: str,
|
||||
tool_calls: Optional[List] = None,
|
||||
tool_results: Optional[List] = None
|
||||
) -> ChatMessage:
|
||||
"""Save a message to the conversation."""
|
||||
from channels.db import database_sync_to_async
|
||||
|
||||
@database_sync_to_async
|
||||
def create_message():
|
||||
msg = ChatMessage.objects.create(
|
||||
conversation=conversation,
|
||||
role=role,
|
||||
content=content,
|
||||
tool_calls=tool_calls or [],
|
||||
tool_results=tool_results or []
|
||||
)
|
||||
# Update conversation title if first user message
|
||||
if role == 'user' and not conversation.title:
|
||||
conversation.title = content[:50] + ('...' if len(content) > 50 else '')
|
||||
conversation.save(update_fields=['title', 'updated_at'])
|
||||
return msg
|
||||
|
||||
return await create_message()
|
||||
|
||||
async def stream_response(
|
||||
self,
|
||||
conversation: ChatConversation,
|
||||
user_message: str
|
||||
) -> AsyncGenerator[Dict[str, Any], None]:
|
||||
"""
|
||||
Stream a response from Claude, handling tool calls.
|
||||
|
||||
Yields events:
|
||||
- {"type": "message_start", "conversation_id": str}
|
||||
- {"type": "text", "content": str}
|
||||
- {"type": "tool_call", "tool": str, "input": dict}
|
||||
- {"type": "tool_result", "tool": str, "result": str}
|
||||
- {"type": "message_end", "message_id": str}
|
||||
- {"type": "error", "error": str}
|
||||
"""
|
||||
# Save user message
|
||||
await self.save_message(conversation, 'user', user_message)
|
||||
|
||||
# Get conversation history
|
||||
messages = await self.get_conversation_messages(conversation)
|
||||
|
||||
yield {"type": "message_start", "conversation_id": str(conversation.id)}
|
||||
|
||||
try:
|
||||
full_response = ""
|
||||
tool_calls = []
|
||||
tool_results = []
|
||||
|
||||
# Keep processing until we get a final response (no more tool calls)
|
||||
while True:
|
||||
# Create message with streaming
|
||||
async with self.client.messages.stream(
|
||||
model=settings.ANTHROPIC_MODEL,
|
||||
max_tokens=4096,
|
||||
system=SYSTEM_PROMPT,
|
||||
messages=messages,
|
||||
tools=self.tools,
|
||||
) as stream:
|
||||
current_tool_use = None
|
||||
current_tool_input = ""
|
||||
|
||||
async for event in stream:
|
||||
if event.type == "content_block_start":
|
||||
if event.content_block.type == "tool_use":
|
||||
current_tool_use = {
|
||||
"id": event.content_block.id,
|
||||
"name": event.content_block.name,
|
||||
}
|
||||
current_tool_input = ""
|
||||
|
||||
elif event.type == "content_block_delta":
|
||||
if event.delta.type == "text_delta":
|
||||
full_response += event.delta.text
|
||||
yield {"type": "text", "content": event.delta.text}
|
||||
elif event.delta.type == "input_json_delta":
|
||||
current_tool_input += event.delta.partial_json
|
||||
|
||||
elif event.type == "content_block_stop":
|
||||
if current_tool_use:
|
||||
try:
|
||||
tool_input = json.loads(current_tool_input) if current_tool_input else {}
|
||||
except json.JSONDecodeError:
|
||||
tool_input = {}
|
||||
|
||||
current_tool_use["input"] = tool_input
|
||||
tool_calls.append(current_tool_use)
|
||||
|
||||
yield {
|
||||
"type": "tool_call",
|
||||
"id": current_tool_use["id"],
|
||||
"tool": current_tool_use["name"],
|
||||
"input": tool_input,
|
||||
"requires_confirmation": current_tool_use["name"] in DESTRUCTIVE_ACTIONS
|
||||
}
|
||||
|
||||
current_tool_use = None
|
||||
current_tool_input = ""
|
||||
|
||||
# Get the final message to check stop reason
|
||||
final_message = await stream.get_final_message()
|
||||
|
||||
# If there are tool calls, execute them and continue
|
||||
if final_message.stop_reason == "tool_use":
|
||||
# Execute each tool call
|
||||
tool_use_results = []
|
||||
for tool_call in tool_calls:
|
||||
if tool_call not in [t for t in tool_use_results]:
|
||||
result = await execute_tool(
|
||||
tool_call["name"],
|
||||
tool_call["input"],
|
||||
self.profile
|
||||
)
|
||||
|
||||
tool_results.append({
|
||||
"id": tool_call["id"],
|
||||
"tool": tool_call["name"],
|
||||
"result": result
|
||||
})
|
||||
|
||||
yield {
|
||||
"type": "tool_result",
|
||||
"id": tool_call["id"],
|
||||
"tool": tool_call["name"],
|
||||
"result": result
|
||||
}
|
||||
|
||||
tool_use_results.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_call["id"],
|
||||
"content": result
|
||||
})
|
||||
|
||||
# Add assistant message with tool use and tool results to continue conversation
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": final_message.content
|
||||
})
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": tool_use_results
|
||||
})
|
||||
|
||||
# Clear tool calls for next iteration
|
||||
tool_calls = []
|
||||
else:
|
||||
# No more tool calls, we're done
|
||||
break
|
||||
|
||||
# Save assistant message
|
||||
msg = await self.save_message(
|
||||
conversation,
|
||||
'assistant',
|
||||
full_response,
|
||||
tool_calls=tool_calls,
|
||||
tool_results=tool_results
|
||||
)
|
||||
|
||||
yield {"type": "message_end", "message_id": str(msg.id)}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Error streaming response")
|
||||
yield {"type": "error", "error": str(e)}
|
||||
9
core/graphql/__init__.py
Normal file
9
core/graphql/__init__.py
Normal file
@ -0,0 +1,9 @@
|
||||
from core.graphql.queries import *
|
||||
from core.graphql.mutations import *
|
||||
from core.graphql.types import *
|
||||
from core.graphql.schema import *
|
||||
from core.graphql.inputs import *
|
||||
from core.graphql.subscriptions import *
|
||||
from core.graphql.pubsub import *
|
||||
from core.graphql.utils import *
|
||||
from core.graphql.enums import *
|
||||
9
core/graphql/enums.py
Normal file
9
core/graphql/enums.py
Normal file
@ -0,0 +1,9 @@
|
||||
import strawberry
|
||||
from enum import Enum
|
||||
|
||||
|
||||
@strawberry.enum
|
||||
class DateOrdering(Enum):
|
||||
"""Ordering direction for date-based queries."""
|
||||
ASC = "ASC"
|
||||
DESC = "DESC"
|
||||
18
core/graphql/filters/__init__.py
Normal file
18
core/graphql/filters/__init__.py
Normal file
@ -0,0 +1,18 @@
|
||||
from core.graphql.filters.account import *
|
||||
from core.graphql.filters.project import *
|
||||
from core.graphql.filters.service import *
|
||||
from core.graphql.filters.labor import *
|
||||
from core.graphql.filters.revenue import *
|
||||
from core.graphql.filters.schedule import *
|
||||
from core.graphql.filters.invoice import *
|
||||
from core.graphql.filters.report import *
|
||||
from core.graphql.filters.account_punchlist import *
|
||||
from core.graphql.filters.project_punchlist import *
|
||||
from core.graphql.filters.customer import *
|
||||
from core.graphql.filters.profile import *
|
||||
from core.graphql.filters.scope import *
|
||||
from core.graphql.filters.scope_template import *
|
||||
from core.graphql.filters.project_scope import *
|
||||
from core.graphql.filters.project_scope_template import *
|
||||
from core.graphql.filters.session import *
|
||||
from core.graphql.filters.session_image import *
|
||||
41
core/graphql/filters/account.py
Normal file
41
core/graphql/filters/account.py
Normal file
@ -0,0 +1,41 @@
|
||||
import strawberry
|
||||
import strawberry_django as sd
|
||||
from django.db.models import Q, QuerySet
|
||||
from core.models.account import Account, AccountContact, AccountAddress
|
||||
|
||||
|
||||
@sd.filter(Account)
|
||||
class AccountFilter:
|
||||
id: strawberry.auto
|
||||
name: strawberry.auto
|
||||
customer_id: strawberry.auto
|
||||
|
||||
@sd.filter_field
|
||||
def is_active(self, queryset, value: bool, prefix: str) -> tuple[QuerySet, Q]:
|
||||
today = sd.utils.timezone.now().date()
|
||||
active_query = Q(
|
||||
status='ACTIVE',
|
||||
start_date__lte=today
|
||||
) & (
|
||||
Q(end_date__isnull=True) | Q(end_date__gte=today)
|
||||
)
|
||||
if value:
|
||||
return queryset, active_query
|
||||
return queryset, ~active_query
|
||||
|
||||
@sd.filter_field
|
||||
def search(self, queryset, value: str, prefix: str) -> tuple[QuerySet, Q]:
|
||||
return queryset, Q(**{f"{prefix}name__icontains": value})
|
||||
|
||||
|
||||
@sd.filter(AccountAddress)
|
||||
class AccountAddressFilter:
|
||||
id: strawberry.auto
|
||||
|
||||
|
||||
@sd.filter(AccountContact)
|
||||
class AccountContactFilter:
|
||||
id: strawberry.auto
|
||||
account_id: strawberry.auto
|
||||
is_active: strawberry.auto
|
||||
is_primary: strawberry.auto
|
||||
8
core/graphql/filters/account_punchlist.py
Normal file
8
core/graphql/filters/account_punchlist.py
Normal file
@ -0,0 +1,8 @@
|
||||
import strawberry
|
||||
import strawberry_django as sd
|
||||
from core.models.account_punchlist import AccountPunchlist
|
||||
|
||||
@sd.filter(AccountPunchlist)
|
||||
class AccountPunchlistFilter:
|
||||
id: strawberry.auto
|
||||
account_id: strawberry.auto
|
||||
42
core/graphql/filters/customer.py
Normal file
42
core/graphql/filters/customer.py
Normal file
@ -0,0 +1,42 @@
|
||||
import strawberry
|
||||
import strawberry_django as sd
|
||||
from typing import Optional
|
||||
from django.db.models import Q
|
||||
from core.models.customer import Customer, CustomerAddress, CustomerContact
|
||||
|
||||
|
||||
@sd.filter(Customer)
|
||||
class CustomerFilter:
|
||||
id: strawberry.auto
|
||||
search: Optional[str] = strawberry.field(default=None)
|
||||
is_active: Optional[bool] = strawberry.field(default=None)
|
||||
|
||||
@staticmethod
|
||||
def filter_search(queryset, value: str):
|
||||
return queryset.filter(name__icontains=value)
|
||||
|
||||
@staticmethod
|
||||
def filter_is_active(queryset, value: bool):
|
||||
today = sd.utils.timezone.now().date()
|
||||
active_query = Q(
|
||||
status='ACTIVE',
|
||||
start_date__lte=today
|
||||
) & (
|
||||
Q(end_date__isnull=True) | Q(end_date__gte=today)
|
||||
)
|
||||
if value:
|
||||
return queryset.filter(active_query)
|
||||
return queryset.exclude(active_query)
|
||||
|
||||
|
||||
@sd.filter(CustomerAddress)
|
||||
class CustomerAddressFilter:
|
||||
id: strawberry.auto
|
||||
|
||||
|
||||
@sd.filter(CustomerContact)
|
||||
class CustomerContactFilter:
|
||||
id: strawberry.auto
|
||||
customer_id: strawberry.auto
|
||||
is_active: strawberry.auto
|
||||
is_primary: strawberry.auto
|
||||
10
core/graphql/filters/invoice.py
Normal file
10
core/graphql/filters/invoice.py
Normal file
@ -0,0 +1,10 @@
|
||||
import strawberry
|
||||
import strawberry_django as sd
|
||||
from core.models.invoice import Invoice
|
||||
|
||||
|
||||
@sd.filter(Invoice)
|
||||
class InvoiceFilter:
|
||||
id: strawberry.auto
|
||||
customer_id: strawberry.auto
|
||||
status: strawberry.auto
|
||||
9
core/graphql/filters/labor.py
Normal file
9
core/graphql/filters/labor.py
Normal file
@ -0,0 +1,9 @@
|
||||
import strawberry
|
||||
import strawberry_django as sd
|
||||
from core.models.labor import Labor
|
||||
|
||||
|
||||
@sd.filter(Labor)
|
||||
class LaborFilter:
|
||||
id: strawberry.auto
|
||||
account_address_id: strawberry.auto
|
||||
29
core/graphql/filters/messaging.py
Normal file
29
core/graphql/filters/messaging.py
Normal file
@ -0,0 +1,29 @@
|
||||
import strawberry
|
||||
import strawberry_django as sd
|
||||
from core.models.messaging import Conversation, Message, ConversationParticipant
|
||||
|
||||
|
||||
@sd.filter(Conversation, lookups=True)
|
||||
class ConversationFilter:
|
||||
id: strawberry.auto
|
||||
conversation_type: strawberry.auto
|
||||
is_archived: strawberry.auto
|
||||
last_message_at: strawberry.auto
|
||||
created_at: strawberry.auto
|
||||
|
||||
|
||||
@sd.filter(Message, lookups=True)
|
||||
class MessageFilter:
|
||||
id: strawberry.auto
|
||||
conversation_id: strawberry.auto
|
||||
is_system_message: strawberry.auto
|
||||
created_at: strawberry.auto
|
||||
|
||||
|
||||
@sd.filter(ConversationParticipant, lookups=True)
|
||||
class ConversationParticipantFilter:
|
||||
id: strawberry.auto
|
||||
conversation_id: strawberry.auto
|
||||
is_muted: strawberry.auto
|
||||
is_archived: strawberry.auto
|
||||
unread_count: strawberry.auto
|
||||
14
core/graphql/filters/profile.py
Normal file
14
core/graphql/filters/profile.py
Normal file
@ -0,0 +1,14 @@
|
||||
import strawberry
|
||||
import strawberry_django as sd
|
||||
from core.models.profile import CustomerProfile, TeamProfile
|
||||
|
||||
|
||||
@sd.filter(CustomerProfile)
|
||||
class CustomerProfileFilter:
|
||||
id: strawberry.auto
|
||||
customers: strawberry.auto
|
||||
|
||||
|
||||
@sd.filter(TeamProfile)
|
||||
class TeamProfileFilter:
|
||||
id: strawberry.auto
|
||||
12
core/graphql/filters/project.py
Normal file
12
core/graphql/filters/project.py
Normal file
@ -0,0 +1,12 @@
|
||||
import strawberry
|
||||
import strawberry_django as sd
|
||||
from core.models.project import Project
|
||||
|
||||
@sd.filter(Project, lookups=True)
|
||||
class ProjectFilter:
|
||||
id: strawberry.auto
|
||||
customer_id: strawberry.auto
|
||||
account_address_id: strawberry.auto
|
||||
status: strawberry.auto
|
||||
team_members: strawberry.auto
|
||||
date: strawberry.auto
|
||||
9
core/graphql/filters/project_punchlist.py
Normal file
9
core/graphql/filters/project_punchlist.py
Normal file
@ -0,0 +1,9 @@
|
||||
import strawberry
|
||||
import strawberry_django as sd
|
||||
from core.models.project_punchlist import ProjectPunchlist
|
||||
|
||||
|
||||
@sd.filter(ProjectPunchlist)
|
||||
class ProjectPunchlistFilter:
|
||||
id: strawberry.auto
|
||||
project_id: strawberry.auto
|
||||
37
core/graphql/filters/project_scope.py
Normal file
37
core/graphql/filters/project_scope.py
Normal file
@ -0,0 +1,37 @@
|
||||
import strawberry
|
||||
import strawberry_django as sd
|
||||
from core.models.project_scope import ProjectScope, ProjectScopeCategory, ProjectScopeTask, ProjectScopeTaskCompletion
|
||||
|
||||
|
||||
@sd.filter(ProjectScope)
|
||||
class ProjectScopeFilter:
|
||||
id: strawberry.auto
|
||||
project_id: strawberry.auto
|
||||
account_id: strawberry.auto
|
||||
account_address_id: strawberry.auto
|
||||
is_active: strawberry.auto
|
||||
|
||||
|
||||
@sd.filter(ProjectScopeCategory)
|
||||
class ProjectScopeCategoryFilter:
|
||||
id: strawberry.auto
|
||||
scope_id: strawberry.auto
|
||||
order: strawberry.auto
|
||||
|
||||
|
||||
@sd.filter(ProjectScopeTask)
|
||||
class ProjectScopeTaskFilter:
|
||||
id: strawberry.auto
|
||||
category_id: strawberry.auto
|
||||
order: strawberry.auto
|
||||
|
||||
|
||||
@sd.filter(ProjectScopeTaskCompletion)
|
||||
class ProjectScopeTaskCompletionFilter:
|
||||
id: strawberry.auto
|
||||
project_id: strawberry.auto
|
||||
task_id: strawberry.auto
|
||||
account_id: strawberry.auto
|
||||
account_address_id: strawberry.auto
|
||||
completed_by_id: strawberry.auto
|
||||
|
||||
65
core/graphql/filters/project_scope_template.py
Normal file
65
core/graphql/filters/project_scope_template.py
Normal file
@ -0,0 +1,65 @@
|
||||
import strawberry
|
||||
import strawberry_django as sd
|
||||
from typing import Optional
|
||||
from core.models.project_scope_template import (
|
||||
ProjectScopeTemplate,
|
||||
ProjectAreaTemplate,
|
||||
ProjectTaskTemplate,
|
||||
)
|
||||
|
||||
|
||||
@sd.filter(ProjectScopeTemplate)
|
||||
class ProjectScopeTemplateFilter:
|
||||
id: strawberry.auto
|
||||
is_active: strawberry.auto
|
||||
|
||||
# Convenience search fields
|
||||
name_search: Optional[str] = strawberry.field(default=None, description="Case-insensitive search on name")
|
||||
description_search: Optional[str] = strawberry.field(default=None,
|
||||
description="Case-insensitive search on description")
|
||||
|
||||
@staticmethod
|
||||
def filter_name_search(queryset, value: Optional[str]):
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(name__icontains=value)
|
||||
|
||||
@staticmethod
|
||||
def filter_description_search(queryset, value: Optional[str]):
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(description__icontains=value)
|
||||
|
||||
|
||||
@sd.filter(ProjectAreaTemplate)
|
||||
class ProjectAreaTemplateFilter:
|
||||
id: strawberry.auto
|
||||
scope_template_id: strawberry.auto
|
||||
order: strawberry.auto
|
||||
|
||||
# Convenience search
|
||||
name_search: Optional[str] = strawberry.field(default=None, description="Case-insensitive search on name")
|
||||
|
||||
@staticmethod
|
||||
def filter_name_search(queryset, value: Optional[str]):
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(name__icontains=value)
|
||||
|
||||
|
||||
@sd.filter(ProjectTaskTemplate)
|
||||
class ProjectTaskTemplateFilter:
|
||||
id: strawberry.auto
|
||||
area_template_id: strawberry.auto
|
||||
order: strawberry.auto
|
||||
estimated_minutes: strawberry.auto
|
||||
|
||||
# Convenience search
|
||||
description_search: Optional[str] = strawberry.field(default=None,
|
||||
description="Case-insensitive search on description")
|
||||
|
||||
@staticmethod
|
||||
def filter_description_search(queryset, value: Optional[str]):
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(description__icontains=value)
|
||||
10
core/graphql/filters/report.py
Normal file
10
core/graphql/filters/report.py
Normal file
@ -0,0 +1,10 @@
|
||||
import strawberry
|
||||
import strawberry_django as sd
|
||||
from core.models.report import Report
|
||||
|
||||
|
||||
@sd.filter(Report)
|
||||
class ReportFilter:
|
||||
id: strawberry.auto
|
||||
date: strawberry.auto
|
||||
team_member_id: strawberry.auto
|
||||
9
core/graphql/filters/revenue.py
Normal file
9
core/graphql/filters/revenue.py
Normal file
@ -0,0 +1,9 @@
|
||||
import strawberry
|
||||
import strawberry_django as sd
|
||||
from core.models.revenue import Revenue
|
||||
|
||||
|
||||
@sd.filter(Revenue)
|
||||
class RevenueFilter:
|
||||
id: strawberry.auto
|
||||
account_id: strawberry.auto
|
||||
9
core/graphql/filters/schedule.py
Normal file
9
core/graphql/filters/schedule.py
Normal file
@ -0,0 +1,9 @@
|
||||
import strawberry
|
||||
import strawberry_django as sd
|
||||
from core.models.schedule import Schedule
|
||||
|
||||
|
||||
@sd.filter(Schedule)
|
||||
class ScheduleFilter:
|
||||
id: strawberry.auto
|
||||
account_address_id: strawberry.auto
|
||||
40
core/graphql/filters/scope.py
Normal file
40
core/graphql/filters/scope.py
Normal file
@ -0,0 +1,40 @@
|
||||
import strawberry
|
||||
import strawberry_django as sd
|
||||
from typing import Optional
|
||||
from core.models.scope import Scope, Area, Task, TaskCompletion
|
||||
|
||||
|
||||
@sd.filter(Scope)
|
||||
class ScopeFilter:
|
||||
id: strawberry.auto
|
||||
account_id: strawberry.auto
|
||||
account_address_id: strawberry.auto
|
||||
is_active: strawberry.auto
|
||||
search: Optional[str] = strawberry.field(default=None)
|
||||
|
||||
@staticmethod
|
||||
def filter_search(queryset, value: str):
|
||||
return queryset.filter(name__icontains=value)
|
||||
|
||||
|
||||
@sd.filter(Area)
|
||||
class AreaFilter:
|
||||
id: strawberry.auto
|
||||
scope_id: strawberry.auto
|
||||
|
||||
|
||||
@sd.filter(Task)
|
||||
class TaskFilter:
|
||||
id: strawberry.auto
|
||||
area_id: strawberry.auto
|
||||
frequency: strawberry.auto
|
||||
|
||||
|
||||
@sd.filter(TaskCompletion)
|
||||
class TaskCompletionFilter:
|
||||
id: strawberry.auto
|
||||
service_id: strawberry.auto
|
||||
task_id: strawberry.auto
|
||||
completed_by_id: strawberry.auto
|
||||
year: strawberry.auto
|
||||
month: strawberry.auto
|
||||
52
core/graphql/filters/scope_template.py
Normal file
52
core/graphql/filters/scope_template.py
Normal file
@ -0,0 +1,52 @@
|
||||
from typing import Optional
|
||||
import strawberry
|
||||
import strawberry_django as sd
|
||||
from django.db.models import Q
|
||||
from core.models.scope_template import ScopeTemplate, AreaTemplate, TaskTemplate
|
||||
|
||||
|
||||
@sd.filter(ScopeTemplate)
|
||||
class ScopeTemplateFilter:
|
||||
id: strawberry.auto
|
||||
is_active: Optional[bool] = strawberry.field(default=None)
|
||||
search: Optional[str] = strawberry.field(default=None, description="Case-insensitive search on name or description")
|
||||
|
||||
@staticmethod
|
||||
def filter_is_active(queryset, value: bool):
|
||||
return queryset.filter(is_active=value)
|
||||
|
||||
@staticmethod
|
||||
def filter_search(queryset, value: str):
|
||||
return queryset.filter(Q(name__icontains=value) | Q(description__icontains=value))
|
||||
|
||||
|
||||
@sd.filter(AreaTemplate)
|
||||
class AreaTemplateFilter:
|
||||
id: strawberry.auto
|
||||
scope_template_id: strawberry.auto
|
||||
search: Optional[str] = strawberry.field(default=None, description="Case-insensitive search on name")
|
||||
|
||||
@staticmethod
|
||||
def filter_search(queryset, value: str):
|
||||
return queryset.filter(name__icontains=value)
|
||||
|
||||
|
||||
@sd.filter(TaskTemplate)
|
||||
class TaskTemplateFilter:
|
||||
id: strawberry.auto
|
||||
area_template_id: strawberry.auto
|
||||
frequency: Optional[str] = strawberry.field(default=None)
|
||||
is_conditional: Optional[bool] = strawberry.field(default=None)
|
||||
description_search: Optional[str] = strawberry.field(default=None, description="Case-insensitive search on description")
|
||||
|
||||
@staticmethod
|
||||
def filter_frequency(queryset, value: str):
|
||||
return queryset.filter(frequency=value)
|
||||
|
||||
@staticmethod
|
||||
def filter_is_conditional(queryset, value: bool):
|
||||
return queryset.filter(is_conditional=value)
|
||||
|
||||
@staticmethod
|
||||
def filter_description_search(queryset, value: str):
|
||||
return queryset.filter(description__icontains=value)
|
||||
12
core/graphql/filters/service.py
Normal file
12
core/graphql/filters/service.py
Normal file
@ -0,0 +1,12 @@
|
||||
import strawberry
|
||||
import strawberry_django as sd
|
||||
from core.models.service import Service
|
||||
|
||||
@sd.filter(Service, lookups=True)
|
||||
class ServiceFilter:
|
||||
id: strawberry.auto
|
||||
account_id: strawberry.auto
|
||||
account_address_id: strawberry.auto
|
||||
status: strawberry.auto
|
||||
team_members: strawberry.auto
|
||||
date: strawberry.auto
|
||||
52
core/graphql/filters/session.py
Normal file
52
core/graphql/filters/session.py
Normal file
@ -0,0 +1,52 @@
|
||||
import strawberry
|
||||
import strawberry_django as sd
|
||||
from typing import Optional
|
||||
from core.models.session import ServiceSession, ProjectSession
|
||||
|
||||
|
||||
@sd.filter(ServiceSession, lookups=True)
|
||||
class ServiceSessionFilter:
|
||||
id: strawberry.auto
|
||||
service_id: strawberry.auto
|
||||
account_address_id: strawberry.auto
|
||||
start: strawberry.auto
|
||||
end: strawberry.auto
|
||||
created_by_id: strawberry.auto
|
||||
|
||||
team_member_id: Optional[str] = strawberry.field(default=strawberry.UNSET)
|
||||
is_active: Optional[bool] = strawberry.field(default=None)
|
||||
|
||||
@staticmethod
|
||||
def filter_team_member_id(queryset, value: Optional[str]):
|
||||
if value is None or value is strawberry.UNSET:
|
||||
return queryset
|
||||
# Filter through the service -> team_members relationship
|
||||
return queryset.filter(service__team_members__id=value)
|
||||
|
||||
@staticmethod
|
||||
def filter_is_active(queryset, value: Optional[bool]):
|
||||
if value is None:
|
||||
return queryset
|
||||
return queryset.filter(end__isnull=value)
|
||||
|
||||
|
||||
@sd.filter(ProjectSession, lookups=True)
|
||||
class ProjectSessionFilter:
|
||||
id: strawberry.auto
|
||||
project_id: strawberry.auto
|
||||
account_id: strawberry.auto
|
||||
account_address_id: strawberry.auto
|
||||
customer_id: strawberry.auto
|
||||
scope_id: strawberry.auto
|
||||
created_by_id: strawberry.auto
|
||||
date: strawberry.auto
|
||||
start: strawberry.auto
|
||||
end: strawberry.auto
|
||||
|
||||
team_member_id: Optional[str] = strawberry.field(default=strawberry.UNSET)
|
||||
|
||||
@staticmethod
|
||||
def filter_team_member_id(queryset, value: Optional[str]):
|
||||
if value is None or value is strawberry.UNSET:
|
||||
return queryset
|
||||
return queryset.filter(project__team_members__id=value)
|
||||
48
core/graphql/filters/session_image.py
Normal file
48
core/graphql/filters/session_image.py
Normal file
@ -0,0 +1,48 @@
|
||||
from datetime import datetime
|
||||
import strawberry
|
||||
import strawberry_django as sd
|
||||
from typing import Optional
|
||||
from core.models.session_image import ServiceSessionImage, ProjectSessionImage
|
||||
|
||||
@sd.filter(ServiceSessionImage)
|
||||
class ServiceSessionImageFilter:
|
||||
id: strawberry.auto
|
||||
service_session_id: strawberry.auto
|
||||
uploaded_by_team_profile_id: strawberry.auto
|
||||
title_contains: Optional[str] = strawberry.field(default=None)
|
||||
created_after: Optional[datetime] = strawberry.field(default=None)
|
||||
created_before: Optional[datetime] = strawberry.field(default=None)
|
||||
|
||||
@staticmethod
|
||||
def filter_title_contains(qs, value: str):
|
||||
return qs.filter(title__icontains=value)
|
||||
|
||||
@staticmethod
|
||||
def filter_created_after(qs, value):
|
||||
return qs.filter(created_at__gte=value)
|
||||
|
||||
@staticmethod
|
||||
def filter_created_before(qs, value):
|
||||
return qs.filter(created_at__lte=value)
|
||||
|
||||
|
||||
@sd.filter(ProjectSessionImage)
|
||||
class ProjectSessionImageFilter:
|
||||
id: strawberry.auto
|
||||
project_session_id: strawberry.auto
|
||||
uploaded_by_team_profile_id: strawberry.auto
|
||||
title_contains: Optional[str] = strawberry.field(default=None)
|
||||
created_after: Optional[datetime] = strawberry.field(default=None)
|
||||
created_before: Optional[datetime] = strawberry.field(default=None)
|
||||
|
||||
@staticmethod
|
||||
def filter_title_contains(qs, value: str):
|
||||
return qs.filter(title__icontains=value)
|
||||
|
||||
@staticmethod
|
||||
def filter_created_after(qs, value):
|
||||
return qs.filter(created_at__gte=value)
|
||||
|
||||
@staticmethod
|
||||
def filter_created_before(qs, value):
|
||||
return qs.filter(created_at__lte=value)
|
||||
51
core/graphql/filters/session_note.py
Normal file
51
core/graphql/filters/session_note.py
Normal file
@ -0,0 +1,51 @@
|
||||
from datetime import datetime
|
||||
import strawberry
|
||||
import strawberry_django as sd
|
||||
from typing import Optional
|
||||
from core.models.session import ServiceSessionNote, ProjectSessionNote
|
||||
|
||||
|
||||
@sd.filter(ServiceSessionNote)
|
||||
class ServiceSessionNoteFilter:
|
||||
id: strawberry.auto
|
||||
session_id: strawberry.auto
|
||||
author_id: strawberry.auto
|
||||
internal: strawberry.auto
|
||||
content_contains: Optional[str] = strawberry.field(default=None)
|
||||
created_after: Optional[datetime] = strawberry.field(default=None)
|
||||
created_before: Optional[datetime] = strawberry.field(default=None)
|
||||
|
||||
@staticmethod
|
||||
def filter_content_contains(qs, value: str):
|
||||
return qs.filter(content__icontains=value)
|
||||
|
||||
@staticmethod
|
||||
def filter_created_after(qs, value):
|
||||
return qs.filter(created_at__gte=value)
|
||||
|
||||
@staticmethod
|
||||
def filter_created_before(qs, value):
|
||||
return qs.filter(created_at__lte=value)
|
||||
|
||||
|
||||
@sd.filter(ProjectSessionNote)
|
||||
class ProjectSessionNoteFilter:
|
||||
id: strawberry.auto
|
||||
session_id: strawberry.auto
|
||||
author_id: strawberry.auto
|
||||
internal: strawberry.auto
|
||||
content_contains: Optional[str] = strawberry.field(default=None)
|
||||
created_after: Optional[datetime] = strawberry.field(default=None)
|
||||
created_before: Optional[datetime] = strawberry.field(default=None)
|
||||
|
||||
@staticmethod
|
||||
def filter_content_contains(qs, value: str):
|
||||
return qs.filter(content__icontains=value)
|
||||
|
||||
@staticmethod
|
||||
def filter_created_after(qs, value):
|
||||
return qs.filter(created_at__gte=value)
|
||||
|
||||
@staticmethod
|
||||
def filter_created_before(qs, value):
|
||||
return qs.filter(created_at__lte=value)
|
||||
75
core/graphql/filters/session_video.py
Normal file
75
core/graphql/filters/session_video.py
Normal file
@ -0,0 +1,75 @@
|
||||
from datetime import datetime
|
||||
import strawberry
|
||||
import strawberry_django as sd
|
||||
from typing import Optional
|
||||
from core.models.session_video import ServiceSessionVideo, ProjectSessionVideo
|
||||
|
||||
|
||||
@sd.filter(ServiceSessionVideo)
|
||||
class ServiceSessionVideoFilter:
|
||||
id: strawberry.auto
|
||||
service_session_id: strawberry.auto
|
||||
uploaded_by_team_profile_id: strawberry.auto
|
||||
internal: strawberry.auto
|
||||
title_contains: Optional[str] = strawberry.field(default=None)
|
||||
created_after: Optional[datetime] = strawberry.field(default=None)
|
||||
created_before: Optional[datetime] = strawberry.field(default=None)
|
||||
min_duration: Optional[int] = strawberry.field(default=None)
|
||||
max_duration: Optional[int] = strawberry.field(default=None)
|
||||
|
||||
@staticmethod
|
||||
def filter_title_contains(qs, value: str):
|
||||
return qs.filter(title__icontains=value)
|
||||
|
||||
@staticmethod
|
||||
def filter_created_after(qs, value):
|
||||
return qs.filter(created_at__gte=value)
|
||||
|
||||
@staticmethod
|
||||
def filter_created_before(qs, value):
|
||||
return qs.filter(created_at__lte=value)
|
||||
|
||||
@staticmethod
|
||||
def filter_min_duration(qs, value: int):
|
||||
"""Filter videos with duration >= value (in seconds)"""
|
||||
return qs.filter(duration_seconds__gte=value)
|
||||
|
||||
@staticmethod
|
||||
def filter_max_duration(qs, value: int):
|
||||
"""Filter videos with duration <= value (in seconds)"""
|
||||
return qs.filter(duration_seconds__lte=value)
|
||||
|
||||
|
||||
@sd.filter(ProjectSessionVideo)
|
||||
class ProjectSessionVideoFilter:
|
||||
id: strawberry.auto
|
||||
project_session_id: strawberry.auto
|
||||
uploaded_by_team_profile_id: strawberry.auto
|
||||
internal: strawberry.auto
|
||||
title_contains: Optional[str] = strawberry.field(default=None)
|
||||
created_after: Optional[datetime] = strawberry.field(default=None)
|
||||
created_before: Optional[datetime] = strawberry.field(default=None)
|
||||
min_duration: Optional[int] = strawberry.field(default=None)
|
||||
max_duration: Optional[int] = strawberry.field(default=None)
|
||||
|
||||
@staticmethod
|
||||
def filter_title_contains(qs, value: str):
|
||||
return qs.filter(title__icontains=value)
|
||||
|
||||
@staticmethod
|
||||
def filter_created_after(qs, value):
|
||||
return qs.filter(created_at__gte=value)
|
||||
|
||||
@staticmethod
|
||||
def filter_created_before(qs, value):
|
||||
return qs.filter(created_at__lte=value)
|
||||
|
||||
@staticmethod
|
||||
def filter_min_duration(qs, value: int):
|
||||
"""Filter videos with duration >= value (in seconds)"""
|
||||
return qs.filter(duration_seconds__gte=value)
|
||||
|
||||
@staticmethod
|
||||
def filter_max_duration(qs, value: int):
|
||||
"""Filter videos with duration <= value (in seconds)"""
|
||||
return qs.filter(duration_seconds__lte=value)
|
||||
18
core/graphql/inputs/__init__.py
Normal file
18
core/graphql/inputs/__init__.py
Normal file
@ -0,0 +1,18 @@
|
||||
from core.graphql.inputs.customer import *
|
||||
from core.graphql.inputs.account import *
|
||||
from core.graphql.inputs.project import *
|
||||
from core.graphql.inputs.service import *
|
||||
from core.graphql.inputs.labor import *
|
||||
from core.graphql.inputs.revenue import *
|
||||
from core.graphql.inputs.schedule import *
|
||||
from core.graphql.inputs.invoice import *
|
||||
from core.graphql.inputs.report import *
|
||||
from core.graphql.inputs.account_punchlist import *
|
||||
from core.graphql.inputs.project_punchlist import *
|
||||
from core.graphql.inputs.profile import *
|
||||
from core.graphql.inputs.scope import *
|
||||
from core.graphql.inputs.scope_template import *
|
||||
from core.graphql.inputs.project_scope import *
|
||||
from core.graphql.inputs.project_scope_template import *
|
||||
from core.graphql.inputs.session import *
|
||||
from core.graphql.inputs.session_image import *
|
||||
76
core/graphql/inputs/account.py
Normal file
76
core/graphql/inputs/account.py
Normal file
@ -0,0 +1,76 @@
|
||||
import datetime
|
||||
from typing import Optional
|
||||
import strawberry
|
||||
from strawberry.relay import GlobalID
|
||||
|
||||
|
||||
# Account inputs
|
||||
@strawberry.input
|
||||
class AccountInput:
|
||||
customer_id: GlobalID
|
||||
name: str
|
||||
status: str
|
||||
start_date: datetime.date
|
||||
end_date: Optional[datetime.date] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class AccountUpdateInput:
|
||||
id: GlobalID
|
||||
customer_id: Optional[GlobalID] = None
|
||||
name: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
start_date: Optional[datetime.date] = None
|
||||
end_date: Optional[datetime.date] = None
|
||||
|
||||
|
||||
# AccountAddress inputs
|
||||
@strawberry.input
|
||||
class AccountAddressInput:
|
||||
account_id: GlobalID
|
||||
name: str
|
||||
street_address: str
|
||||
city: str
|
||||
state: str
|
||||
zip_code: str
|
||||
is_active: bool = True
|
||||
is_primary: bool = False
|
||||
notes: str = ""
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class AccountAddressUpdateInput:
|
||||
id: GlobalID
|
||||
name: Optional[str] = None
|
||||
street_address: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
zip_code: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
is_primary: Optional[bool] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
# AccountContact inputs
|
||||
@strawberry.input
|
||||
class AccountContactInput:
|
||||
account_id: GlobalID
|
||||
first_name: str
|
||||
last_name: str
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
is_primary: bool = False
|
||||
is_active: bool = True
|
||||
notes: str = ""
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class AccountContactUpdateInput:
|
||||
id: GlobalID
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
is_primary: Optional[bool] = None
|
||||
is_active: Optional[bool] = None
|
||||
notes: Optional[str] = None
|
||||
17
core/graphql/inputs/account_punchlist.py
Normal file
17
core/graphql/inputs/account_punchlist.py
Normal file
@ -0,0 +1,17 @@
|
||||
import datetime
|
||||
from typing import Optional
|
||||
import strawberry
|
||||
from strawberry.relay import GlobalID
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class AccountPunchlistInput:
|
||||
account_id: GlobalID
|
||||
date: datetime.date
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class AccountPunchlistUpdateInput:
|
||||
id: GlobalID
|
||||
account_id: Optional[GlobalID] = None
|
||||
date: Optional[datetime.date] = None
|
||||
78
core/graphql/inputs/customer.py
Normal file
78
core/graphql/inputs/customer.py
Normal file
@ -0,0 +1,78 @@
|
||||
import datetime
|
||||
from typing import Optional
|
||||
import strawberry
|
||||
from strawberry.relay import GlobalID
|
||||
|
||||
|
||||
# Customer inputs
|
||||
@strawberry.input
|
||||
class CustomerInput:
|
||||
name: str
|
||||
status: str
|
||||
start_date: datetime.date
|
||||
end_date: Optional[datetime.date] = None
|
||||
billing_terms: str
|
||||
billing_email: str
|
||||
wave_customer_id: Optional[str] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class CustomerUpdateInput:
|
||||
id: GlobalID
|
||||
name: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
start_date: Optional[datetime.date] = None
|
||||
end_date: Optional[datetime.date] = None
|
||||
billing_terms: Optional[str] = None
|
||||
billing_email: Optional[str] = None
|
||||
wave_customer_id: Optional[str] = None
|
||||
|
||||
|
||||
# CustomerAddress inputs
|
||||
@strawberry.input
|
||||
class CustomerAddressInput:
|
||||
customer_id: GlobalID
|
||||
street_address: str
|
||||
city: str
|
||||
state: str
|
||||
zip_code: str
|
||||
address_type: str
|
||||
is_active: bool = True
|
||||
is_primary: bool = False
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class CustomerAddressUpdateInput:
|
||||
id: GlobalID
|
||||
street_address: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
zip_code: Optional[str] = None
|
||||
address_type: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
is_primary: Optional[bool] = None
|
||||
|
||||
|
||||
# CustomerContact inputs
|
||||
@strawberry.input
|
||||
class CustomerContactInput:
|
||||
customer_id: GlobalID
|
||||
first_name: str
|
||||
last_name: str
|
||||
phone: str
|
||||
email: str
|
||||
is_primary: bool = False
|
||||
is_active: bool = True
|
||||
notes: str = ""
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class CustomerContactUpdateInput:
|
||||
id: GlobalID
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
is_primary: Optional[bool] = None
|
||||
is_active: Optional[bool] = None
|
||||
notes: Optional[str] = None
|
||||
29
core/graphql/inputs/invoice.py
Normal file
29
core/graphql/inputs/invoice.py
Normal file
@ -0,0 +1,29 @@
|
||||
import datetime
|
||||
from typing import List, Optional
|
||||
import strawberry
|
||||
from strawberry.relay import GlobalID
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class InvoiceInput:
|
||||
date: datetime.date
|
||||
customer_id: GlobalID
|
||||
status: str
|
||||
date_paid: Optional[datetime.date] = None
|
||||
payment_type: Optional[str] = None
|
||||
project_ids: Optional[List[GlobalID]] = None
|
||||
revenue_ids: Optional[List[GlobalID]] = None
|
||||
wave_invoice_id: Optional[str] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class InvoiceUpdateInput:
|
||||
id: GlobalID
|
||||
date: Optional[datetime.date] = None
|
||||
customer_id: Optional[GlobalID] = None
|
||||
status: Optional[str] = None
|
||||
date_paid: Optional[datetime.date] = None
|
||||
payment_type: Optional[str] = None
|
||||
project_ids: Optional[List[GlobalID]] = None
|
||||
revenue_ids: Optional[List[GlobalID]] = None
|
||||
wave_invoice_id: Optional[str] = None
|
||||
21
core/graphql/inputs/labor.py
Normal file
21
core/graphql/inputs/labor.py
Normal file
@ -0,0 +1,21 @@
|
||||
import datetime
|
||||
from typing import Optional
|
||||
import strawberry
|
||||
from strawberry.relay import GlobalID
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class LaborInput:
|
||||
account_address_id: GlobalID
|
||||
amount: float
|
||||
start_date: datetime.date
|
||||
end_date: Optional[datetime.date] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class LaborUpdateInput:
|
||||
id: GlobalID
|
||||
account_address_id: Optional[GlobalID] = None
|
||||
amount: Optional[float] = None
|
||||
start_date: Optional[datetime.date] = None
|
||||
end_date: Optional[datetime.date] = None
|
||||
75
core/graphql/inputs/messaging.py
Normal file
75
core/graphql/inputs/messaging.py
Normal file
@ -0,0 +1,75 @@
|
||||
from typing import List, Optional
|
||||
import strawberry
|
||||
from strawberry.relay import GlobalID
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ConversationInput:
|
||||
"""Input for creating a new conversation"""
|
||||
subject: str
|
||||
conversation_type: str # DIRECT, GROUP, SUPPORT
|
||||
participant_ids: List[GlobalID] # List of TeamProfile or CustomerProfile IDs
|
||||
entity_type: Optional[str] = None # e.g., "Project", "Service", "Account"
|
||||
entity_id: Optional[GlobalID] = None # UUID of the entity
|
||||
metadata: Optional[str] = None # JSON string
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ConversationUpdateInput:
|
||||
"""Input for updating a conversation"""
|
||||
id: GlobalID
|
||||
subject: Optional[str] = None
|
||||
is_archived: Optional[bool] = None
|
||||
metadata: Optional[str] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class MessageInput:
|
||||
"""Input for sending a new message"""
|
||||
conversation_id: GlobalID
|
||||
body: str
|
||||
reply_to_id: Optional[GlobalID] = None # For threading
|
||||
attachments: Optional[str] = None # JSON string with attachment metadata
|
||||
metadata: Optional[str] = None # JSON string
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class MessageUpdateInput:
|
||||
"""Input for updating a message (limited fields)"""
|
||||
id: GlobalID
|
||||
body: str
|
||||
attachments: Optional[str] = None # JSON string with attachment metadata
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class AddParticipantInput:
|
||||
"""Input for adding a participant to a conversation"""
|
||||
conversation_id: GlobalID
|
||||
participant_id: GlobalID # TeamProfile or CustomerProfile ID
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class RemoveParticipantInput:
|
||||
"""Input for removing a participant from a conversation"""
|
||||
conversation_id: GlobalID
|
||||
participant_id: GlobalID
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class MarkAsReadInput:
|
||||
"""Input for marking messages as read"""
|
||||
conversation_id: GlobalID
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ArchiveConversationInput:
|
||||
"""Input for archiving/unarchiving a conversation"""
|
||||
conversation_id: GlobalID
|
||||
is_archived: bool
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class MuteConversationInput:
|
||||
"""Input for muting/unmuting a conversation"""
|
||||
conversation_id: GlobalID
|
||||
is_muted: bool
|
||||
53
core/graphql/inputs/profile.py
Normal file
53
core/graphql/inputs/profile.py
Normal file
@ -0,0 +1,53 @@
|
||||
from typing import Optional, List
|
||||
import strawberry
|
||||
from strawberry.relay import GlobalID
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class CustomerProfileInput:
|
||||
user_id: Optional[GlobalID] = None
|
||||
first_name: str
|
||||
last_name: str
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
status: str = 'PENDING'
|
||||
notes: Optional[str] = ''
|
||||
customer_ids: Optional[List[GlobalID]] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class CustomerProfileUpdateInput:
|
||||
id: GlobalID
|
||||
user_id: Optional[GlobalID] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
customer_ids: Optional[List[GlobalID]] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class TeamProfileInput:
|
||||
user_id: Optional[GlobalID] = None
|
||||
first_name: str
|
||||
last_name: str
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
status: str = 'PENDING'
|
||||
notes: Optional[str] = None
|
||||
role: str
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class TeamProfileUpdateInput:
|
||||
id: GlobalID
|
||||
user_id: Optional[GlobalID] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
45
core/graphql/inputs/project.py
Normal file
45
core/graphql/inputs/project.py
Normal file
@ -0,0 +1,45 @@
|
||||
import datetime
|
||||
from typing import List, Optional
|
||||
import strawberry
|
||||
from strawberry.relay import GlobalID
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ProjectInput:
|
||||
customer_id: GlobalID
|
||||
account_address_id: Optional[GlobalID] = None
|
||||
street_address: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
zip_code: Optional[str] = None
|
||||
name: str
|
||||
date: datetime.date
|
||||
status: str
|
||||
notes: Optional[str] = None
|
||||
labor: float
|
||||
amount: float
|
||||
team_member_ids: Optional[List[GlobalID]] = None
|
||||
scope_id: Optional[GlobalID] = None
|
||||
calendar_event_id: Optional[str] = None
|
||||
wave_service_id: Optional[str] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ProjectUpdateInput:
|
||||
id: GlobalID
|
||||
customer_id: Optional[GlobalID] = None
|
||||
account_address_id: Optional[GlobalID] = None
|
||||
street_address: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
zip_code: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
date: Optional[datetime.date] = None
|
||||
status: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
labor: Optional[float] = None
|
||||
amount: Optional[float] = None
|
||||
team_member_ids: Optional[List[GlobalID]] = None
|
||||
scope_id: Optional[GlobalID] = None
|
||||
calendar_event_id: Optional[str] = None
|
||||
wave_service_id: Optional[str] = None
|
||||
17
core/graphql/inputs/project_punchlist.py
Normal file
17
core/graphql/inputs/project_punchlist.py
Normal file
@ -0,0 +1,17 @@
|
||||
import datetime
|
||||
from typing import Optional
|
||||
import strawberry
|
||||
from strawberry.relay import GlobalID
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ProjectPunchlistInput:
|
||||
project_id: GlobalID
|
||||
date: datetime.date
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ProjectPunchlistUpdateInput:
|
||||
id: GlobalID
|
||||
project_id: Optional[GlobalID] = None
|
||||
date: Optional[datetime.date] = None
|
||||
66
core/graphql/inputs/project_scope.py
Normal file
66
core/graphql/inputs/project_scope.py
Normal file
@ -0,0 +1,66 @@
|
||||
from typing import Optional
|
||||
import strawberry
|
||||
from strawberry.relay import GlobalID
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ProjectScopeInput:
|
||||
name: str
|
||||
project_id: GlobalID
|
||||
account_id: Optional[GlobalID] = None
|
||||
account_address_id: Optional[GlobalID] = None
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = True
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ProjectScopeUpdateInput:
|
||||
id: GlobalID
|
||||
name: Optional[str] = None
|
||||
account_id: Optional[GlobalID] = None
|
||||
account_address_id: Optional[GlobalID] = None
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ProjectScopeCategoryInput:
|
||||
scope_id: GlobalID
|
||||
name: str
|
||||
order: int = 0
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ProjectScopeCategoryUpdateInput:
|
||||
id: GlobalID
|
||||
name: Optional[str] = None
|
||||
order: Optional[int] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ProjectScopeTaskInput:
|
||||
category_id: GlobalID
|
||||
description: str
|
||||
checklist_description: Optional[str] = ""
|
||||
order: int = 0
|
||||
estimated_minutes: Optional[int] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ProjectScopeTaskUpdateInput:
|
||||
id: GlobalID
|
||||
description: Optional[str] = None
|
||||
checklist_description: Optional[str] = None
|
||||
order: Optional[int] = None
|
||||
estimated_minutes: Optional[int] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class CreateProjectScopeFromTemplateInput:
|
||||
template_id: GlobalID
|
||||
project_id: GlobalID
|
||||
account_id: Optional[GlobalID] = None
|
||||
account_address_id: Optional[GlobalID] = None
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = True
|
||||
50
core/graphql/inputs/project_scope_template.py
Normal file
50
core/graphql/inputs/project_scope_template.py
Normal file
@ -0,0 +1,50 @@
|
||||
from typing import Optional
|
||||
import strawberry
|
||||
from strawberry.relay import GlobalID
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ProjectScopeTemplateInput:
|
||||
name: str
|
||||
description: Optional[str] = ""
|
||||
is_active: Optional[bool] = True
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ProjectScopeTemplateUpdateInput:
|
||||
id: GlobalID
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ProjectAreaTemplateInput:
|
||||
scope_template_id: GlobalID
|
||||
name: str
|
||||
order: int = 0
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ProjectAreaTemplateUpdateInput:
|
||||
id: GlobalID
|
||||
name: Optional[str] = None
|
||||
order: Optional[int] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ProjectTaskTemplateInput:
|
||||
area_template_id: GlobalID
|
||||
description: str
|
||||
checklist_description: Optional[str] = ""
|
||||
order: int = 0
|
||||
estimated_minutes: Optional[int] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ProjectTaskTemplateUpdateInput:
|
||||
id: GlobalID
|
||||
description: Optional[str] = None
|
||||
checklist_description: Optional[str] = None
|
||||
order: Optional[int] = None
|
||||
estimated_minutes: Optional[int] = None
|
||||
21
core/graphql/inputs/report.py
Normal file
21
core/graphql/inputs/report.py
Normal file
@ -0,0 +1,21 @@
|
||||
import datetime
|
||||
from typing import List, Optional
|
||||
import strawberry
|
||||
from strawberry.relay import GlobalID
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ReportInput:
|
||||
date: datetime.date
|
||||
team_member_id: GlobalID
|
||||
service_ids: Optional[List[GlobalID]] = None
|
||||
project_ids: Optional[List[GlobalID]] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ReportUpdateInput:
|
||||
id: GlobalID
|
||||
date: Optional[datetime.date] = None
|
||||
team_member_id: Optional[GlobalID] = None
|
||||
service_ids: Optional[List[GlobalID]] = None
|
||||
project_ids: Optional[List[GlobalID]] = None
|
||||
23
core/graphql/inputs/revenue.py
Normal file
23
core/graphql/inputs/revenue.py
Normal file
@ -0,0 +1,23 @@
|
||||
import datetime
|
||||
from typing import Optional
|
||||
import strawberry
|
||||
from strawberry.relay import GlobalID
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class RevenueInput:
|
||||
account_id: GlobalID
|
||||
amount: float
|
||||
start_date: datetime.date
|
||||
end_date: Optional[datetime.date] = None
|
||||
wave_service_id: Optional[str] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class RevenueUpdateInput:
|
||||
id: GlobalID
|
||||
account_id: Optional[GlobalID] = None
|
||||
amount: Optional[float] = None
|
||||
start_date: Optional[datetime.date] = None
|
||||
end_date: Optional[datetime.date] = None
|
||||
wave_service_id: Optional[str] = None
|
||||
39
core/graphql/inputs/schedule.py
Normal file
39
core/graphql/inputs/schedule.py
Normal file
@ -0,0 +1,39 @@
|
||||
import datetime
|
||||
from typing import Optional
|
||||
import strawberry
|
||||
from strawberry.relay import GlobalID
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ScheduleInput:
|
||||
name: Optional[str] = None
|
||||
account_address_id: GlobalID = None
|
||||
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
|
||||
start_date: datetime.date
|
||||
end_date: Optional[datetime.date] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ScheduleUpdateInput:
|
||||
id: GlobalID
|
||||
account_address_id: Optional[GlobalID]
|
||||
name: Optional[str] = None
|
||||
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[datetime.date] = None
|
||||
end_date: Optional[datetime.date] = None
|
||||
84
core/graphql/inputs/scope.py
Normal file
84
core/graphql/inputs/scope.py
Normal file
@ -0,0 +1,84 @@
|
||||
import datetime
|
||||
from typing import Optional
|
||||
import strawberry
|
||||
from strawberry.relay import GlobalID
|
||||
|
||||
|
||||
# Scope inputs
|
||||
@strawberry.input
|
||||
class ScopeInput:
|
||||
name: str
|
||||
account_id: GlobalID
|
||||
account_address_id: Optional[GlobalID] = None
|
||||
description: str = ""
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ScopeUpdateInput:
|
||||
id: GlobalID
|
||||
name: Optional[str] = None
|
||||
account_id: Optional[GlobalID] = None
|
||||
account_address_id: Optional[GlobalID] = None
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
# Area inputs
|
||||
@strawberry.input
|
||||
class AreaInput:
|
||||
name: str
|
||||
scope_id: GlobalID
|
||||
order: int = 0
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class AreaUpdateInput:
|
||||
id: GlobalID
|
||||
name: Optional[str] = None
|
||||
scope_id: Optional[GlobalID] = None
|
||||
order: Optional[int] = None
|
||||
|
||||
|
||||
# Task inputs
|
||||
@strawberry.input
|
||||
class TaskInput:
|
||||
area_id: GlobalID
|
||||
description: str
|
||||
checklist_description: Optional[str] = None
|
||||
frequency: str
|
||||
order: int = 0
|
||||
is_conditional: bool = False
|
||||
estimated_minutes: Optional[int] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class TaskUpdateInput:
|
||||
id: GlobalID
|
||||
area_id: Optional[GlobalID] = None
|
||||
description: Optional[str] = None
|
||||
checklist_description: Optional[str] = None
|
||||
frequency: Optional[str] = None
|
||||
order: Optional[int] = None
|
||||
is_conditional: Optional[bool] = None
|
||||
estimated_minutes: Optional[int] = None
|
||||
|
||||
|
||||
# TaskCompletion inputs
|
||||
@strawberry.input
|
||||
class TaskCompletionInput:
|
||||
service_id: GlobalID
|
||||
task_id: GlobalID
|
||||
completed_by_id: GlobalID
|
||||
completed_at: datetime.datetime
|
||||
notes: str = ""
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class TaskCompletionUpdateInput:
|
||||
id: GlobalID
|
||||
service_id: Optional[GlobalID] = None
|
||||
task_id: Optional[GlobalID] = None
|
||||
completed_by_id: Optional[GlobalID] = None
|
||||
completed_at: Optional[datetime.datetime] = None
|
||||
notes: Optional[str] = None
|
||||
63
core/graphql/inputs/scope_template.py
Normal file
63
core/graphql/inputs/scope_template.py
Normal file
@ -0,0 +1,63 @@
|
||||
import strawberry
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ScopeTemplateInput:
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = True
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ScopeTemplateUpdateInput:
|
||||
id: strawberry.ID
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class AreaTemplateInput:
|
||||
scope_template_id: strawberry.ID
|
||||
name: str
|
||||
order: Optional[int] = 0
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class AreaTemplateUpdateInput:
|
||||
id: strawberry.ID
|
||||
name: Optional[str] = None
|
||||
order: Optional[int] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class TaskTemplateInput:
|
||||
area_template_id: strawberry.ID
|
||||
description: str
|
||||
checklist_description: Optional[str] = None
|
||||
frequency: str # Must match TaskFrequencyChoices values
|
||||
order: Optional[int] = 0
|
||||
is_conditional: Optional[bool] = False
|
||||
estimated_minutes: Optional[int] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class TaskTemplateUpdateInput:
|
||||
id: strawberry.ID
|
||||
description: Optional[str] = None
|
||||
checklist_description: Optional[str] = None
|
||||
frequency: Optional[str] = None
|
||||
order: Optional[int] = None
|
||||
is_conditional: Optional[bool] = None
|
||||
estimated_minutes: Optional[int] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class CreateScopeFromTemplateInput:
|
||||
template_id: strawberry.ID
|
||||
account_id: strawberry.ID
|
||||
account_address_id: Optional[strawberry.ID] = None
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = True
|
||||
34
core/graphql/inputs/service.py
Normal file
34
core/graphql/inputs/service.py
Normal file
@ -0,0 +1,34 @@
|
||||
import datetime
|
||||
from typing import List, Optional
|
||||
import strawberry
|
||||
from strawberry.relay import GlobalID
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ServiceInput:
|
||||
account_id: Optional[GlobalID] = None
|
||||
account_address_id: GlobalID
|
||||
date: datetime.date
|
||||
status: str
|
||||
notes: Optional[str] = None
|
||||
team_member_ids: Optional[List[GlobalID]] = None
|
||||
calendar_event_id: Optional[str] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ServiceUpdateInput:
|
||||
id: GlobalID
|
||||
account_id: Optional[GlobalID] = None
|
||||
account_address_id: Optional[GlobalID] = None
|
||||
date: Optional[datetime.date] = None
|
||||
status: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
team_member_ids: Optional[List[GlobalID]] = None
|
||||
calendar_event_id: Optional[str] = None
|
||||
|
||||
@strawberry.input
|
||||
class ServiceGenerationInput:
|
||||
account_address_id: GlobalID
|
||||
schedule_id: GlobalID
|
||||
month: int
|
||||
year: int
|
||||
36
core/graphql/inputs/session.py
Normal file
36
core/graphql/inputs/session.py
Normal file
@ -0,0 +1,36 @@
|
||||
from typing import List, Optional
|
||||
import strawberry
|
||||
from strawberry import ID
|
||||
from strawberry.relay import GlobalID
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class OpenServiceSessionInput:
|
||||
service_id: ID
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class CloseServiceSessionInput:
|
||||
service_id: ID
|
||||
task_ids: List[ID]
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class RevertServiceSessionInput:
|
||||
service_id: ID
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ProjectSessionStartInput:
|
||||
project_id: ID
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ProjectSessionCloseInput:
|
||||
project_id: ID
|
||||
completed_task_ids: Optional[List[ID]] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ProjectSessionRevertInput:
|
||||
project_id: ID
|
||||
17
core/graphql/inputs/session_image.py
Normal file
17
core/graphql/inputs/session_image.py
Normal file
@ -0,0 +1,17 @@
|
||||
import strawberry
|
||||
from typing import Optional
|
||||
from strawberry.relay import GlobalID
|
||||
|
||||
@strawberry.input
|
||||
class ServiceSessionImageUpdateInput:
|
||||
id: GlobalID
|
||||
title: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
internal: Optional[bool] = None
|
||||
|
||||
@strawberry.input
|
||||
class ProjectSessionImageUpdateInput:
|
||||
id: GlobalID
|
||||
title: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
internal: Optional[bool] = None
|
||||
35
core/graphql/inputs/session_note.py
Normal file
35
core/graphql/inputs/session_note.py
Normal file
@ -0,0 +1,35 @@
|
||||
import strawberry
|
||||
from typing import Optional
|
||||
from strawberry.relay import GlobalID
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ServiceSessionNoteInput:
|
||||
session_id: GlobalID
|
||||
content: str
|
||||
author_id: Optional[GlobalID] = None
|
||||
internal: bool = True
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ServiceSessionNoteUpdateInput:
|
||||
id: GlobalID
|
||||
content: Optional[str] = None
|
||||
author_id: Optional[GlobalID] = None
|
||||
internal: Optional[bool] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ProjectSessionNoteInput:
|
||||
session_id: GlobalID
|
||||
content: str
|
||||
author_id: Optional[GlobalID] = None
|
||||
internal: bool = True
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class ProjectSessionNoteUpdateInput:
|
||||
id: GlobalID
|
||||
content: Optional[str] = None
|
||||
author_id: Optional[GlobalID] = None
|
||||
internal: Optional[bool] = None
|
||||
17
core/graphql/inputs/session_video.py
Normal file
17
core/graphql/inputs/session_video.py
Normal file
@ -0,0 +1,17 @@
|
||||
import strawberry
|
||||
from typing import Optional
|
||||
from strawberry.relay import GlobalID
|
||||
|
||||
@strawberry.input
|
||||
class ServiceSessionVideoUpdateInput:
|
||||
id: GlobalID
|
||||
title: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
internal: Optional[bool] = None
|
||||
|
||||
@strawberry.input
|
||||
class ProjectSessionVideoUpdateInput:
|
||||
id: GlobalID
|
||||
title: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
internal: Optional[bool] = None
|
||||
18
core/graphql/mutations/__init__.py
Normal file
18
core/graphql/mutations/__init__.py
Normal file
@ -0,0 +1,18 @@
|
||||
from core.graphql.mutations.customer import *
|
||||
from core.graphql.mutations.account import *
|
||||
from core.graphql.mutations.profile import *
|
||||
from core.graphql.mutations.project import *
|
||||
from core.graphql.mutations.service import *
|
||||
from core.graphql.mutations.labor import *
|
||||
from core.graphql.mutations.revenue import *
|
||||
from core.graphql.mutations.schedule import *
|
||||
from core.graphql.mutations.invoice import *
|
||||
from core.graphql.mutations.report import *
|
||||
from core.graphql.mutations.account_punchlist import *
|
||||
from core.graphql.mutations.project_punchlist import *
|
||||
from core.graphql.mutations.scope import *
|
||||
from core.graphql.mutations.scope_template import *
|
||||
from core.graphql.mutations.project_scope import *
|
||||
from core.graphql.mutations.project_scope_template import *
|
||||
from core.graphql.mutations.session import *
|
||||
from core.graphql.mutations.session_image import *
|
||||
188
core/graphql/mutations/account.py
Normal file
188
core/graphql/mutations/account.py
Normal file
@ -0,0 +1,188 @@
|
||||
from typing import cast
|
||||
import strawberry
|
||||
from strawberry.types import Info
|
||||
from channels.db import database_sync_to_async
|
||||
from core.graphql.pubsub import pubsub
|
||||
from core.graphql.inputs.account import (
|
||||
AccountInput, AccountUpdateInput,
|
||||
AccountAddressInput, AccountAddressUpdateInput,
|
||||
AccountContactInput, AccountContactUpdateInput,
|
||||
)
|
||||
from core.graphql.types.account import (
|
||||
AccountType,
|
||||
AccountAddressType,
|
||||
AccountContactType,
|
||||
)
|
||||
from core.models.account import Account, AccountAddress, AccountContact
|
||||
from core.graphql.utils import create_object, update_object, delete_object
|
||||
from core.services.events import (
|
||||
publish_account_created, publish_account_updated, publish_account_deleted,
|
||||
publish_account_status_changed,
|
||||
publish_account_address_created, publish_account_address_updated, publish_account_address_deleted,
|
||||
publish_account_contact_created, publish_account_contact_updated, publish_account_contact_deleted,
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Mutation:
|
||||
@strawberry.mutation(description="Create a new account")
|
||||
async def create_account(self, input: AccountInput, info: Info) -> AccountType:
|
||||
instance = await create_object(input, Account)
|
||||
await pubsub.publish("account_created", instance.id)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_account_created(
|
||||
account_id=str(instance.id),
|
||||
triggered_by=profile,
|
||||
metadata={'customer_id': str(instance.customer_id), 'status': instance.status, 'name': instance.name}
|
||||
)
|
||||
|
||||
return cast(AccountType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing account")
|
||||
async def update_account(self, input: AccountUpdateInput, info: Info) -> AccountType:
|
||||
# Get old status for comparison
|
||||
old_account = await database_sync_to_async(Account.objects.get)(pk=input.id.node_id)
|
||||
old_status = old_account.status
|
||||
|
||||
instance = await update_object(input, Account)
|
||||
await pubsub.publish("account_updated", instance.id)
|
||||
|
||||
# Publish events for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_account_updated(
|
||||
account_id=str(instance.id),
|
||||
triggered_by=profile,
|
||||
metadata={'name': instance.name}
|
||||
)
|
||||
|
||||
# Check for status change
|
||||
if hasattr(input, 'status') and input.status != old_status:
|
||||
await publish_account_status_changed(
|
||||
account_id=str(instance.id),
|
||||
old_status=old_status,
|
||||
new_status=instance.status,
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(AccountType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete an existing account")
|
||||
async def delete_account(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
instance = await delete_object(id, Account)
|
||||
if not instance:
|
||||
raise ValueError(f"Account with ID {id} does not exist")
|
||||
await pubsub.publish("account_deleted", id)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_account_deleted(
|
||||
account_id=str(id),
|
||||
triggered_by=profile,
|
||||
metadata={'name': instance.name}
|
||||
)
|
||||
|
||||
return id
|
||||
|
||||
@strawberry.mutation(description="Create a new account address")
|
||||
async def create_account_address(
|
||||
self, input: AccountAddressInput, info: Info
|
||||
) -> AccountAddressType:
|
||||
instance = await create_object(input, AccountAddress)
|
||||
await pubsub.publish("account_address_created", instance.id)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_account_address_created(
|
||||
address_id=str(instance.id),
|
||||
account_id=str(instance.account_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(AccountAddressType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing account address")
|
||||
async def update_account_address(
|
||||
self, input: AccountAddressUpdateInput, info: Info
|
||||
) -> AccountAddressType:
|
||||
instance = await update_object(input, AccountAddress)
|
||||
await pubsub.publish("account_address_updated", instance.id)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_account_address_updated(
|
||||
address_id=str(instance.id),
|
||||
account_id=str(instance.account_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(AccountAddressType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete an existing account address")
|
||||
async def delete_account_address(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
instance = await delete_object(id, AccountAddress)
|
||||
if not instance:
|
||||
raise ValueError(f"AccountAddress with ID {id} does not exist")
|
||||
await pubsub.publish("account_address_deleted", id)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_account_address_deleted(
|
||||
address_id=str(id),
|
||||
account_id=str(instance.account_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return id
|
||||
|
||||
@strawberry.mutation(description="Create a new account contact")
|
||||
async def create_account_contact(
|
||||
self, input: AccountContactInput, info: Info
|
||||
) -> AccountContactType:
|
||||
instance = await create_object(input, AccountContact)
|
||||
await pubsub.publish("account_contact_created", instance.id)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_account_contact_created(
|
||||
contact_id=str(instance.id),
|
||||
account_id=str(instance.account_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(AccountContactType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing account contact")
|
||||
async def update_account_contact(
|
||||
self, input: AccountContactUpdateInput, info: Info
|
||||
) -> AccountContactType:
|
||||
instance = await update_object(input, AccountContact)
|
||||
await pubsub.publish("account_contact_updated", instance.id)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_account_contact_updated(
|
||||
contact_id=str(instance.id),
|
||||
account_id=str(instance.account_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(AccountContactType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete an existing account contact")
|
||||
async def delete_account_contact(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
instance = await delete_object(id, AccountContact)
|
||||
if not instance:
|
||||
raise ValueError(f"AccountContact with ID {id} does not exist")
|
||||
await pubsub.publish("account_contact_deleted", id)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_account_contact_deleted(
|
||||
contact_id=str(id),
|
||||
account_id=str(instance.account_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return id
|
||||
114
core/graphql/mutations/account_punchlist.py
Normal file
114
core/graphql/mutations/account_punchlist.py
Normal file
@ -0,0 +1,114 @@
|
||||
from typing import cast
|
||||
import strawberry
|
||||
from strawberry.types import Info
|
||||
from channels.db import database_sync_to_async
|
||||
from core.graphql.pubsub import pubsub
|
||||
from core.graphql.inputs.account_punchlist import (
|
||||
AccountPunchlistInput,
|
||||
AccountPunchlistUpdateInput,
|
||||
)
|
||||
from core.graphql.types.account_punchlist import AccountPunchlistType
|
||||
from core.models.account_punchlist import AccountPunchlist
|
||||
from core.graphql.utils import create_object, update_object, delete_object
|
||||
from core.services.events import (
|
||||
publish_account_punchlist_created,
|
||||
publish_account_punchlist_updated,
|
||||
publish_account_punchlist_deleted,
|
||||
publish_punchlist_status_changed,
|
||||
publish_punchlist_priority_changed,
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Mutation:
|
||||
@strawberry.mutation(description="Create a new account punchlist")
|
||||
async def create_account_punchlist(
|
||||
self, input: AccountPunchlistInput, info: Info
|
||||
) -> AccountPunchlistType:
|
||||
instance = await create_object(input, AccountPunchlist)
|
||||
await pubsub.publish(f"account_punchlist_created", instance.id)
|
||||
|
||||
# Get profile from request context
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
|
||||
# Publish event
|
||||
await publish_account_punchlist_created(
|
||||
punchlist_id=str(instance.id),
|
||||
account_id=str(instance.account_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(AccountPunchlistType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing account punchlist")
|
||||
async def update_account_punchlist(
|
||||
self, input: AccountPunchlistUpdateInput, info: Info
|
||||
) -> AccountPunchlistType:
|
||||
# Get old instance for comparison
|
||||
old_instance = await database_sync_to_async(
|
||||
AccountPunchlist.objects.get
|
||||
)(id=input.id)
|
||||
|
||||
# Update the instance
|
||||
instance = await update_object(input, AccountPunchlist)
|
||||
await pubsub.publish(f"account_punchlist_updated", instance.id)
|
||||
|
||||
# Get profile from request context
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
|
||||
# Publish update event
|
||||
await publish_account_punchlist_updated(
|
||||
punchlist_id=str(instance.id),
|
||||
account_id=str(instance.account_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
# Check for status changes (if the model has status field)
|
||||
if hasattr(old_instance, 'status') and hasattr(instance, 'status'):
|
||||
if old_instance.status != instance.status:
|
||||
await publish_punchlist_status_changed(
|
||||
punchlist_id=str(instance.id),
|
||||
entity_type='AccountPunchlist',
|
||||
old_status=old_instance.status,
|
||||
new_status=instance.status,
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
# Check for priority changes (if the model has priority field)
|
||||
if hasattr(old_instance, 'priority') and hasattr(instance, 'priority'):
|
||||
if old_instance.priority != instance.priority:
|
||||
await publish_punchlist_priority_changed(
|
||||
punchlist_id=str(instance.id),
|
||||
entity_type='AccountPunchlist',
|
||||
old_priority=old_instance.priority,
|
||||
new_priority=instance.priority,
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(AccountPunchlistType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete an existing account punchlist")
|
||||
async def delete_account_punchlist(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
# Get instance before deletion to access account_id
|
||||
instance = await database_sync_to_async(
|
||||
AccountPunchlist.objects.get
|
||||
)(id=id)
|
||||
|
||||
# Delete the instance
|
||||
deleted_instance = await delete_object(id, AccountPunchlist)
|
||||
if not deleted_instance:
|
||||
raise ValueError(f"AccountPunchlist with ID {id} does not exist")
|
||||
|
||||
await pubsub.publish(f"account_punchlist_deleted", id)
|
||||
|
||||
# Get profile from request context
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
|
||||
# Publish delete event
|
||||
await publish_account_punchlist_deleted(
|
||||
punchlist_id=str(id),
|
||||
account_id=str(instance.account_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return id
|
||||
188
core/graphql/mutations/customer.py
Normal file
188
core/graphql/mutations/customer.py
Normal file
@ -0,0 +1,188 @@
|
||||
from typing import cast
|
||||
import strawberry
|
||||
from strawberry.types import Info
|
||||
from channels.db import database_sync_to_async
|
||||
from core.graphql.pubsub import pubsub
|
||||
from core.graphql.inputs.customer import (
|
||||
CustomerInput, CustomerUpdateInput,
|
||||
CustomerAddressInput, CustomerAddressUpdateInput,
|
||||
CustomerContactInput, CustomerContactUpdateInput,
|
||||
)
|
||||
from core.graphql.types.customer import (
|
||||
CustomerType,
|
||||
CustomerAddressType,
|
||||
CustomerContactType,
|
||||
)
|
||||
from core.models.customer import Customer, CustomerAddress, CustomerContact
|
||||
from core.graphql.utils import create_object, update_object, delete_object
|
||||
from core.services.events import (
|
||||
publish_customer_created, publish_customer_updated, publish_customer_deleted,
|
||||
publish_customer_status_changed,
|
||||
publish_customer_address_created, publish_customer_address_updated, publish_customer_address_deleted,
|
||||
publish_customer_contact_created, publish_customer_contact_updated, publish_customer_contact_deleted,
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Mutation:
|
||||
@strawberry.mutation(description="Create a new customer")
|
||||
async def create_customer(self, input: CustomerInput, info: Info) -> CustomerType:
|
||||
instance = await create_object(input, Customer)
|
||||
await pubsub.publish(f"customer_created", instance.id)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_customer_created(
|
||||
customer_id=str(instance.id),
|
||||
triggered_by=profile,
|
||||
metadata={'status': instance.status, 'name': instance.name}
|
||||
)
|
||||
|
||||
return cast(CustomerType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing customer")
|
||||
async def update_customer(self, input: CustomerUpdateInput, info: Info) -> CustomerType:
|
||||
# Get old status for comparison
|
||||
old_customer = await database_sync_to_async(Customer.objects.get)(pk=input.id.node_id)
|
||||
old_status = old_customer.status
|
||||
|
||||
instance = await update_object(input, Customer)
|
||||
await pubsub.publish(f"customer_updated", instance.id)
|
||||
|
||||
# Publish events for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_customer_updated(
|
||||
customer_id=str(instance.id),
|
||||
triggered_by=profile,
|
||||
metadata={'name': instance.name}
|
||||
)
|
||||
|
||||
# Check for status change
|
||||
if hasattr(input, 'status') and input.status != old_status:
|
||||
await publish_customer_status_changed(
|
||||
customer_id=str(instance.id),
|
||||
old_status=old_status,
|
||||
new_status=instance.status,
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(CustomerType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete an existing customer")
|
||||
async def delete_customer(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
instance = await delete_object(id, Customer)
|
||||
if not instance:
|
||||
raise ValueError(f"Customer with ID {id} does not exist")
|
||||
await pubsub.publish(f"customer_deleted", id)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_customer_deleted(
|
||||
customer_id=str(id),
|
||||
triggered_by=profile,
|
||||
metadata={'name': instance.name}
|
||||
)
|
||||
|
||||
return id
|
||||
|
||||
@strawberry.mutation(description="Create a new customer address")
|
||||
async def create_customer_address(
|
||||
self, input: CustomerAddressInput, info: Info
|
||||
) -> CustomerAddressType:
|
||||
instance = await create_object(input, CustomerAddress)
|
||||
await pubsub.publish(f"customer_address_created", instance.id)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_customer_address_created(
|
||||
address_id=str(instance.id),
|
||||
customer_id=str(instance.customer_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(CustomerAddressType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing customer address")
|
||||
async def update_customer_address(
|
||||
self, input: CustomerAddressUpdateInput, info: Info
|
||||
) -> CustomerAddressType:
|
||||
instance = await update_object(input, CustomerAddress)
|
||||
await pubsub.publish(f"customer_address_updated", instance.id)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_customer_address_updated(
|
||||
address_id=str(instance.id),
|
||||
customer_id=str(instance.customer_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(CustomerAddressType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete an existing customer address")
|
||||
async def delete_customer_address(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
instance = await delete_object(id, CustomerAddress)
|
||||
if not instance:
|
||||
raise ValueError(f"CustomerAddress with ID {id} does not exist")
|
||||
await pubsub.publish(f"customer_address_deleted", id)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_customer_address_deleted(
|
||||
address_id=str(id),
|
||||
customer_id=str(instance.customer_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return id
|
||||
|
||||
@strawberry.mutation(description="Create a new customer contact")
|
||||
async def create_customer_contact(
|
||||
self, input: CustomerContactInput, info: Info
|
||||
) -> CustomerContactType:
|
||||
instance = await create_object(input, CustomerContact)
|
||||
await pubsub.publish(f"customer_contact_created", instance.id)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_customer_contact_created(
|
||||
contact_id=str(instance.id),
|
||||
customer_id=str(instance.customer_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(CustomerContactType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing customer contact")
|
||||
async def update_customer_contact(
|
||||
self, input: CustomerContactUpdateInput, info: Info
|
||||
) -> CustomerContactType:
|
||||
instance = await update_object(input, CustomerContact)
|
||||
await pubsub.publish(f"customer_contact_updated", instance.id)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_customer_contact_updated(
|
||||
contact_id=str(instance.id),
|
||||
customer_id=str(instance.customer_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(CustomerContactType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete an existing customer contact")
|
||||
async def delete_customer_contact(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
instance = await delete_object(id, CustomerContact)
|
||||
if not instance:
|
||||
raise ValueError(f"CustomerContact with ID {id} does not exist")
|
||||
await pubsub.publish(f"customer_contact_deleted", id)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_customer_contact_deleted(
|
||||
contact_id=str(id),
|
||||
customer_id=str(instance.customer_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return id
|
||||
265
core/graphql/mutations/event.py
Normal file
265
core/graphql/mutations/event.py
Normal file
@ -0,0 +1,265 @@
|
||||
import strawberry
|
||||
from typing import List, Optional
|
||||
from strawberry.types import Info
|
||||
from strawberry.relay import GlobalID
|
||||
from channels.db import database_sync_to_async
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils import timezone
|
||||
|
||||
from core.graphql.types.event import NotificationRuleType, NotificationType
|
||||
from core.models.events import NotificationRule, Notification
|
||||
from core.models.enums import (
|
||||
EventTypeChoices,
|
||||
NotificationChannelChoices,
|
||||
RoleChoices
|
||||
)
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class NotificationRuleInput:
|
||||
"""Input for creating a notification rule"""
|
||||
name: str
|
||||
description: Optional[str] = ""
|
||||
event_types: List[EventTypeChoices]
|
||||
channels: List[NotificationChannelChoices]
|
||||
target_roles: Optional[List[RoleChoices]] = None
|
||||
target_team_profile_ids: Optional[List[strawberry.ID]] = None
|
||||
target_customer_profile_ids: Optional[List[strawberry.ID]] = None
|
||||
is_active: Optional[bool] = True
|
||||
template_subject: Optional[str] = ""
|
||||
template_body: Optional[str] = ""
|
||||
conditions: Optional[strawberry.scalars.JSON] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class NotificationRuleUpdateInput:
|
||||
"""Input for updating a notification rule"""
|
||||
id: GlobalID
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
event_types: Optional[List[EventTypeChoices]] = None
|
||||
channels: Optional[List[NotificationChannelChoices]] = None
|
||||
target_roles: Optional[List[RoleChoices]] = None
|
||||
target_team_profile_ids: Optional[List[strawberry.ID]] = None
|
||||
target_customer_profile_ids: Optional[List[strawberry.ID]] = None
|
||||
is_active: Optional[bool] = None
|
||||
template_subject: Optional[str] = None
|
||||
template_body: Optional[str] = None
|
||||
conditions: Optional[strawberry.scalars.JSON] = None
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Mutation:
|
||||
@strawberry.mutation(description="Create a notification rule (Admin only)")
|
||||
async def create_notification_rule(
|
||||
self,
|
||||
info: Info,
|
||||
input: NotificationRuleInput
|
||||
) -> NotificationRuleType:
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
if not profile:
|
||||
raise PermissionError("Authentication required")
|
||||
|
||||
# Only admins can create notification rules
|
||||
from core.models.profile import TeamProfile
|
||||
if not isinstance(profile, TeamProfile) or profile.role != RoleChoices.ADMIN:
|
||||
raise PermissionError("Admin access required")
|
||||
|
||||
# Prepare data
|
||||
data = {
|
||||
'name': input.name,
|
||||
'description': input.description or '',
|
||||
'event_types': input.event_types,
|
||||
'channels': input.channels,
|
||||
'target_roles': input.target_roles or [],
|
||||
'is_active': input.is_active if input.is_active is not None else True,
|
||||
'template_subject': input.template_subject or '',
|
||||
'template_body': input.template_body or '',
|
||||
'conditions': input.conditions or {},
|
||||
}
|
||||
|
||||
# Create rule
|
||||
rule = await database_sync_to_async(NotificationRule.objects.create)(**data)
|
||||
|
||||
# Set M2M relationships
|
||||
if input.target_team_profile_ids:
|
||||
await database_sync_to_async(
|
||||
lambda: rule.target_team_profiles.set(input.target_team_profile_ids)
|
||||
)()
|
||||
|
||||
if input.target_customer_profile_ids:
|
||||
await database_sync_to_async(
|
||||
lambda: rule.target_customer_profiles.set(input.target_customer_profile_ids)
|
||||
)()
|
||||
|
||||
return rule
|
||||
|
||||
@strawberry.mutation(description="Update a notification rule (Admin only)")
|
||||
async def update_notification_rule(
|
||||
self,
|
||||
info: Info,
|
||||
input: NotificationRuleUpdateInput
|
||||
) -> NotificationRuleType:
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
if not profile:
|
||||
raise PermissionError("Authentication required")
|
||||
|
||||
# Only admins can update notification rules
|
||||
from core.models.profile import TeamProfile
|
||||
if not isinstance(profile, TeamProfile) or profile.role != RoleChoices.ADMIN:
|
||||
raise PermissionError("Admin access required")
|
||||
|
||||
# Get rule
|
||||
rule = await database_sync_to_async(NotificationRule.objects.get)(pk=input.id.node_id)
|
||||
|
||||
# Update fields
|
||||
update_fields = []
|
||||
if input.name is not None:
|
||||
rule.name = input.name
|
||||
update_fields.append('name')
|
||||
|
||||
if input.description is not None:
|
||||
rule.description = input.description
|
||||
update_fields.append('description')
|
||||
|
||||
if input.event_types is not None:
|
||||
rule.event_types = input.event_types
|
||||
update_fields.append('event_types')
|
||||
|
||||
if input.channels is not None:
|
||||
rule.channels = input.channels
|
||||
update_fields.append('channels')
|
||||
|
||||
if input.target_roles is not None:
|
||||
rule.target_roles = input.target_roles
|
||||
update_fields.append('target_roles')
|
||||
|
||||
if input.is_active is not None:
|
||||
rule.is_active = input.is_active
|
||||
update_fields.append('is_active')
|
||||
|
||||
if input.template_subject is not None:
|
||||
rule.template_subject = input.template_subject
|
||||
update_fields.append('template_subject')
|
||||
|
||||
if input.template_body is not None:
|
||||
rule.template_body = input.template_body
|
||||
update_fields.append('template_body')
|
||||
|
||||
if input.conditions is not None:
|
||||
rule.conditions = input.conditions
|
||||
update_fields.append('conditions')
|
||||
|
||||
if update_fields:
|
||||
update_fields.append('updated_at')
|
||||
await database_sync_to_async(rule.save)(update_fields=update_fields)
|
||||
|
||||
# Update M2M relationships
|
||||
if input.target_team_profile_ids is not None:
|
||||
await database_sync_to_async(
|
||||
lambda: rule.target_team_profiles.set(input.target_team_profile_ids)
|
||||
)()
|
||||
|
||||
if input.target_customer_profile_ids is not None:
|
||||
await database_sync_to_async(
|
||||
lambda: rule.target_customer_profiles.set(input.target_customer_profile_ids)
|
||||
)()
|
||||
|
||||
return rule
|
||||
|
||||
@strawberry.mutation(description="Delete a notification rule (Admin only)")
|
||||
async def delete_notification_rule(
|
||||
self,
|
||||
info: Info,
|
||||
id: strawberry.ID
|
||||
) -> strawberry.ID:
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
if not profile:
|
||||
raise PermissionError("Authentication required")
|
||||
|
||||
# Only admins can delete notification rules
|
||||
from core.models.profile import TeamProfile
|
||||
if not isinstance(profile, TeamProfile) or profile.role != RoleChoices.ADMIN:
|
||||
raise PermissionError("Admin access required")
|
||||
|
||||
rule = await database_sync_to_async(NotificationRule.objects.get)(pk=id)
|
||||
await database_sync_to_async(rule.delete)()
|
||||
|
||||
return id
|
||||
|
||||
@strawberry.mutation(description="Mark notification as read")
|
||||
async def mark_notification_as_read(
|
||||
self,
|
||||
info: Info,
|
||||
id: strawberry.ID
|
||||
) -> NotificationType:
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
if not profile:
|
||||
raise PermissionError("Authentication required")
|
||||
|
||||
# Get notification
|
||||
notification = await database_sync_to_async(
|
||||
lambda: Notification.objects.select_related('event', 'rule', 'recipient_content_type').get(pk=id)
|
||||
)()
|
||||
|
||||
# Verify user has access to this notification
|
||||
content_type = await database_sync_to_async(ContentType.objects.get_for_model)(profile)
|
||||
|
||||
if (notification.recipient_content_type != content_type or
|
||||
str(notification.recipient_object_id) != str(profile.id)):
|
||||
raise PermissionError("Not authorized to mark this notification as read")
|
||||
|
||||
# Mark as read
|
||||
await database_sync_to_async(lambda: notification.mark_as_read())()
|
||||
|
||||
return notification
|
||||
|
||||
@strawberry.mutation(description="Mark all notifications as read for current user")
|
||||
async def mark_all_notifications_as_read(self, info: Info) -> int:
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
if not profile:
|
||||
return 0
|
||||
|
||||
# Get content type for the profile
|
||||
content_type = await database_sync_to_async(ContentType.objects.get_for_model)(profile)
|
||||
|
||||
# Update all unread notifications
|
||||
from core.models.enums import NotificationStatusChoices
|
||||
|
||||
count = await database_sync_to_async(
|
||||
lambda: Notification.objects.filter(
|
||||
recipient_content_type=content_type,
|
||||
recipient_object_id=profile.id,
|
||||
read_at__isnull=True
|
||||
).update(
|
||||
read_at=timezone.now(),
|
||||
status=NotificationStatusChoices.READ
|
||||
)
|
||||
)()
|
||||
|
||||
return count
|
||||
|
||||
@strawberry.mutation(description="Delete a notification")
|
||||
async def delete_notification(
|
||||
self,
|
||||
info: Info,
|
||||
id: strawberry.ID
|
||||
) -> strawberry.ID:
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
if not profile:
|
||||
raise PermissionError("Authentication required")
|
||||
|
||||
# Get notification and verify access
|
||||
@database_sync_to_async
|
||||
def get_and_verify():
|
||||
notification = Notification.objects.get(pk=id)
|
||||
content_type = ContentType.objects.get_for_model(type(profile))
|
||||
|
||||
if (notification.recipient_content_type != content_type or
|
||||
str(notification.recipient_object_id) != str(profile.id)):
|
||||
raise PermissionError("Not authorized to delete this notification")
|
||||
|
||||
notification.delete()
|
||||
return id
|
||||
|
||||
return await get_and_verify()
|
||||
105
core/graphql/mutations/invoice.py
Normal file
105
core/graphql/mutations/invoice.py
Normal file
@ -0,0 +1,105 @@
|
||||
from typing import cast
|
||||
import strawberry
|
||||
from strawberry.types import Info
|
||||
from core.graphql.pubsub import pubsub
|
||||
from core.graphql.inputs.invoice import InvoiceInput, InvoiceUpdateInput
|
||||
from core.graphql.types.invoice import InvoiceType
|
||||
from core.models.invoice import Invoice
|
||||
from core.models.enums import InvoiceChoices
|
||||
from core.graphql.utils import create_object, update_object, delete_object
|
||||
from core.services.events import publish_invoice_generated, publish_invoice_paid
|
||||
from core.services.events import EventPublisher
|
||||
from core.models.enums import EventTypeChoices
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Mutation:
|
||||
@strawberry.mutation(description="Create a new invoice")
|
||||
async def create_invoice(self, input: InvoiceInput, info: Info) -> InvoiceType:
|
||||
# Exclude m2m id fields from model constructor
|
||||
payload = {k: v for k, v in input.__dict__.items() if k not in {"project_ids", "revenue_ids"}}
|
||||
m2m_data = {
|
||||
"projects": input.project_ids,
|
||||
"revenues": input.revenue_ids,
|
||||
}
|
||||
instance = await create_object(payload, Invoice, m2m_data)
|
||||
await pubsub.publish("invoice_created", instance.id)
|
||||
|
||||
# Publish event for notifications (invoice creation = invoice generated)
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_invoice_generated(
|
||||
invoice_id=str(instance.id),
|
||||
triggered_by=profile,
|
||||
metadata={'customer_id': str(instance.customer_id), 'status': instance.status}
|
||||
)
|
||||
|
||||
return cast(InvoiceType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing invoice")
|
||||
async def update_invoice(self, input: InvoiceUpdateInput, info: Info) -> InvoiceType:
|
||||
# Get old invoice to check for status changes
|
||||
from channels.db import database_sync_to_async
|
||||
old_invoice = await database_sync_to_async(Invoice.objects.get)(pk=input.id.node_id)
|
||||
old_status = old_invoice.status
|
||||
|
||||
# Keep id and non-m2m fields; drop m2m *_ids from the update payload
|
||||
payload = {k: v for k, v in input.__dict__.items() if k not in {"project_ids", "revenue_ids"}}
|
||||
m2m_data = {
|
||||
"projects": getattr(input, "project_ids", None),
|
||||
"revenues": getattr(input, "revenue_ids", None),
|
||||
}
|
||||
instance = await update_object(payload, Invoice, m2m_data)
|
||||
await pubsub.publish("invoice_updated", instance.id)
|
||||
|
||||
# Publish events for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
|
||||
# Check if status changed
|
||||
if hasattr(input, 'status') and input.status and input.status != old_status:
|
||||
# Publish status change event
|
||||
await EventPublisher.publish(
|
||||
event_type=EventTypeChoices.INVOICE_SENT if input.status == InvoiceChoices.SENT else
|
||||
EventTypeChoices.INVOICE_PAID if input.status == InvoiceChoices.PAID else
|
||||
EventTypeChoices.INVOICE_OVERDUE if input.status == InvoiceChoices.OVERDUE else
|
||||
EventTypeChoices.INVOICE_CANCELLED if input.status == InvoiceChoices.CANCELLED else None,
|
||||
entity_type='Invoice',
|
||||
entity_id=str(instance.id),
|
||||
triggered_by=profile,
|
||||
metadata={'old_status': old_status, 'new_status': instance.status, 'customer_id': str(instance.customer_id)}
|
||||
)
|
||||
|
||||
# Special handling for paid invoices
|
||||
if instance.status == InvoiceChoices.PAID:
|
||||
await publish_invoice_paid(
|
||||
invoice_id=str(instance.id),
|
||||
triggered_by=profile,
|
||||
metadata={'customer_id': str(instance.customer_id), 'amount': str(instance.amount)}
|
||||
)
|
||||
|
||||
return cast(InvoiceType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete an existing invoice")
|
||||
async def delete_invoice(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
# Get invoice before deletion to access customer_id for event
|
||||
from channels.db import database_sync_to_async
|
||||
from core.graphql.utils import _decode_global_id
|
||||
pk = _decode_global_id(id)
|
||||
invoice = await database_sync_to_async(Invoice.objects.get)(pk=pk)
|
||||
customer_id = str(invoice.customer_id)
|
||||
|
||||
instance = await delete_object(id, Invoice)
|
||||
if not instance:
|
||||
raise ValueError(f"Invoice with ID {id} does not exist")
|
||||
await pubsub.publish("invoice_deleted", id)
|
||||
|
||||
# Publish event for notifications (deletion treated as cancellation)
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await EventPublisher.publish(
|
||||
event_type=EventTypeChoices.INVOICE_CANCELLED,
|
||||
entity_type='Invoice',
|
||||
entity_id=str(id),
|
||||
triggered_by=profile,
|
||||
metadata={'customer_id': customer_id, 'action': 'deleted'}
|
||||
)
|
||||
|
||||
return id
|
||||
58
core/graphql/mutations/labor.py
Normal file
58
core/graphql/mutations/labor.py
Normal file
@ -0,0 +1,58 @@
|
||||
from typing import cast
|
||||
import strawberry
|
||||
from strawberry.types import Info
|
||||
from core.graphql.pubsub import pubsub
|
||||
from core.graphql.inputs.labor import LaborInput, LaborUpdateInput
|
||||
from core.graphql.types.labor import LaborType
|
||||
from core.models.labor import Labor
|
||||
from core.graphql.utils import create_object, update_object, delete_object
|
||||
from core.services.events import (
|
||||
publish_labor_rate_created, publish_labor_rate_updated, publish_labor_rate_deleted,
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Mutation:
|
||||
@strawberry.mutation(description="Create a new labor rate")
|
||||
async def create_labor(self, input: LaborInput, info: Info) -> LaborType:
|
||||
instance = await create_object(input, Labor)
|
||||
await pubsub.publish("labor_created", instance.id)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_labor_rate_created(
|
||||
rate_id=str(instance.id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(LaborType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing labor rate")
|
||||
async def update_labor(self, input: LaborUpdateInput, info: Info) -> LaborType:
|
||||
instance = await update_object(input, Labor)
|
||||
await pubsub.publish("labor_updated", instance.id)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_labor_rate_updated(
|
||||
rate_id=str(instance.id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(LaborType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete an existing labor rate")
|
||||
async def delete_labor(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
instance = await delete_object(id, Labor)
|
||||
if not instance:
|
||||
raise ValueError(f"Labor with ID {id} does not exist")
|
||||
await pubsub.publish("labor_deleted", id)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_labor_rate_deleted(
|
||||
rate_id=str(id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return id
|
||||
513
core/graphql/mutations/messaging.py
Normal file
513
core/graphql/mutations/messaging.py
Normal file
@ -0,0 +1,513 @@
|
||||
from typing import cast
|
||||
import strawberry
|
||||
from strawberry.types import Info
|
||||
from strawberry.relay import GlobalID
|
||||
from channels.db import database_sync_to_async
|
||||
from django.utils import timezone
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
import json
|
||||
|
||||
from core.graphql.pubsub import pubsub
|
||||
from core.graphql.inputs.messaging import (
|
||||
ConversationInput,
|
||||
ConversationUpdateInput,
|
||||
MessageInput,
|
||||
MessageUpdateInput,
|
||||
AddParticipantInput,
|
||||
RemoveParticipantInput,
|
||||
MarkAsReadInput,
|
||||
ArchiveConversationInput,
|
||||
MuteConversationInput,
|
||||
)
|
||||
from core.graphql.types.messaging import ConversationType, MessageType, ConversationParticipantType
|
||||
from core.models.messaging import Conversation, Message, ConversationParticipant, MessageReadReceipt
|
||||
from core.models.profile import TeamProfile, CustomerProfile
|
||||
from core.models.enums import EventTypeChoices
|
||||
from core.services.events import EventPublisher
|
||||
|
||||
|
||||
def is_admin_profile(profile) -> bool:
|
||||
"""Check if the profile is the admin profile"""
|
||||
from django.conf import settings
|
||||
return str(profile.id) == settings.DISPATCH_TEAM_PROFILE_ID
|
||||
|
||||
|
||||
@database_sync_to_async
|
||||
def get_profile_from_id(participant_id: str):
|
||||
"""Helper to get TeamProfile or CustomerProfile from GlobalID"""
|
||||
# Try TeamProfile first
|
||||
try:
|
||||
return TeamProfile.objects.get(pk=participant_id)
|
||||
except TeamProfile.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Try CustomerProfile
|
||||
try:
|
||||
return CustomerProfile.objects.get(pk=participant_id)
|
||||
except CustomerProfile.DoesNotExist:
|
||||
raise ValueError(f"Profile with ID {participant_id} not found")
|
||||
|
||||
|
||||
@database_sync_to_async
|
||||
def get_entity_from_type_and_id(entity_type: str, entity_id: str):
|
||||
"""Helper to get entity (Project, Service, etc.) from type and ID"""
|
||||
from django.apps import apps
|
||||
|
||||
try:
|
||||
model = apps.get_model('core', entity_type)
|
||||
return model.objects.get(pk=entity_id)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Entity {entity_type} with ID {entity_id} not found: {e}")
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Mutation:
|
||||
@strawberry.mutation(description="Create a new conversation")
|
||||
async def create_conversation(self, input: ConversationInput, info: Info) -> ConversationType:
|
||||
"""
|
||||
Create a new conversation with participants and optional entity link.
|
||||
"""
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
if not profile:
|
||||
raise ValueError("User must be authenticated")
|
||||
|
||||
# Parse metadata if provided
|
||||
metadata = json.loads(input.metadata) if input.metadata else {}
|
||||
|
||||
# Create conversation
|
||||
@database_sync_to_async
|
||||
def create():
|
||||
# Get creator content type inside sync context
|
||||
creator_content_type = ContentType.objects.get_for_model(type(profile))
|
||||
|
||||
conversation = Conversation.objects.create(
|
||||
subject=input.subject,
|
||||
conversation_type=input.conversation_type,
|
||||
created_by_content_type=creator_content_type,
|
||||
created_by_object_id=profile.id,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
# Link to entity if provided
|
||||
if input.entity_type and input.entity_id:
|
||||
from django.apps import apps
|
||||
try:
|
||||
model = apps.get_model('core', input.entity_type)
|
||||
content_type = ContentType.objects.get_for_model(model)
|
||||
conversation.entity_content_type = content_type
|
||||
# Extract UUID from GlobalID
|
||||
conversation.entity_object_id = input.entity_id.node_id
|
||||
conversation.save()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Add creator as a participant first
|
||||
ConversationParticipant.objects.create(
|
||||
conversation=conversation,
|
||||
participant_content_type=creator_content_type,
|
||||
participant_object_id=profile.id
|
||||
)
|
||||
|
||||
# Add other participants
|
||||
for participant_id in input.participant_ids:
|
||||
# Extract UUID from GlobalID
|
||||
uuid = participant_id.node_id
|
||||
try:
|
||||
participant = TeamProfile.objects.get(pk=uuid)
|
||||
content_type = ContentType.objects.get_for_model(TeamProfile)
|
||||
except TeamProfile.DoesNotExist:
|
||||
try:
|
||||
participant = CustomerProfile.objects.get(pk=uuid)
|
||||
content_type = ContentType.objects.get_for_model(CustomerProfile)
|
||||
except CustomerProfile.DoesNotExist:
|
||||
continue
|
||||
|
||||
# Skip if this participant is the creator (already added)
|
||||
if content_type == creator_content_type and participant.id == profile.id:
|
||||
continue
|
||||
|
||||
ConversationParticipant.objects.create(
|
||||
conversation=conversation,
|
||||
participant_content_type=content_type,
|
||||
participant_object_id=participant.id
|
||||
)
|
||||
|
||||
return conversation
|
||||
|
||||
instance = await create()
|
||||
await pubsub.publish("conversation_created", instance.id)
|
||||
|
||||
# Publish event
|
||||
await EventPublisher.publish(
|
||||
event_type=EventTypeChoices.CONVERSATION_CREATED,
|
||||
entity_type='Conversation',
|
||||
entity_id=str(instance.id),
|
||||
triggered_by=profile,
|
||||
metadata={'subject': instance.subject, 'type': instance.conversation_type}
|
||||
)
|
||||
|
||||
return cast(ConversationType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update a conversation")
|
||||
async def update_conversation(self, input: ConversationUpdateInput, info: Info) -> ConversationType:
|
||||
"""Update conversation details"""
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
|
||||
@database_sync_to_async
|
||||
def update():
|
||||
conversation = Conversation.objects.get(pk=input.id.node_id)
|
||||
|
||||
if input.subject is not None:
|
||||
conversation.subject = input.subject
|
||||
if input.is_archived is not None:
|
||||
conversation.is_archived = input.is_archived
|
||||
if input.metadata is not None:
|
||||
conversation.metadata = json.loads(input.metadata)
|
||||
|
||||
conversation.save()
|
||||
return conversation
|
||||
|
||||
instance = await update()
|
||||
await pubsub.publish("conversation_updated", instance.id)
|
||||
|
||||
if input.is_archived:
|
||||
await EventPublisher.publish(
|
||||
event_type=EventTypeChoices.CONVERSATION_ARCHIVED,
|
||||
entity_type='Conversation',
|
||||
entity_id=str(instance.id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ConversationType, instance)
|
||||
|
||||
@strawberry.mutation(description="Send a message in a conversation")
|
||||
async def send_message(self, input: MessageInput, info: Info) -> MessageType:
|
||||
"""
|
||||
Send a new message in a conversation.
|
||||
Updates unread counts for other participants.
|
||||
"""
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
if not profile:
|
||||
raise ValueError("User must be authenticated")
|
||||
|
||||
# Parse attachments and metadata
|
||||
attachments = json.loads(input.attachments) if input.attachments else []
|
||||
metadata = json.loads(input.metadata) if input.metadata else {}
|
||||
|
||||
@database_sync_to_async
|
||||
def create():
|
||||
# Get sender content type inside sync context
|
||||
sender_content_type = ContentType.objects.get_for_model(type(profile))
|
||||
# Extract UUIDs from GlobalIDs
|
||||
conversation_uuid = input.conversation_id.node_id
|
||||
reply_to_uuid = input.reply_to_id.node_id if input.reply_to_id else None
|
||||
|
||||
# Create message
|
||||
message = Message.objects.create(
|
||||
conversation_id=conversation_uuid,
|
||||
sender_content_type=sender_content_type,
|
||||
sender_object_id=profile.id,
|
||||
body=input.body,
|
||||
reply_to_id=reply_to_uuid,
|
||||
attachments=attachments,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
# Update conversation last_message_at
|
||||
conversation = message.conversation
|
||||
conversation.last_message_at = message.created_at
|
||||
conversation.save(update_fields=['last_message_at', 'updated_at'])
|
||||
|
||||
# Increment unread count for all participants except sender
|
||||
participants = ConversationParticipant.objects.filter(
|
||||
conversation=conversation
|
||||
).exclude(
|
||||
participant_content_type=sender_content_type,
|
||||
participant_object_id=profile.id
|
||||
)
|
||||
|
||||
for participant in participants:
|
||||
participant.unread_count += 1
|
||||
participant.save(update_fields=['unread_count', 'updated_at'])
|
||||
|
||||
return message
|
||||
|
||||
instance = await create()
|
||||
await pubsub.publish("message_sent", {
|
||||
"message_id": instance.id,
|
||||
"conversation_id": str(input.conversation_id)
|
||||
})
|
||||
|
||||
# Publish event
|
||||
await EventPublisher.publish(
|
||||
event_type=EventTypeChoices.MESSAGE_SENT,
|
||||
entity_type='Message',
|
||||
entity_id=str(instance.id),
|
||||
triggered_by=profile,
|
||||
metadata={
|
||||
'conversation_id': str(input.conversation_id),
|
||||
'body_preview': instance.body[:100]
|
||||
}
|
||||
)
|
||||
|
||||
return cast(MessageType, instance)
|
||||
|
||||
@strawberry.mutation(description="Mark conversation as read")
|
||||
async def mark_conversation_as_read(self, input: MarkAsReadInput, info: Info) -> ConversationType:
|
||||
"""
|
||||
Mark all messages in a conversation as read for the current user.
|
||||
Resets unread count to 0.
|
||||
"""
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
if not profile:
|
||||
raise ValueError("User must be authenticated")
|
||||
|
||||
content_type = ContentType.objects.get_for_model(type(profile))
|
||||
|
||||
@database_sync_to_async
|
||||
def mark_read():
|
||||
conversation = Conversation.objects.get(pk=input.conversation_id.node_id)
|
||||
|
||||
# Update participant record
|
||||
participant = ConversationParticipant.objects.get(
|
||||
conversation=conversation,
|
||||
participant_content_type=content_type,
|
||||
participant_object_id=profile.id
|
||||
)
|
||||
participant.last_read_at = timezone.now()
|
||||
participant.unread_count = 0
|
||||
participant.save(update_fields=['last_read_at', 'unread_count', 'updated_at'])
|
||||
|
||||
# Create read receipts for unread messages
|
||||
messages = Message.objects.filter(
|
||||
conversation=conversation,
|
||||
created_at__gt=participant.last_read_at or timezone.now()
|
||||
).exclude(
|
||||
sender_content_type=content_type,
|
||||
sender_object_id=profile.id
|
||||
)
|
||||
|
||||
for message in messages:
|
||||
MessageReadReceipt.objects.get_or_create(
|
||||
message=message,
|
||||
reader_content_type=content_type,
|
||||
reader_object_id=profile.id
|
||||
)
|
||||
|
||||
return conversation
|
||||
|
||||
instance = await mark_read()
|
||||
await pubsub.publish("conversation_read", {
|
||||
"conversation_id": instance.id,
|
||||
"participant_id": str(profile.id)
|
||||
})
|
||||
|
||||
return cast(ConversationType, instance)
|
||||
|
||||
@strawberry.mutation(description="Archive or unarchive a conversation")
|
||||
async def archive_conversation(self, input: ArchiveConversationInput, info: Info) -> ConversationType:
|
||||
"""Archive or unarchive a conversation for the current user"""
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
if not profile:
|
||||
raise ValueError("User must be authenticated")
|
||||
|
||||
content_type = ContentType.objects.get_for_model(type(profile))
|
||||
|
||||
@database_sync_to_async
|
||||
def archive():
|
||||
conversation = Conversation.objects.get(pk=input.conversation_id.node_id)
|
||||
|
||||
participant = ConversationParticipant.objects.get(
|
||||
conversation=conversation,
|
||||
participant_content_type=content_type,
|
||||
participant_object_id=profile.id
|
||||
)
|
||||
participant.is_archived = input.is_archived
|
||||
participant.save(update_fields=['is_archived', 'updated_at'])
|
||||
|
||||
return conversation
|
||||
|
||||
instance = await archive()
|
||||
return cast(ConversationType, instance)
|
||||
|
||||
@strawberry.mutation(description="Mute or unmute a conversation")
|
||||
async def mute_conversation(self, input: MuteConversationInput, info: Info) -> ConversationType:
|
||||
"""Mute or unmute notifications for a conversation"""
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
if not profile:
|
||||
raise ValueError("User must be authenticated")
|
||||
|
||||
content_type = ContentType.objects.get_for_model(type(profile))
|
||||
|
||||
@database_sync_to_async
|
||||
def mute():
|
||||
conversation = Conversation.objects.get(pk=input.conversation_id.node_id)
|
||||
|
||||
participant = ConversationParticipant.objects.get(
|
||||
conversation=conversation,
|
||||
participant_content_type=content_type,
|
||||
participant_object_id=profile.id
|
||||
)
|
||||
participant.is_muted = input.is_muted
|
||||
participant.save(update_fields=['is_muted', 'updated_at'])
|
||||
|
||||
return conversation
|
||||
|
||||
instance = await mute()
|
||||
return cast(ConversationType, instance)
|
||||
|
||||
@strawberry.mutation(description="Add a participant to a conversation")
|
||||
async def add_participant(self, input: AddParticipantInput, info: Info) -> ConversationParticipantType:
|
||||
"""Add a new participant to an existing conversation"""
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
|
||||
@database_sync_to_async
|
||||
def add():
|
||||
conversation = Conversation.objects.get(pk=input.conversation_id.node_id)
|
||||
|
||||
# Get participant profile
|
||||
participant_uuid = input.participant_id.node_id
|
||||
try:
|
||||
participant = TeamProfile.objects.get(pk=participant_uuid)
|
||||
content_type = ContentType.objects.get_for_model(TeamProfile)
|
||||
except TeamProfile.DoesNotExist:
|
||||
participant = CustomerProfile.objects.get(pk=participant_uuid)
|
||||
content_type = ContentType.objects.get_for_model(CustomerProfile)
|
||||
|
||||
# Create participant record
|
||||
conv_participant, created = ConversationParticipant.objects.get_or_create(
|
||||
conversation=conversation,
|
||||
participant_content_type=content_type,
|
||||
participant_object_id=participant.id
|
||||
)
|
||||
|
||||
return conv_participant
|
||||
|
||||
instance = await add()
|
||||
await pubsub.publish("participant_added", {
|
||||
"conversation_id": str(input.conversation_id),
|
||||
"participant_id": str(input.participant_id)
|
||||
})
|
||||
|
||||
# Publish event
|
||||
await EventPublisher.publish(
|
||||
event_type=EventTypeChoices.CONVERSATION_PARTICIPANT_ADDED,
|
||||
entity_type='Conversation',
|
||||
entity_id=str(input.conversation_id),
|
||||
triggered_by=profile,
|
||||
metadata={'participant_id': str(input.participant_id)}
|
||||
)
|
||||
|
||||
return cast(ConversationParticipantType, instance)
|
||||
|
||||
@strawberry.mutation(description="Remove a participant from a conversation")
|
||||
async def remove_participant(self, input: RemoveParticipantInput, info: Info) -> strawberry.ID:
|
||||
"""Remove a participant from a conversation"""
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
|
||||
@database_sync_to_async
|
||||
def remove():
|
||||
conversation = Conversation.objects.get(pk=input.conversation_id.node_id)
|
||||
|
||||
# Get participant profile
|
||||
participant_uuid = input.participant_id.node_id
|
||||
try:
|
||||
participant = TeamProfile.objects.get(pk=participant_uuid)
|
||||
content_type = ContentType.objects.get_for_model(TeamProfile)
|
||||
except TeamProfile.DoesNotExist:
|
||||
participant = CustomerProfile.objects.get(pk=participant_uuid)
|
||||
content_type = ContentType.objects.get_for_model(CustomerProfile)
|
||||
|
||||
# Delete participant record
|
||||
ConversationParticipant.objects.filter(
|
||||
conversation=conversation,
|
||||
participant_content_type=content_type,
|
||||
participant_object_id=participant.id
|
||||
).delete()
|
||||
|
||||
return conversation.id
|
||||
|
||||
conversation_id = await remove()
|
||||
await pubsub.publish("participant_removed", {
|
||||
"conversation_id": str(input.conversation_id),
|
||||
"participant_id": str(input.participant_id)
|
||||
})
|
||||
|
||||
# Publish event
|
||||
await EventPublisher.publish(
|
||||
event_type=EventTypeChoices.CONVERSATION_PARTICIPANT_REMOVED,
|
||||
entity_type='Conversation',
|
||||
entity_id=str(input.conversation_id),
|
||||
triggered_by=profile,
|
||||
metadata={'participant_id': str(input.participant_id)}
|
||||
)
|
||||
|
||||
return input.conversation_id
|
||||
|
||||
@strawberry.mutation(description="Delete a conversation")
|
||||
async def delete_conversation(self, id: GlobalID, info: Info) -> strawberry.ID:
|
||||
"""Delete a conversation (only by creator or admin)"""
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
if not profile:
|
||||
raise ValueError("User must be authenticated")
|
||||
|
||||
content_type = ContentType.objects.get_for_model(type(profile))
|
||||
|
||||
@database_sync_to_async
|
||||
def delete():
|
||||
conversation = Conversation.objects.get(pk=id.node_id)
|
||||
|
||||
# Check if user is the creator OR the admin profile
|
||||
is_creator = (conversation.created_by_content_type == content_type and
|
||||
conversation.created_by_object_id == profile.id)
|
||||
|
||||
if not (is_creator or is_admin_profile(profile)):
|
||||
raise PermissionError("Only the conversation creator or admin can delete it")
|
||||
|
||||
conversation.delete()
|
||||
return id
|
||||
|
||||
conversation_id = await delete()
|
||||
await pubsub.publish("conversation_deleted", str(conversation_id))
|
||||
|
||||
return conversation_id
|
||||
|
||||
@strawberry.mutation(description="Delete a message")
|
||||
async def delete_message(self, id: GlobalID, info: Info) -> strawberry.ID:
|
||||
"""Delete a message (only by sender or admin)"""
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
if not profile:
|
||||
raise ValueError("User must be authenticated")
|
||||
|
||||
content_type = ContentType.objects.get_for_model(type(profile))
|
||||
|
||||
@database_sync_to_async
|
||||
def delete():
|
||||
message = Message.objects.get(pk=id.node_id)
|
||||
|
||||
# Check if user is the sender OR the admin profile
|
||||
is_sender = (message.sender_object_id == profile.id and
|
||||
message.sender_content_type == content_type)
|
||||
|
||||
if not (is_sender or is_admin_profile(profile)):
|
||||
raise PermissionError("You can only delete your own messages or be an admin")
|
||||
|
||||
conversation_id = message.conversation_id
|
||||
message.delete()
|
||||
return conversation_id
|
||||
|
||||
conversation_id = await delete()
|
||||
await pubsub.publish("message_deleted", {
|
||||
"message_id": str(id),
|
||||
"conversation_id": str(conversation_id)
|
||||
})
|
||||
|
||||
# Publish event
|
||||
await EventPublisher.publish(
|
||||
event_type=EventTypeChoices.MESSAGE_DELETED,
|
||||
entity_type='Message',
|
||||
entity_id=str(id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return id
|
||||
131
core/graphql/mutations/profile.py
Normal file
131
core/graphql/mutations/profile.py
Normal file
@ -0,0 +1,131 @@
|
||||
from typing import cast
|
||||
import strawberry
|
||||
from strawberry.types import Info
|
||||
from channels.db import database_sync_to_async
|
||||
from core.graphql.pubsub import pubsub
|
||||
from core.graphql.inputs.profile import (
|
||||
CustomerProfileInput,
|
||||
CustomerProfileUpdateInput,
|
||||
TeamProfileInput,
|
||||
TeamProfileUpdateInput,
|
||||
)
|
||||
from core.graphql.types.profile import CustomerProfileType, TeamProfileType
|
||||
from core.models.profile import CustomerProfile, TeamProfile
|
||||
from core.graphql.utils import create_object, update_object, delete_object
|
||||
from core.services.events import (
|
||||
publish_team_profile_created, publish_team_profile_updated, publish_team_profile_deleted,
|
||||
publish_team_profile_role_changed,
|
||||
publish_customer_profile_created, publish_customer_profile_updated, publish_customer_profile_deleted,
|
||||
publish_customer_profile_access_granted, publish_customer_profile_access_revoked,
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Mutation:
|
||||
@strawberry.mutation(description="Create a new customer profile")
|
||||
async def create_customer_profile(
|
||||
self, input: CustomerProfileInput, info: Info
|
||||
) -> CustomerProfileType:
|
||||
m2m_data = {"customers": input.customer_ids} if input.customer_ids else None
|
||||
instance = await create_object(input, CustomerProfile, m2m_data)
|
||||
await pubsub.publish(f"customer_profile_created", instance.id)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_customer_profile_created(str(instance.id), triggered_by=profile)
|
||||
|
||||
return cast(CustomerProfileType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing customer profile")
|
||||
async def update_customer_profile(
|
||||
self, input: CustomerProfileUpdateInput, info: Info
|
||||
) -> CustomerProfileType:
|
||||
# Get old profile to detect customer access changes
|
||||
old_profile = await database_sync_to_async(CustomerProfile.objects.get)(pk=input.id.node_id)
|
||||
old_customer_ids = set(str(cid) for cid in await database_sync_to_async(list)(
|
||||
old_profile.customers.values_list('id', flat=True)
|
||||
))
|
||||
|
||||
m2m_data = {"customers": input.customer_ids} if input.customer_ids else None
|
||||
instance = await update_object(input, CustomerProfile, m2m_data)
|
||||
await pubsub.publish(f"customer_profile_updated", instance.id)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_customer_profile_updated(str(instance.id), triggered_by=profile)
|
||||
|
||||
# Detect customer access changes
|
||||
if input.customer_ids is not None:
|
||||
new_customer_ids = set(str(cid) for cid in input.customer_ids)
|
||||
|
||||
# Newly granted access
|
||||
for customer_id in new_customer_ids - old_customer_ids:
|
||||
await publish_customer_profile_access_granted(
|
||||
str(instance.id), customer_id, triggered_by=profile
|
||||
)
|
||||
|
||||
# Revoked access
|
||||
for customer_id in old_customer_ids - new_customer_ids:
|
||||
await publish_customer_profile_access_revoked(
|
||||
str(instance.id), customer_id, triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(CustomerProfileType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete an existing customer profile")
|
||||
async def delete_customer_profile(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
instance = await delete_object(id, CustomerProfile)
|
||||
if not instance:
|
||||
raise ValueError(f"CustomerProfile with ID {id} does not exist")
|
||||
await pubsub.publish(f"customer_profile_deleted", id)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_customer_profile_deleted(str(id), triggered_by=profile)
|
||||
|
||||
return id
|
||||
|
||||
@strawberry.mutation(description="Create a new team profile")
|
||||
async def create_team_profile(self, input: TeamProfileInput, info: Info) -> TeamProfileType:
|
||||
instance = await create_object(input, TeamProfile)
|
||||
await pubsub.publish(f"team_profile_created", instance.id)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_team_profile_created(str(instance.id), triggered_by=profile)
|
||||
|
||||
return cast(TeamProfileType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing team profile")
|
||||
async def update_team_profile(self, input: TeamProfileUpdateInput, info: Info) -> TeamProfileType:
|
||||
# Get old profile to detect role changes
|
||||
old_profile = await database_sync_to_async(TeamProfile.objects.get)(pk=input.id.node_id)
|
||||
old_role = old_profile.role
|
||||
|
||||
instance = await update_object(input, TeamProfile)
|
||||
await pubsub.publish(f"team_profile_updated", instance.id)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_team_profile_updated(str(instance.id), triggered_by=profile)
|
||||
|
||||
# Check for role change
|
||||
if input.role is not None and input.role != old_role:
|
||||
await publish_team_profile_role_changed(
|
||||
str(instance.id), old_role, input.role, triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(TeamProfileType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete an existing team profile")
|
||||
async def delete_team_profile(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
instance = await delete_object(id, TeamProfile)
|
||||
if not instance:
|
||||
raise ValueError(f"TeamProfile with ID {id} does not exist")
|
||||
await pubsub.publish(f"team_profile_deleted", id)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_team_profile_deleted(str(id), triggered_by=profile)
|
||||
|
||||
return id
|
||||
188
core/graphql/mutations/project.py
Normal file
188
core/graphql/mutations/project.py
Normal file
@ -0,0 +1,188 @@
|
||||
from typing import cast
|
||||
import strawberry
|
||||
from strawberry.types import Info
|
||||
from asgiref.sync import sync_to_async
|
||||
from channels.db import database_sync_to_async
|
||||
from core.graphql.pubsub import pubsub
|
||||
from core.graphql.inputs.project import ProjectInput, ProjectUpdateInput
|
||||
from core.graphql.types.project import ProjectType
|
||||
from core.models.account import AccountAddress
|
||||
from core.models.profile import TeamProfile
|
||||
from core.models.project import Project
|
||||
from core.models.enums import ServiceChoices
|
||||
from core.graphql.utils import create_object, update_object, delete_object
|
||||
from core.services.events import (
|
||||
publish_project_created, publish_project_status_changed,
|
||||
publish_project_completed, publish_project_dispatched,
|
||||
publish_project_deleted,
|
||||
)
|
||||
|
||||
|
||||
# Helper to get admin profile
|
||||
async def _get_admin_profile():
|
||||
return await sync_to_async(
|
||||
lambda: TeamProfile.objects.filter(role='ADMIN').first()
|
||||
)()
|
||||
|
||||
|
||||
# Helper to check if admin is in team member IDs (handles GlobalID objects)
|
||||
def _admin_in_team_members(admin_id, team_member_ids):
|
||||
if not team_member_ids or not admin_id:
|
||||
return False
|
||||
# team_member_ids may be GlobalID objects with .node_id attribute
|
||||
member_uuids = []
|
||||
for mid in team_member_ids:
|
||||
if hasattr(mid, 'node_id'):
|
||||
member_uuids.append(str(mid.node_id))
|
||||
else:
|
||||
member_uuids.append(str(mid))
|
||||
return str(admin_id) in member_uuids
|
||||
|
||||
|
||||
# Helper to get old team member IDs from instance
|
||||
async def _get_old_team_member_ids(instance):
|
||||
return await sync_to_async(
|
||||
lambda: set(str(m.id) for m in instance.team_members.all())
|
||||
)()
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Mutation:
|
||||
@strawberry.mutation(description="Create a new project")
|
||||
async def create_project(self, input: ProjectInput, info: Info) -> ProjectType:
|
||||
# Exclude m2m id fields from model constructor
|
||||
payload = {k: v for k, v in input.__dict__.items() if k not in {"team_member_ids"}}
|
||||
m2m_data = {"team_members": input.team_member_ids}
|
||||
instance = await create_object(payload, Project, m2m_data)
|
||||
await pubsub.publish("project_created", instance.id)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_project_created(
|
||||
project_id=str(instance.id),
|
||||
triggered_by=profile,
|
||||
metadata={
|
||||
'status': instance.status,
|
||||
'customer_id': str(instance.customer_id),
|
||||
'name': instance.name,
|
||||
'date': str(instance.date)
|
||||
}
|
||||
)
|
||||
|
||||
# Check if project was dispatched (admin in team members)
|
||||
admin = await _get_admin_profile()
|
||||
if admin and _admin_in_team_members(admin.id, input.team_member_ids):
|
||||
# Build metadata
|
||||
account_address_id = None
|
||||
account_name = None
|
||||
if instance.account_address_id:
|
||||
account_address_id = str(instance.account_address_id)
|
||||
account_address = await sync_to_async(
|
||||
lambda: AccountAddress.objects.select_related('account').get(id=instance.account_address_id)
|
||||
)()
|
||||
account_name = account_address.account.name if account_address.account else None
|
||||
|
||||
await publish_project_dispatched(
|
||||
project_id=str(instance.id),
|
||||
triggered_by=profile,
|
||||
metadata={
|
||||
'project_id': str(instance.id),
|
||||
'project_name': instance.name,
|
||||
'customer_id': str(instance.customer_id),
|
||||
'account_address_id': account_address_id,
|
||||
'account_name': account_name,
|
||||
'date': str(instance.date),
|
||||
'status': instance.status
|
||||
}
|
||||
)
|
||||
|
||||
return cast(ProjectType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing project")
|
||||
async def update_project(self, input: ProjectUpdateInput, info: Info) -> ProjectType:
|
||||
# Get old project to check for status changes
|
||||
old_project = await database_sync_to_async(Project.objects.get)(pk=input.id.node_id)
|
||||
old_status = old_project.status
|
||||
|
||||
# Get old team member IDs before update (for dispatched detection)
|
||||
old_team_member_ids = await _get_old_team_member_ids(old_project)
|
||||
|
||||
# Keep id and non-m2m fields; drop m2m *_ids from the update payload
|
||||
payload = {k: v for k, v in input.__dict__.items() if k not in {"team_member_ids"}}
|
||||
m2m_data = {"team_members": getattr(input, "team_member_ids", None)}
|
||||
instance = await update_object(payload, Project, m2m_data)
|
||||
await pubsub.publish("project_updated", instance.id)
|
||||
|
||||
# Publish events for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
|
||||
# Check if status changed
|
||||
if hasattr(input, 'status') and input.status and input.status != old_status:
|
||||
await publish_project_status_changed(
|
||||
project_id=str(instance.id),
|
||||
old_status=old_status,
|
||||
new_status=instance.status,
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
# Check if project was completed
|
||||
if instance.status == ServiceChoices.COMPLETED:
|
||||
await publish_project_completed(
|
||||
project_id=str(instance.id),
|
||||
triggered_by=profile,
|
||||
metadata={
|
||||
'customer_id': str(instance.customer_id),
|
||||
'name': instance.name,
|
||||
'date': str(instance.date)
|
||||
}
|
||||
)
|
||||
|
||||
# Check if admin was newly added (dispatched)
|
||||
if input.team_member_ids is not None:
|
||||
admin = await _get_admin_profile()
|
||||
if admin:
|
||||
admin_was_in_old = str(admin.id) in old_team_member_ids
|
||||
admin_in_new = _admin_in_team_members(admin.id, input.team_member_ids)
|
||||
|
||||
if not admin_was_in_old and admin_in_new:
|
||||
# Admin was just added - project was dispatched
|
||||
account_address_id = None
|
||||
account_name = None
|
||||
if instance.account_address_id:
|
||||
account_address_id = str(instance.account_address_id)
|
||||
account_address = await sync_to_async(
|
||||
lambda: AccountAddress.objects.select_related('account').get(id=instance.account_address_id)
|
||||
)()
|
||||
account_name = account_address.account.name if account_address.account else None
|
||||
|
||||
await publish_project_dispatched(
|
||||
project_id=str(instance.id),
|
||||
triggered_by=profile,
|
||||
metadata={
|
||||
'project_id': str(instance.id),
|
||||
'project_name': instance.name,
|
||||
'customer_id': str(instance.customer_id),
|
||||
'account_address_id': account_address_id,
|
||||
'account_name': account_name,
|
||||
'date': str(instance.date),
|
||||
'status': instance.status
|
||||
}
|
||||
)
|
||||
|
||||
return cast(ProjectType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete an existing project")
|
||||
async def delete_project(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
instance = await delete_object(id, Project)
|
||||
if not instance:
|
||||
raise ValueError(f"Project with ID {id} does not exist")
|
||||
await pubsub.publish("project_deleted", id)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_project_deleted(
|
||||
project_id=str(id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return id
|
||||
114
core/graphql/mutations/project_punchlist.py
Normal file
114
core/graphql/mutations/project_punchlist.py
Normal file
@ -0,0 +1,114 @@
|
||||
from typing import cast
|
||||
import strawberry
|
||||
from strawberry.types import Info
|
||||
from channels.db import database_sync_to_async
|
||||
from core.graphql.pubsub import pubsub
|
||||
from core.graphql.inputs.project_punchlist import (
|
||||
ProjectPunchlistInput,
|
||||
ProjectPunchlistUpdateInput,
|
||||
)
|
||||
from core.graphql.types.project_punchlist import ProjectPunchlistType
|
||||
from core.models.project_punchlist import ProjectPunchlist
|
||||
from core.graphql.utils import create_object, update_object, delete_object
|
||||
from core.services.events import (
|
||||
publish_project_punchlist_created,
|
||||
publish_project_punchlist_updated,
|
||||
publish_project_punchlist_deleted,
|
||||
publish_punchlist_status_changed,
|
||||
publish_punchlist_priority_changed,
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Mutation:
|
||||
@strawberry.mutation(description="Create a new project punchlist")
|
||||
async def create_project_punchlist(
|
||||
self, input: ProjectPunchlistInput, info: Info
|
||||
) -> ProjectPunchlistType:
|
||||
instance = await create_object(input, ProjectPunchlist)
|
||||
await pubsub.publish(f"project_punchlist_created", instance.id)
|
||||
|
||||
# Get profile from request context
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
|
||||
# Publish event
|
||||
await publish_project_punchlist_created(
|
||||
punchlist_id=str(instance.id),
|
||||
project_id=str(instance.project_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ProjectPunchlistType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing project punchlist")
|
||||
async def update_project_punchlist(
|
||||
self, input: ProjectPunchlistUpdateInput, info: Info
|
||||
) -> ProjectPunchlistType:
|
||||
# Get old instance for comparison
|
||||
old_instance = await database_sync_to_async(
|
||||
ProjectPunchlist.objects.get
|
||||
)(id=input.id)
|
||||
|
||||
# Update the instance
|
||||
instance = await update_object(input, ProjectPunchlist)
|
||||
await pubsub.publish(f"project_punchlist_updated", instance.id)
|
||||
|
||||
# Get profile from request context
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
|
||||
# Publish update event
|
||||
await publish_project_punchlist_updated(
|
||||
punchlist_id=str(instance.id),
|
||||
project_id=str(instance.project_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
# Check for status changes (if the model has status field)
|
||||
if hasattr(old_instance, 'status') and hasattr(instance, 'status'):
|
||||
if old_instance.status != instance.status:
|
||||
await publish_punchlist_status_changed(
|
||||
punchlist_id=str(instance.id),
|
||||
entity_type='ProjectPunchlist',
|
||||
old_status=old_instance.status,
|
||||
new_status=instance.status,
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
# Check for priority changes (if the model has priority field)
|
||||
if hasattr(old_instance, 'priority') and hasattr(instance, 'priority'):
|
||||
if old_instance.priority != instance.priority:
|
||||
await publish_punchlist_priority_changed(
|
||||
punchlist_id=str(instance.id),
|
||||
entity_type='ProjectPunchlist',
|
||||
old_priority=old_instance.priority,
|
||||
new_priority=instance.priority,
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ProjectPunchlistType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete an existing project punchlist")
|
||||
async def delete_project_punchlist(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
# Get instance before deletion to access project_id
|
||||
instance = await database_sync_to_async(
|
||||
ProjectPunchlist.objects.get
|
||||
)(id=id)
|
||||
|
||||
# Delete the instance
|
||||
deleted_instance = await delete_object(id, ProjectPunchlist)
|
||||
if not deleted_instance:
|
||||
raise ValueError(f"ProjectPunchlist with ID {id} does not exist")
|
||||
|
||||
await pubsub.publish(f"project_punchlist_deleted", id)
|
||||
|
||||
# Get profile from request context
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
|
||||
# Publish delete event
|
||||
await publish_project_punchlist_deleted(
|
||||
punchlist_id=str(id),
|
||||
project_id=str(instance.project_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return id
|
||||
218
core/graphql/mutations/project_scope.py
Normal file
218
core/graphql/mutations/project_scope.py
Normal file
@ -0,0 +1,218 @@
|
||||
from typing import cast
|
||||
import strawberry
|
||||
from strawberry.types import Info
|
||||
from asgiref.sync import sync_to_async
|
||||
from core.graphql.inputs.project_scope import (
|
||||
ProjectScopeInput,
|
||||
ProjectScopeUpdateInput,
|
||||
ProjectScopeCategoryInput,
|
||||
ProjectScopeCategoryUpdateInput,
|
||||
ProjectScopeTaskInput,
|
||||
ProjectScopeTaskUpdateInput,
|
||||
CreateProjectScopeFromTemplateInput,
|
||||
)
|
||||
from core.graphql.types.project_scope import (
|
||||
ProjectScopeType,
|
||||
ProjectScopeCategoryType,
|
||||
ProjectScopeTaskType,
|
||||
)
|
||||
from core.graphql.utils import create_object, update_object, delete_object
|
||||
from core.models.account import Account, AccountAddress
|
||||
from core.models.project import Project
|
||||
from core.models.project_scope import ProjectScope, ProjectScopeCategory, ProjectScopeTask
|
||||
from core.models.project_scope_template import ProjectScopeTemplate
|
||||
from core.services.events import (
|
||||
publish_project_scope_created, publish_project_scope_updated, publish_project_scope_deleted,
|
||||
publish_project_scope_category_created, publish_project_scope_category_updated, publish_project_scope_category_deleted,
|
||||
publish_project_scope_task_created, publish_project_scope_task_updated, publish_project_scope_task_deleted,
|
||||
publish_project_scope_template_instantiated,
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Mutation:
|
||||
# ProjectScope CRUD
|
||||
@strawberry.mutation(description="Create a new ProjectScope")
|
||||
async def create_project_scope(self, input: ProjectScopeInput, info: Info) -> ProjectScopeType:
|
||||
instance = await create_object(input, ProjectScope)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_project_scope_created(
|
||||
scope_id=str(instance.id),
|
||||
project_id=str(instance.project_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ProjectScopeType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing ProjectScope")
|
||||
async def update_project_scope(self, input: ProjectScopeUpdateInput, info: Info) -> ProjectScopeType:
|
||||
instance = await update_object(input, ProjectScope)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_project_scope_updated(
|
||||
scope_id=str(instance.id),
|
||||
project_id=str(instance.project_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ProjectScopeType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete a ProjectScope")
|
||||
async def delete_project_scope(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
instance = await delete_object(id, ProjectScope)
|
||||
if not instance:
|
||||
raise ValueError(f"ProjectScope with ID {id} does not exist")
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_project_scope_deleted(
|
||||
scope_id=str(id),
|
||||
project_id=str(instance.project_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return id
|
||||
|
||||
@strawberry.mutation(description="Create a ProjectScopeCategory")
|
||||
async def create_project_scope_category(self, input: ProjectScopeCategoryInput, info: Info) -> ProjectScopeCategoryType:
|
||||
instance = await create_object(input, ProjectScopeCategory)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_project_scope_category_created(
|
||||
category_id=str(instance.id),
|
||||
scope_id=str(instance.scope_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ProjectScopeCategoryType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update a ProjectScopeCategory")
|
||||
async def update_project_scope_category(self, input: ProjectScopeCategoryUpdateInput, info: Info) -> ProjectScopeCategoryType:
|
||||
instance = await update_object(input, ProjectScopeCategory)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_project_scope_category_updated(
|
||||
category_id=str(instance.id),
|
||||
scope_id=str(instance.scope_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ProjectScopeCategoryType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete a ProjectScopeCategory")
|
||||
async def delete_project_scope_category(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
instance = await delete_object(id, ProjectScopeCategory)
|
||||
if not instance:
|
||||
raise ValueError(f"ProjectScopeCategory with ID {id} does not exist")
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_project_scope_category_deleted(
|
||||
category_id=str(id),
|
||||
scope_id=str(instance.scope_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return id
|
||||
|
||||
@strawberry.mutation(description="Create a ProjectScopeTask")
|
||||
async def create_project_scope_task(self, input: ProjectScopeTaskInput, info: Info) -> ProjectScopeTaskType:
|
||||
instance = await create_object(input, ProjectScopeTask)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_project_scope_task_created(
|
||||
task_id=str(instance.id),
|
||||
category_id=str(instance.category_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ProjectScopeTaskType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update a ProjectScopeTask")
|
||||
async def update_project_scope_task(self, input: ProjectScopeTaskUpdateInput, info: Info) -> ProjectScopeTaskType:
|
||||
instance = await update_object(input, ProjectScopeTask)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_project_scope_task_updated(
|
||||
task_id=str(instance.id),
|
||||
category_id=str(instance.category_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ProjectScopeTaskType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete a ProjectScopeTask")
|
||||
async def delete_project_scope_task(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
instance = await delete_object(id, ProjectScopeTask)
|
||||
if not instance:
|
||||
raise ValueError(f"ProjectScopeTask with ID {id} does not exist")
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_project_scope_task_deleted(
|
||||
task_id=str(id),
|
||||
category_id=str(instance.category_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return id
|
||||
|
||||
@strawberry.mutation(description="Instantiate a ProjectScope (with Categories and Tasks) from a ProjectScopeTemplate")
|
||||
async def create_project_scope_from_template(self, input: CreateProjectScopeFromTemplateInput, info: Info) -> ProjectScopeType:
|
||||
def _do_create_sync() -> tuple[ProjectScope, str, str]:
|
||||
# Load required objects synchronously (ORM-safe in this thread)
|
||||
project = (
|
||||
Project.objects
|
||||
.select_related("account_address__account")
|
||||
.get(pk=input.project_id.node_id)
|
||||
)
|
||||
tpl = ProjectScopeTemplate.objects.get(pk=input.template_id.node_id)
|
||||
|
||||
# Defaults derived from project (if project has an account_address)
|
||||
account = None
|
||||
account_address = None
|
||||
if project.account_address_id:
|
||||
account_address = project.account_address
|
||||
account = account_address.account
|
||||
|
||||
if input.account_address_id:
|
||||
account_address = AccountAddress.objects.get(pk=input.account_address_id.node_id)
|
||||
account = account_address.account
|
||||
|
||||
if input.account_id:
|
||||
account = Account.objects.get(pk=input.account_id.node_id)
|
||||
|
||||
# Instantiate the ProjectScope object from the template
|
||||
instance = tpl.instantiate(
|
||||
project=project,
|
||||
account=account,
|
||||
account_address=account_address,
|
||||
name=input.name,
|
||||
description=input.description,
|
||||
is_active=input.is_active if input.is_active is not None else True,
|
||||
)
|
||||
|
||||
# Persist the relation on the project
|
||||
project.scope = instance
|
||||
project.save(update_fields=["scope"])
|
||||
return instance, str(tpl.id), str(project.id)
|
||||
|
||||
instance, template_id, project_id = await sync_to_async(_do_create_sync, thread_sensitive=True)()
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_project_scope_template_instantiated(
|
||||
scope_id=str(instance.id),
|
||||
template_id=template_id,
|
||||
project_id=project_id,
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ProjectScopeType, instance)
|
||||
141
core/graphql/mutations/project_scope_template.py
Normal file
141
core/graphql/mutations/project_scope_template.py
Normal file
@ -0,0 +1,141 @@
|
||||
from typing import cast
|
||||
import strawberry
|
||||
from strawberry.types import Info
|
||||
from strawberry.scalars import JSON
|
||||
from asgiref.sync import sync_to_async
|
||||
from core.graphql.inputs.project_scope_template import (
|
||||
ProjectScopeTemplateInput,
|
||||
ProjectScopeTemplateUpdateInput,
|
||||
ProjectAreaTemplateInput,
|
||||
ProjectAreaTemplateUpdateInput,
|
||||
ProjectTaskTemplateInput,
|
||||
ProjectTaskTemplateUpdateInput,
|
||||
)
|
||||
from core.graphql.pubsub import pubsub
|
||||
from core.graphql.types.project_scope_template import (
|
||||
ProjectScopeTemplateType,
|
||||
ProjectAreaTemplateType,
|
||||
ProjectTaskTemplateType,
|
||||
)
|
||||
from core.graphql.utils import create_object, update_object, delete_object
|
||||
from core.models.project_scope_template import (
|
||||
ProjectScopeTemplate,
|
||||
ProjectAreaTemplate,
|
||||
ProjectTaskTemplate,
|
||||
)
|
||||
from core.services.scope_builder import build_project_scope_template
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Mutation:
|
||||
@strawberry.mutation(description="Create a new Project Scope Template")
|
||||
async def create_project_scope_template(self, input: ProjectScopeTemplateInput, info: Info) -> ProjectScopeTemplateType:
|
||||
instance = await create_object(input, ProjectScopeTemplate)
|
||||
await pubsub.publish("project_scope_template_created", instance.id)
|
||||
# Note: No event publisher exists for project scope template CRUD operations yet
|
||||
return cast(ProjectScopeTemplateType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing Project Scope Template")
|
||||
async def update_project_scope_template(self, input: ProjectScopeTemplateUpdateInput, info: Info) -> ProjectScopeTemplateType:
|
||||
instance = await update_object(input, ProjectScopeTemplate)
|
||||
await pubsub.publish("project_scope_template_updated", instance.id)
|
||||
# Note: No event publisher exists for project scope template CRUD operations yet
|
||||
return cast(ProjectScopeTemplateType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete a Project Scope Template")
|
||||
async def delete_project_scope_template(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
instance = await delete_object(id, ProjectScopeTemplate)
|
||||
if not instance:
|
||||
raise ValueError(f"ProjectScopeTemplate with ID {id} does not exist")
|
||||
await pubsub.publish("project_scope_template_deleted", id)
|
||||
# Note: No event publisher exists for project scope template CRUD operations yet
|
||||
return id
|
||||
|
||||
@strawberry.mutation(description="Create a Project Area Template")
|
||||
async def create_project_area_template(self, input: ProjectAreaTemplateInput, info: Info) -> ProjectAreaTemplateType:
|
||||
instance = await create_object(input, ProjectAreaTemplate)
|
||||
await pubsub.publish("project_area_template_created", instance.id)
|
||||
# Note: No event publisher exists for project area template CRUD operations yet
|
||||
return cast(ProjectAreaTemplateType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update a Project Area Template")
|
||||
async def update_project_area_template(self, input: ProjectAreaTemplateUpdateInput, info: Info) -> ProjectAreaTemplateType:
|
||||
instance = await update_object(input, ProjectAreaTemplate)
|
||||
await pubsub.publish("project_area_template_updated", instance.id)
|
||||
# Note: No event publisher exists for project area template CRUD operations yet
|
||||
return cast(ProjectAreaTemplateType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete a Project Area Template")
|
||||
async def delete_project_area_template(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
instance = await delete_object(id, ProjectAreaTemplate)
|
||||
if not instance:
|
||||
raise ValueError(f"ProjectAreaTemplate with ID {id} does not exist")
|
||||
await pubsub.publish("project_area_template_deleted", id)
|
||||
# Note: No event publisher exists for project area template CRUD operations yet
|
||||
return id
|
||||
|
||||
@strawberry.mutation(description="Create a Project Task Template")
|
||||
async def create_project_task_template(self, input: ProjectTaskTemplateInput, info: Info) -> ProjectTaskTemplateType:
|
||||
instance = await create_object(input, ProjectTaskTemplate)
|
||||
await pubsub.publish("project_task_template_created", instance.id)
|
||||
# Note: No event publisher exists for project task template CRUD operations yet
|
||||
return cast(ProjectTaskTemplateType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update a Project Task Template")
|
||||
async def update_project_task_template(self, input: ProjectTaskTemplateUpdateInput, info: Info) -> ProjectTaskTemplateType:
|
||||
instance = await update_object(input, ProjectTaskTemplate)
|
||||
await pubsub.publish("project_task_template_updated", instance.id)
|
||||
# Note: No event publisher exists for project task template CRUD operations yet
|
||||
return cast(ProjectTaskTemplateType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete a Project Task Template")
|
||||
async def delete_project_task_template(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
instance = await delete_object(id, ProjectTaskTemplate)
|
||||
if not instance:
|
||||
raise ValueError(f"ProjectTaskTemplate with ID {id} does not exist")
|
||||
await pubsub.publish("project_task_template_deleted", id)
|
||||
# Note: No event publisher exists for project task template CRUD operations yet
|
||||
return id
|
||||
|
||||
@strawberry.mutation(description="Create a ProjectScopeTemplate (and nested Categories/Tasks) from a JSON payload")
|
||||
async def create_project_scope_template_from_json(
|
||||
self,
|
||||
payload: JSON,
|
||||
replace: bool = False,
|
||||
info: Info | None = None,
|
||||
) -> ProjectScopeTemplateType:
|
||||
"""
|
||||
Accepts a JSON object matching the builder payload shape:
|
||||
{
|
||||
"name": str, "description": str, "is_active": bool,
|
||||
"categories": [
|
||||
{"name": str, "order": int, "tasks": [
|
||||
{"description": str, "checklist_description": str, "order": int, "estimated_minutes": int}
|
||||
]}
|
||||
]
|
||||
}
|
||||
If replace=True and a template with the same name exists, it will be deleted first.
|
||||
"""
|
||||
|
||||
def _do_create_sync():
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("payload must be a JSON object")
|
||||
|
||||
name = payload.get("name")
|
||||
if not name or not isinstance(name, str):
|
||||
raise ValueError("payload.name is required and must be a string")
|
||||
|
||||
if replace:
|
||||
ProjectScopeTemplate.objects.filter(name=name).delete()
|
||||
elif ProjectScopeTemplate.objects.filter(name=name).exists():
|
||||
raise ValueError(
|
||||
f"A ProjectScopeTemplate named '{name}' already exists (use replace=true to overwrite)"
|
||||
)
|
||||
|
||||
tpl = build_project_scope_template(payload)
|
||||
return tpl
|
||||
|
||||
instance = await sync_to_async(_do_create_sync)()
|
||||
await pubsub.publish("project_scope_template_created", instance.id)
|
||||
# Note: No event publisher exists for project scope template CRUD operations yet
|
||||
return cast(ProjectScopeTemplateType, instance)
|
||||
70
core/graphql/mutations/report.py
Normal file
70
core/graphql/mutations/report.py
Normal file
@ -0,0 +1,70 @@
|
||||
from typing import cast
|
||||
import strawberry
|
||||
from strawberry.types import Info
|
||||
from core.graphql.pubsub import pubsub
|
||||
from core.graphql.inputs.report import ReportInput, ReportUpdateInput
|
||||
from core.graphql.types.report import ReportType
|
||||
from core.models.report import Report
|
||||
from core.graphql.utils import create_object, update_object, delete_object
|
||||
from core.services.events import publish_report_submitted, publish_report_updated, publish_report_deleted
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Mutation:
|
||||
@strawberry.mutation(description="Create a new report")
|
||||
async def create_report(self, input: ReportInput, info: Info) -> ReportType:
|
||||
# Exclude m2m id fields from model constructor
|
||||
payload = {k: v for k, v in input.__dict__.items() if k not in {"service_ids", "project_ids"}}
|
||||
m2m_data = {
|
||||
"services": input.service_ids,
|
||||
"projects": input.project_ids,
|
||||
}
|
||||
instance = await create_object(payload, Report, m2m_data)
|
||||
await pubsub.publish("report_created", instance.id)
|
||||
|
||||
# Publish event for notifications (report creation = report submission)
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_report_submitted(
|
||||
report_id=str(instance.id),
|
||||
triggered_by=profile,
|
||||
metadata={'team_member_id': str(instance.team_member_id)}
|
||||
)
|
||||
|
||||
return cast(ReportType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing report")
|
||||
async def update_report(self, input: ReportUpdateInput, info: Info) -> ReportType:
|
||||
# Keep id and non-m2m fields; drop m2m *_ids from the update payload
|
||||
payload = {k: v for k, v in input.__dict__.items() if k not in {"service_ids", "project_ids"}}
|
||||
m2m_data = {
|
||||
"services": getattr(input, "service_ids", None),
|
||||
"projects": getattr(input, "project_ids", None),
|
||||
}
|
||||
instance = await update_object(payload, Report, m2m_data)
|
||||
await pubsub.publish("report_updated", instance.id)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_report_updated(
|
||||
report_id=str(instance.id),
|
||||
triggered_by=profile,
|
||||
metadata={'team_member_id': str(instance.team_member_id)}
|
||||
)
|
||||
|
||||
return cast(ReportType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete an existing report")
|
||||
async def delete_report(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
instance = await delete_object(id, Report)
|
||||
if not instance:
|
||||
raise ValueError(f"Report with ID {id} does not exist")
|
||||
await pubsub.publish("report_deleted", id)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_report_deleted(
|
||||
report_id=str(id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return id
|
||||
58
core/graphql/mutations/revenue.py
Normal file
58
core/graphql/mutations/revenue.py
Normal file
@ -0,0 +1,58 @@
|
||||
from typing import cast
|
||||
import strawberry
|
||||
from strawberry.types import Info
|
||||
from core.graphql.pubsub import pubsub
|
||||
from core.graphql.inputs.revenue import RevenueInput, RevenueUpdateInput
|
||||
from core.graphql.types.revenue import RevenueType
|
||||
from core.models.revenue import Revenue
|
||||
from core.graphql.utils import create_object, update_object, delete_object
|
||||
from core.services.events import (
|
||||
publish_revenue_rate_created, publish_revenue_rate_updated, publish_revenue_rate_deleted,
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Mutation:
|
||||
@strawberry.mutation(description="Create a new revenue rate")
|
||||
async def create_revenue(self, input: RevenueInput, info: Info) -> RevenueType:
|
||||
instance = await create_object(input, Revenue)
|
||||
await pubsub.publish("revenue_created", instance.id)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_revenue_rate_created(
|
||||
rate_id=str(instance.id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(RevenueType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing revenue rate")
|
||||
async def update_revenue(self, input: RevenueUpdateInput, info: Info) -> RevenueType:
|
||||
instance = await update_object(input, Revenue)
|
||||
await pubsub.publish("revenue_updated", instance.id)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_revenue_rate_updated(
|
||||
rate_id=str(instance.id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(RevenueType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete an existing revenue rate")
|
||||
async def delete_revenue(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
instance = await delete_object(id, Revenue)
|
||||
if not instance:
|
||||
raise ValueError(f"Revenue with ID {id} does not exist")
|
||||
await pubsub.publish("revenue_deleted", id)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_revenue_rate_deleted(
|
||||
rate_id=str(id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return id
|
||||
108
core/graphql/mutations/schedule.py
Normal file
108
core/graphql/mutations/schedule.py
Normal file
@ -0,0 +1,108 @@
|
||||
from typing import cast
|
||||
import strawberry
|
||||
from strawberry.types import Info
|
||||
from channels.db import database_sync_to_async
|
||||
from core.graphql.pubsub import pubsub
|
||||
from core.graphql.inputs.schedule import ScheduleInput, ScheduleUpdateInput
|
||||
from core.graphql.types.schedule import ScheduleType
|
||||
from core.models.schedule import Schedule
|
||||
from core.models.account import AccountAddress
|
||||
from core.graphql.utils import create_object, update_object, delete_object
|
||||
from core.services.events import (
|
||||
publish_schedule_created, publish_schedule_updated, publish_schedule_deleted,
|
||||
publish_schedule_frequency_changed,
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Mutation:
|
||||
@strawberry.mutation(description="Create a new service schedule")
|
||||
async def create_schedule(self, input: ScheduleInput, info: Info) -> ScheduleType:
|
||||
instance = await create_object(input, Schedule)
|
||||
await pubsub.publish("schedule_created", instance.id)
|
||||
|
||||
# Get profile from request context
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
|
||||
# Publish event with account_id in metadata
|
||||
account_address = await database_sync_to_async(
|
||||
lambda: AccountAddress.objects.select_related('account').get(id=instance.account_address_id)
|
||||
)()
|
||||
account_id = str(account_address.account_id) if account_address.account_id else None
|
||||
await publish_schedule_created(
|
||||
schedule_id=str(instance.id),
|
||||
triggered_by=profile,
|
||||
metadata={'account_id': account_id}
|
||||
)
|
||||
|
||||
return cast(ScheduleType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing service schedule")
|
||||
async def update_schedule(self, input: ScheduleUpdateInput, info: Info) -> ScheduleType:
|
||||
# Get the old schedule to check for frequency changes
|
||||
old_schedule = await database_sync_to_async(Schedule.objects.get)(pk=input.id.node_id)
|
||||
|
||||
# Store old frequency state
|
||||
old_frequency = {
|
||||
'monday': old_schedule.monday_service,
|
||||
'tuesday': old_schedule.tuesday_service,
|
||||
'wednesday': old_schedule.wednesday_service,
|
||||
'thursday': old_schedule.thursday_service,
|
||||
'friday': old_schedule.friday_service,
|
||||
'saturday': old_schedule.saturday_service,
|
||||
'sunday': old_schedule.sunday_service,
|
||||
'weekend': old_schedule.weekend_service,
|
||||
}
|
||||
|
||||
instance = await update_object(input, Schedule)
|
||||
await pubsub.publish("schedule_updated", instance.id)
|
||||
|
||||
# Get profile from request context
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
|
||||
# Publish schedule updated event
|
||||
await publish_schedule_updated(
|
||||
schedule_id=str(instance.id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
# Check if frequency changed
|
||||
new_frequency = {
|
||||
'monday': instance.monday_service,
|
||||
'tuesday': instance.tuesday_service,
|
||||
'wednesday': instance.wednesday_service,
|
||||
'thursday': instance.thursday_service,
|
||||
'friday': instance.friday_service,
|
||||
'saturday': instance.saturday_service,
|
||||
'sunday': instance.sunday_service,
|
||||
'weekend': instance.weekend_service,
|
||||
}
|
||||
|
||||
if old_frequency != new_frequency:
|
||||
# Publish frequency changed event
|
||||
await publish_schedule_frequency_changed(
|
||||
schedule_id=str(instance.id),
|
||||
old_frequency=str(old_frequency),
|
||||
new_frequency=str(new_frequency),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ScheduleType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete an existing service schedule")
|
||||
async def delete_schedule(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
instance = await delete_object(id, Schedule)
|
||||
if not instance:
|
||||
raise ValueError(f"Schedule with ID {id} does not exist")
|
||||
await pubsub.publish("schedule_deleted", id)
|
||||
|
||||
# Get profile from request context
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
|
||||
# Publish schedule deleted event
|
||||
await publish_schedule_deleted(
|
||||
schedule_id=str(id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return id
|
||||
294
core/graphql/mutations/scope.py
Normal file
294
core/graphql/mutations/scope.py
Normal file
@ -0,0 +1,294 @@
|
||||
from typing import cast
|
||||
import strawberry
|
||||
from strawberry.types import Info
|
||||
from channels.db import database_sync_to_async
|
||||
from core.graphql.pubsub import pubsub
|
||||
from core.graphql.inputs.scope import (
|
||||
ScopeInput, ScopeUpdateInput,
|
||||
AreaInput, AreaUpdateInput,
|
||||
TaskInput, TaskUpdateInput,
|
||||
TaskCompletionInput, TaskCompletionUpdateInput,
|
||||
)
|
||||
from core.graphql.types.scope import (
|
||||
ScopeType,
|
||||
AreaType,
|
||||
TaskType,
|
||||
TaskCompletionType,
|
||||
)
|
||||
from core.models.scope import Scope, Area, Task, TaskCompletion
|
||||
from core.models.session import ServiceSession
|
||||
from core.graphql.utils import create_object, update_object, delete_object, _decode_global_id
|
||||
from core.services.events import (
|
||||
publish_scope_created, publish_scope_updated, publish_scope_deleted,
|
||||
publish_area_created, publish_area_updated, publish_area_deleted,
|
||||
publish_task_created, publish_task_updated, publish_task_deleted,
|
||||
publish_task_completion_recorded,
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Mutation:
|
||||
@strawberry.mutation(description="Create a new scope")
|
||||
async def create_scope(self, input: ScopeInput, info: Info) -> ScopeType:
|
||||
instance = await create_object(input, Scope)
|
||||
await pubsub.publish("scope_created", instance.id)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_scope_created(
|
||||
scope_id=str(instance.id),
|
||||
account_id=str(instance.account_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ScopeType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing scope")
|
||||
async def update_scope(self, input: ScopeUpdateInput, info: Info) -> ScopeType:
|
||||
instance = await update_object(input, Scope)
|
||||
await pubsub.publish("scope_updated", instance.id)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_scope_updated(
|
||||
scope_id=str(instance.id),
|
||||
account_id=str(instance.account_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ScopeType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete an existing scope")
|
||||
async def delete_scope(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
def _delete_scope_sync(scope_id):
|
||||
"""
|
||||
Smart delete: soft-delete if sessions reference this scope, hard-delete otherwise.
|
||||
Returns (account_id, action) where action is 'deleted' or 'deactivated'.
|
||||
"""
|
||||
pk = _decode_global_id(scope_id)
|
||||
try:
|
||||
scope = Scope.objects.get(pk=pk)
|
||||
except Scope.DoesNotExist:
|
||||
return None, None
|
||||
|
||||
account_id = scope.account_id
|
||||
|
||||
# Check if any service sessions reference this scope
|
||||
has_sessions = ServiceSession.objects.filter(scope_id=pk).exists()
|
||||
|
||||
if has_sessions:
|
||||
# Soft delete - deactivate the scope to preserve historical data
|
||||
scope.is_active = False
|
||||
scope.save(update_fields=['is_active'])
|
||||
else:
|
||||
# Hard delete - no sessions reference this scope
|
||||
scope.delete()
|
||||
|
||||
return account_id, 'deactivated' if has_sessions else 'deleted'
|
||||
|
||||
account_id, action = await database_sync_to_async(_delete_scope_sync)(id)
|
||||
|
||||
if account_id is None:
|
||||
raise ValueError(f"Scope with ID {id} does not exist")
|
||||
|
||||
await pubsub.publish("scope_deleted", id)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_scope_deleted(
|
||||
scope_id=str(id),
|
||||
account_id=str(account_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return id
|
||||
|
||||
@strawberry.mutation(description="Create a new area")
|
||||
async def create_area(self, input: AreaInput, info: Info) -> AreaType:
|
||||
instance = await create_object(input, Area)
|
||||
await pubsub.publish("area_created", instance.id)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_area_created(
|
||||
area_id=str(instance.id),
|
||||
scope_id=str(instance.scope_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(AreaType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing area")
|
||||
async def update_area(self, input: AreaUpdateInput, info: Info) -> AreaType:
|
||||
instance = await update_object(input, Area)
|
||||
await pubsub.publish("area_updated", instance.id)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_area_updated(
|
||||
area_id=str(instance.id),
|
||||
scope_id=str(instance.scope_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(AreaType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete an existing area")
|
||||
async def delete_area(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
def _delete_area_sync(area_id):
|
||||
"""
|
||||
Delete an area if no task completions reference its tasks.
|
||||
Returns scope_id on success, raises ValueError if completions exist.
|
||||
"""
|
||||
pk = _decode_global_id(area_id)
|
||||
try:
|
||||
area = Area.objects.get(pk=pk)
|
||||
except Area.DoesNotExist:
|
||||
return None
|
||||
|
||||
# Check if any task completions reference tasks in this area
|
||||
has_completions = TaskCompletion.objects.filter(task__area_id=pk).exists()
|
||||
|
||||
if has_completions:
|
||||
raise ValueError(
|
||||
"Cannot delete area: it contains tasks with recorded completions. "
|
||||
"Deactivate the scope instead to preserve historical data."
|
||||
)
|
||||
|
||||
scope_id = area.scope_id
|
||||
area.delete()
|
||||
return scope_id
|
||||
|
||||
scope_id = await database_sync_to_async(_delete_area_sync)(id)
|
||||
|
||||
if scope_id is None:
|
||||
raise ValueError(f"Area with ID {id} does not exist")
|
||||
|
||||
await pubsub.publish("area_deleted", id)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_area_deleted(
|
||||
area_id=str(id),
|
||||
scope_id=str(scope_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return id
|
||||
|
||||
@strawberry.mutation(description="Create a new task")
|
||||
async def create_task(self, input: TaskInput, info: Info) -> TaskType:
|
||||
instance = await create_object(input, Task)
|
||||
await pubsub.publish("task_created", instance.id)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_task_created(
|
||||
task_id=str(instance.id),
|
||||
area_id=str(instance.area_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(TaskType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing task")
|
||||
async def update_task(self, input: TaskUpdateInput, info: Info) -> TaskType:
|
||||
instance = await update_object(input, Task)
|
||||
await pubsub.publish("task_updated", instance.id)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_task_updated(
|
||||
task_id=str(instance.id),
|
||||
area_id=str(instance.area_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(TaskType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete an existing task")
|
||||
async def delete_task(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
def _delete_task_sync(task_id):
|
||||
"""
|
||||
Delete a task if no task completions reference it.
|
||||
Returns area_id on success, raises ValueError if completions exist.
|
||||
"""
|
||||
pk = _decode_global_id(task_id)
|
||||
try:
|
||||
task = Task.objects.get(pk=pk)
|
||||
except Task.DoesNotExist:
|
||||
return None
|
||||
|
||||
# Check if any task completions reference this task
|
||||
has_completions = TaskCompletion.objects.filter(task_id=pk).exists()
|
||||
|
||||
if has_completions:
|
||||
raise ValueError(
|
||||
"Cannot delete task: it has recorded completions. "
|
||||
"Deactivate the scope instead to preserve historical data."
|
||||
)
|
||||
|
||||
area_id = task.area_id
|
||||
task.delete()
|
||||
return area_id
|
||||
|
||||
area_id = await database_sync_to_async(_delete_task_sync)(id)
|
||||
|
||||
if area_id is None:
|
||||
raise ValueError(f"Task with ID {id} does not exist")
|
||||
|
||||
await pubsub.publish("task_deleted", id)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_task_deleted(
|
||||
task_id=str(id),
|
||||
area_id=str(area_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return id
|
||||
|
||||
@strawberry.mutation(description="Create a new task completion")
|
||||
async def create_task_completion(self, input: TaskCompletionInput, info: Info) -> TaskCompletionType:
|
||||
instance = await create_object(input, TaskCompletion)
|
||||
await pubsub.publish("task_completion_created", instance.id)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_task_completion_recorded(
|
||||
completion_id=str(instance.id),
|
||||
task_id=str(instance.task_id),
|
||||
service_id=str(instance.service_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(TaskCompletionType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing task completion")
|
||||
async def update_task_completion(self, input: TaskCompletionUpdateInput, info: Info) -> TaskCompletionType:
|
||||
instance = await update_object(input, TaskCompletion)
|
||||
await pubsub.publish("task_completion_updated", instance.id)
|
||||
|
||||
# Publish event (reuse the same event for updates)
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_task_completion_recorded(
|
||||
completion_id=str(instance.id),
|
||||
task_id=str(instance.task_id),
|
||||
service_id=str(instance.service_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(TaskCompletionType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete an existing task completion")
|
||||
async def delete_task_completion(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
instance = await delete_object(id, TaskCompletion)
|
||||
if not instance:
|
||||
raise ValueError(f"TaskCompletion with ID {id} does not exist")
|
||||
await pubsub.publish("task_completion_deleted", id)
|
||||
|
||||
# Note: No event publication for deletion as there's no corresponding delete event
|
||||
# in the events.py file for task completions
|
||||
|
||||
return id
|
||||
246
core/graphql/mutations/scope_template.py
Normal file
246
core/graphql/mutations/scope_template.py
Normal file
@ -0,0 +1,246 @@
|
||||
from typing import cast
|
||||
import strawberry
|
||||
from strawberry.types import Info
|
||||
from asgiref.sync import sync_to_async
|
||||
from core.graphql.pubsub import pubsub
|
||||
from core.graphql.utils import create_object, update_object, delete_object, _decode_global_id
|
||||
from core.graphql.types.scope_template import (
|
||||
ScopeTemplateType,
|
||||
AreaTemplateType,
|
||||
TaskTemplateType,
|
||||
)
|
||||
from core.graphql.types.scope import ScopeType
|
||||
from core.graphql.inputs.scope_template import (
|
||||
ScopeTemplateInput, ScopeTemplateUpdateInput,
|
||||
AreaTemplateInput, AreaTemplateUpdateInput,
|
||||
TaskTemplateInput, TaskTemplateUpdateInput,
|
||||
CreateScopeFromTemplateInput,
|
||||
)
|
||||
from core.models.scope_template import ScopeTemplate, AreaTemplate, TaskTemplate
|
||||
from core.models.account import Account, AccountAddress
|
||||
from strawberry.scalars import JSON
|
||||
from core.services import build_scope_template
|
||||
from core.services.events import (
|
||||
publish_scope_template_created, publish_scope_template_updated, publish_scope_template_deleted,
|
||||
publish_scope_template_instantiated,
|
||||
publish_area_template_created, publish_area_template_updated, publish_area_template_deleted,
|
||||
publish_task_template_created, publish_task_template_updated, publish_task_template_deleted,
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Mutation:
|
||||
@strawberry.mutation(description="Create a new scope template")
|
||||
async def create_scope_template(self, input: ScopeTemplateInput, info: Info) -> ScopeTemplateType:
|
||||
instance = await create_object(input, ScopeTemplate)
|
||||
await pubsub.publish("scope_template_created", instance.id)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_scope_template_created(
|
||||
template_id=str(instance.id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ScopeTemplateType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing scope template")
|
||||
async def update_scope_template(self, input: ScopeTemplateUpdateInput, info: Info) -> ScopeTemplateType:
|
||||
instance = await update_object(input, ScopeTemplate)
|
||||
await pubsub.publish("scope_template_updated", instance.id)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_scope_template_updated(
|
||||
template_id=str(instance.id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ScopeTemplateType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete an existing scope template")
|
||||
async def delete_scope_template(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
instance = await delete_object(id, ScopeTemplate)
|
||||
if not instance:
|
||||
raise ValueError(f"ScopeTemplate with ID {id} does not exist")
|
||||
await pubsub.publish("scope_template_deleted", id)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_scope_template_deleted(
|
||||
template_id=str(id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return id
|
||||
|
||||
@strawberry.mutation(description="Create a new area template")
|
||||
async def create_area_template(self, input: AreaTemplateInput, info: Info) -> AreaTemplateType:
|
||||
instance = await create_object(input, AreaTemplate)
|
||||
await pubsub.publish("area_template_created", instance.id)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_area_template_created(
|
||||
template_id=str(instance.id),
|
||||
scope_template_id=str(instance.scope_template_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(AreaTemplateType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing area template")
|
||||
async def update_area_template(self, input: AreaTemplateUpdateInput, info: Info) -> AreaTemplateType:
|
||||
instance = await update_object(input, AreaTemplate)
|
||||
await pubsub.publish("area_template_updated", instance.id)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_area_template_updated(
|
||||
template_id=str(instance.id),
|
||||
scope_template_id=str(instance.scope_template_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(AreaTemplateType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete an existing area template")
|
||||
async def delete_area_template(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
instance = await delete_object(id, AreaTemplate)
|
||||
if not instance:
|
||||
raise ValueError(f"AreaTemplate with ID {id} does not exist")
|
||||
await pubsub.publish("area_template_deleted", id)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_area_template_deleted(
|
||||
template_id=str(id),
|
||||
scope_template_id=str(instance.scope_template_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return id
|
||||
|
||||
@strawberry.mutation(description="Create a new task template")
|
||||
async def create_task_template(self, input: TaskTemplateInput, info: Info) -> TaskTemplateType:
|
||||
instance = await create_object(input, TaskTemplate)
|
||||
await pubsub.publish("task_template_created", instance.id)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_task_template_created(
|
||||
template_id=str(instance.id),
|
||||
area_template_id=str(instance.area_template_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(TaskTemplateType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing task template")
|
||||
async def update_task_template(self, input: TaskTemplateUpdateInput, info: Info) -> TaskTemplateType:
|
||||
instance = await update_object(input, TaskTemplate)
|
||||
await pubsub.publish("task_template_updated", instance.id)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_task_template_updated(
|
||||
template_id=str(instance.id),
|
||||
area_template_id=str(instance.area_template_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(TaskTemplateType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete an existing task template")
|
||||
async def delete_task_template(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
instance = await delete_object(id, TaskTemplate)
|
||||
if not instance:
|
||||
raise ValueError(f"TaskTemplate with ID {id} does not exist")
|
||||
await pubsub.publish("task_template_deleted", id)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_task_template_deleted(
|
||||
template_id=str(id),
|
||||
area_template_id=str(instance.area_template_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return id
|
||||
|
||||
@strawberry.mutation(description="Instantiate a Scope (with Areas and Tasks) from a ScopeTemplate")
|
||||
async def create_scope_from_template(self, input: CreateScopeFromTemplateInput, info: Info) -> ScopeType:
|
||||
def _do_create_sync():
|
||||
template = ScopeTemplate.objects.get(pk=_decode_global_id(input.template_id))
|
||||
account = Account.objects.get(pk=_decode_global_id(input.account_id))
|
||||
account_address = None
|
||||
if input.account_address_id:
|
||||
account_address = AccountAddress.objects.get(
|
||||
pk=_decode_global_id(input.account_address_id), account=account
|
||||
)
|
||||
scope = template.instantiate(
|
||||
account=account,
|
||||
account_address=account_address,
|
||||
name=input.name,
|
||||
description=input.description,
|
||||
is_active=input.is_active if input.is_active is not None else True,
|
||||
)
|
||||
return scope, str(template.id), str(account.id)
|
||||
|
||||
# Run ORM-heavy work in a thread
|
||||
instance, template_id, account_id = await sync_to_async(_do_create_sync)()
|
||||
await pubsub.publish("scope_created_from_template", instance.id)
|
||||
|
||||
# Publish event
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_scope_template_instantiated(
|
||||
scope_id=str(instance.id),
|
||||
template_id=template_id,
|
||||
account_id=account_id,
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ScopeType, instance)
|
||||
|
||||
@strawberry.mutation(description="Create a ScopeTemplate (and nested Areas/Tasks) from a JSON payload")
|
||||
async def create_scope_template_from_json(
|
||||
self,
|
||||
payload: JSON,
|
||||
replace: bool = False,
|
||||
info: Info | None = None,
|
||||
) -> ScopeTemplateType:
|
||||
"""
|
||||
Accepts a JSON object matching the builder payload shape.
|
||||
If replace=True and a template with the same name exists, it will be deleted first.
|
||||
"""
|
||||
|
||||
def _do_create_sync():
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("payload must be a JSON object")
|
||||
|
||||
name = payload.get("name")
|
||||
if not name or not isinstance(name, str):
|
||||
raise ValueError("payload.name is required and must be a string")
|
||||
|
||||
if replace:
|
||||
ScopeTemplate.objects.filter(name=name).delete()
|
||||
elif ScopeTemplate.objects.filter(name=name).exists():
|
||||
raise ValueError(
|
||||
f"A ScopeTemplate named '{name}' already exists (use replace=true to overwrite)"
|
||||
)
|
||||
|
||||
tpl = build_scope_template(payload)
|
||||
return tpl
|
||||
|
||||
instance = await sync_to_async(_do_create_sync)()
|
||||
await pubsub.publish("scope_template_created", instance.id)
|
||||
|
||||
# Publish event
|
||||
if info:
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_scope_template_created(
|
||||
template_id=str(instance.id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ScopeTemplateType, instance)
|
||||
327
core/graphql/mutations/service.py
Normal file
327
core/graphql/mutations/service.py
Normal file
@ -0,0 +1,327 @@
|
||||
import calendar
|
||||
import datetime
|
||||
from typing import List, cast
|
||||
from uuid import UUID
|
||||
import strawberry
|
||||
from strawberry.types import Info
|
||||
from asgiref.sync import sync_to_async
|
||||
from channels.db import database_sync_to_async
|
||||
from django.db import transaction
|
||||
from core.graphql.inputs.service import ServiceInput, ServiceUpdateInput, ServiceGenerationInput
|
||||
from core.graphql.pubsub import pubsub
|
||||
from core.graphql.types.service import ServiceType
|
||||
from core.graphql.utils import create_object, update_object, delete_object, _is_holiday
|
||||
from core.models.account import AccountAddress
|
||||
from core.models.profile import TeamProfile
|
||||
from core.models.schedule import Schedule
|
||||
from core.models.service import Service
|
||||
from core.services.events import (
|
||||
publish_service_created, publish_service_deleted,
|
||||
publish_service_status_changed, publish_service_completed, publish_service_cancelled,
|
||||
publish_services_bulk_generated, publish_service_dispatched,
|
||||
)
|
||||
|
||||
|
||||
# Helper to get admin profile
|
||||
async def _get_admin_profile():
|
||||
return await sync_to_async(
|
||||
lambda: TeamProfile.objects.filter(role='ADMIN').first()
|
||||
)()
|
||||
|
||||
|
||||
# Helper to check if admin is in team member IDs (handles GlobalID objects)
|
||||
def _admin_in_team_members(admin_id, team_member_ids):
|
||||
if not team_member_ids or not admin_id:
|
||||
return False
|
||||
# team_member_ids may be GlobalID objects with .node_id attribute
|
||||
member_uuids = []
|
||||
for mid in team_member_ids:
|
||||
if hasattr(mid, 'node_id'):
|
||||
member_uuids.append(str(mid.node_id))
|
||||
else:
|
||||
member_uuids.append(str(mid))
|
||||
return str(admin_id) in member_uuids
|
||||
|
||||
|
||||
# Helper to get old team member IDs from instance
|
||||
async def _get_old_team_member_ids(instance):
|
||||
return await sync_to_async(
|
||||
lambda: set(str(m.id) for m in instance.team_members.all())
|
||||
)()
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Mutation:
|
||||
@strawberry.mutation(description="Create a new service visit")
|
||||
async def create_service(self, input: ServiceInput, info: Info) -> ServiceType:
|
||||
# Exclude m2m id fields from model constructor
|
||||
payload = {k: v for k, v in input.__dict__.items() if k not in {"team_member_ids"}}
|
||||
m2m_data = {"team_members": input.team_member_ids}
|
||||
instance = await create_object(payload, Service, m2m_data)
|
||||
await pubsub.publish("service_created", instance.id)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
# Get account_id safely via account_address
|
||||
account_id = None
|
||||
if instance.account_address_id:
|
||||
account_address = await sync_to_async(
|
||||
lambda: AccountAddress.objects.select_related('account').get(id=instance.account_address_id)
|
||||
)()
|
||||
account_id = str(account_address.account_id) if account_address.account_id else None
|
||||
|
||||
await publish_service_created(
|
||||
service_id=str(instance.id),
|
||||
triggered_by=profile,
|
||||
metadata={
|
||||
'account_id': account_id,
|
||||
'date': str(instance.date),
|
||||
'status': instance.status
|
||||
}
|
||||
)
|
||||
|
||||
# Check if service was dispatched (admin in team members)
|
||||
admin = await _get_admin_profile()
|
||||
if admin and _admin_in_team_members(admin.id, input.team_member_ids):
|
||||
# Build metadata
|
||||
account_name = None
|
||||
account_address_id = None
|
||||
if instance.account_address_id:
|
||||
account_address_id = str(instance.account_address_id)
|
||||
account_address = await sync_to_async(
|
||||
lambda: AccountAddress.objects.select_related('account').get(id=instance.account_address_id)
|
||||
)()
|
||||
account_name = account_address.account.name if account_address.account else None
|
||||
|
||||
await publish_service_dispatched(
|
||||
service_id=str(instance.id),
|
||||
triggered_by=profile,
|
||||
metadata={
|
||||
'service_id': str(instance.id),
|
||||
'account_address_id': account_address_id,
|
||||
'account_name': account_name,
|
||||
'date': str(instance.date),
|
||||
'status': instance.status
|
||||
}
|
||||
)
|
||||
|
||||
return cast(ServiceType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing service visit")
|
||||
async def update_service(self, input: ServiceUpdateInput, info: Info) -> ServiceType:
|
||||
# Get old service data for comparison
|
||||
old_service = await database_sync_to_async(Service.objects.get)(pk=input.id.node_id)
|
||||
old_status = old_service.status
|
||||
|
||||
# Get old team member IDs before update (for dispatched detection)
|
||||
old_team_member_ids = await _get_old_team_member_ids(old_service)
|
||||
|
||||
# Keep id and non-m2m fields; drop m2m *_ids from the update payload
|
||||
payload = {k: v for k, v in input.__dict__.items() if k not in {"team_member_ids"}}
|
||||
m2m_data = {"team_members": getattr(input, "team_member_ids", None)}
|
||||
instance = await update_object(payload, Service, m2m_data)
|
||||
await pubsub.publish("service_updated", instance.id)
|
||||
|
||||
# Publish events for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
|
||||
# Check for status change
|
||||
if hasattr(input, 'status') and input.status and input.status != old_status:
|
||||
# Get account name for notifications
|
||||
account_name = None
|
||||
if instance.account_address_id:
|
||||
account_address = await sync_to_async(
|
||||
lambda: AccountAddress.objects.select_related('account').get(id=instance.account_address_id)
|
||||
)()
|
||||
account_name = account_address.account.name if account_address.account else None
|
||||
|
||||
if instance.status == 'COMPLETED':
|
||||
await publish_service_completed(
|
||||
service_id=str(instance.id),
|
||||
triggered_by=profile,
|
||||
metadata={
|
||||
'date': str(instance.date),
|
||||
'account_name': account_name
|
||||
}
|
||||
)
|
||||
elif instance.status == 'CANCELLED':
|
||||
await publish_service_cancelled(
|
||||
service_id=str(instance.id),
|
||||
triggered_by=profile,
|
||||
metadata={
|
||||
'date': str(instance.date),
|
||||
'account_name': account_name
|
||||
}
|
||||
)
|
||||
else:
|
||||
await publish_service_status_changed(
|
||||
service_id=str(instance.id),
|
||||
old_status=old_status,
|
||||
new_status=instance.status,
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
# Check if admin was newly added (dispatched)
|
||||
if input.team_member_ids is not None:
|
||||
admin = await _get_admin_profile()
|
||||
if admin:
|
||||
admin_was_in_old = str(admin.id) in old_team_member_ids
|
||||
admin_in_new = _admin_in_team_members(admin.id, input.team_member_ids)
|
||||
|
||||
if not admin_was_in_old and admin_in_new:
|
||||
# Admin was just added - service was dispatched
|
||||
account_name = None
|
||||
account_address_id = None
|
||||
# Use explicit select_related to safely traverse FK chain
|
||||
if instance.account_address_id:
|
||||
account_address_id = str(instance.account_address_id)
|
||||
account_address = await sync_to_async(
|
||||
lambda: AccountAddress.objects.select_related('account').get(id=instance.account_address_id)
|
||||
)()
|
||||
account_name = account_address.account.name if account_address.account else None
|
||||
|
||||
await publish_service_dispatched(
|
||||
service_id=str(instance.id),
|
||||
triggered_by=profile,
|
||||
metadata={
|
||||
'service_id': str(instance.id),
|
||||
'account_address_id': account_address_id,
|
||||
'account_name': account_name,
|
||||
'date': str(instance.date),
|
||||
'status': instance.status
|
||||
}
|
||||
)
|
||||
|
||||
return cast(ServiceType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete an existing service visit")
|
||||
async def delete_service(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
instance = await delete_object(id, Service)
|
||||
if instance:
|
||||
await pubsub.publish("service_deleted", id)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_service_deleted(
|
||||
service_id=str(id),
|
||||
triggered_by=profile,
|
||||
metadata={'date': str(instance.date)}
|
||||
)
|
||||
|
||||
return id
|
||||
raise ValueError(f"Service with ID {id} does not exist")
|
||||
|
||||
@strawberry.mutation(description="Generate service visits for a given month (all-or-nothing)")
|
||||
async def generate_services_by_month(self, input: ServiceGenerationInput, info: Info) -> List[ServiceType]:
|
||||
if input.month < 1 or input.month > 12:
|
||||
raise ValueError("month must be in range 1..12")
|
||||
|
||||
year = input.year
|
||||
month_num = input.month
|
||||
|
||||
# Fetch the AccountAddress and Schedule by their IDs
|
||||
address = await AccountAddress.objects.aget(id=input.account_address_id.node_id)
|
||||
schedule = await Schedule.objects.aget(id=input.schedule_id.node_id)
|
||||
|
||||
# Optional but recommended: ensure the schedule belongs to this address
|
||||
if getattr(schedule, "account_address_id", None) != address.id:
|
||||
raise ValueError("Schedule does not belong to the provided account address")
|
||||
|
||||
cal = calendar.Calendar(firstweekday=calendar.MONDAY)
|
||||
days_in_month = [d for d in cal.itermonthdates(year, month_num) if d.month == month_num]
|
||||
|
||||
def is_within_schedule(dt: datetime.date) -> bool:
|
||||
if dt < schedule.start_date:
|
||||
return False
|
||||
if schedule.end_date and dt > schedule.end_date:
|
||||
return False
|
||||
return True
|
||||
|
||||
def day_flag(weekday: int) -> bool:
|
||||
return [
|
||||
schedule.monday_service,
|
||||
schedule.tuesday_service,
|
||||
schedule.wednesday_service,
|
||||
schedule.thursday_service,
|
||||
schedule.friday_service,
|
||||
schedule.saturday_service,
|
||||
schedule.sunday_service,
|
||||
][weekday]
|
||||
|
||||
targets: list[tuple[datetime.date, str | None]] = []
|
||||
for day in days_in_month:
|
||||
if not is_within_schedule(day):
|
||||
continue
|
||||
if _is_holiday(day):
|
||||
continue
|
||||
|
||||
wd = day.weekday() # Mon=0...Sun=6
|
||||
schedule_today = False
|
||||
note: str | None = None
|
||||
|
||||
if 0 <= wd <= 3:
|
||||
schedule_today = day_flag(wd)
|
||||
elif wd == 4:
|
||||
# Friday
|
||||
if schedule.weekend_service:
|
||||
schedule_today = True
|
||||
note = "Weekend service window (Fri–Sun)"
|
||||
else:
|
||||
schedule_today = day_flag(wd)
|
||||
else:
|
||||
# Sat-Sun
|
||||
if schedule.weekend_service:
|
||||
schedule_today = False
|
||||
else:
|
||||
schedule_today = day_flag(wd)
|
||||
|
||||
if schedule_today:
|
||||
targets.append((day, note))
|
||||
|
||||
if not targets:
|
||||
return cast(List[ServiceType], [])
|
||||
|
||||
# Run the transactional DB work in a sync thread
|
||||
def _create_services_sync(
|
||||
account_address_id: UUID,
|
||||
targets_local: list[tuple[datetime.date, str | None]]
|
||||
) -> List[Service]:
|
||||
with transaction.atomic():
|
||||
if Service.objects.filter(
|
||||
account_address_id=account_address_id,
|
||||
date__in=[svc_day for (svc_day, _) in targets_local]
|
||||
).exists():
|
||||
raise ValueError(
|
||||
"One or more services already exist for the selected month; nothing was created."
|
||||
)
|
||||
|
||||
to_create = [
|
||||
Service(
|
||||
account_address_id=account_address_id,
|
||||
date=svc_day,
|
||||
notes=(svc_note or None),
|
||||
)
|
||||
for (svc_day, svc_note) in targets_local
|
||||
]
|
||||
return Service.objects.bulk_create(to_create)
|
||||
|
||||
created_instances: List[Service] = await sync_to_async(
|
||||
_create_services_sync,
|
||||
thread_sensitive=True,
|
||||
)(address.id, targets)
|
||||
|
||||
for obj in created_instances:
|
||||
await pubsub.publish("service_created", obj.id)
|
||||
|
||||
# Publish bulk generation event for notifications
|
||||
if created_instances:
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
month_name = datetime.date(year, month_num, 1).strftime('%B %Y')
|
||||
await publish_services_bulk_generated(
|
||||
account_id=str(address.account_id),
|
||||
count=len(created_instances),
|
||||
month=month_name,
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(List[ServiceType], created_instances)
|
||||
467
core/graphql/mutations/session.py
Normal file
467
core/graphql/mutations/session.py
Normal file
@ -0,0 +1,467 @@
|
||||
from typing import List, cast
|
||||
from uuid import UUID
|
||||
import strawberry
|
||||
from channels.db import database_sync_to_async
|
||||
from django.core.exceptions import ValidationError
|
||||
from strawberry import Info
|
||||
from core.graphql.inputs.session import OpenServiceSessionInput, CloseServiceSessionInput, RevertServiceSessionInput, ProjectSessionStartInput, \
|
||||
ProjectSessionCloseInput, ProjectSessionRevertInput
|
||||
from core.graphql.pubsub import pubsub
|
||||
from core.graphql.types.session import ServiceSessionType, ProjectSessionType
|
||||
from core.models.profile import TeamProfile
|
||||
from core.models.scope import Task
|
||||
from core.models.session import ServiceSession, ProjectSession
|
||||
from core.models.project_scope import ProjectScopeTask
|
||||
from core.services.session_service import SessionService
|
||||
from core.services.events import (
|
||||
publish_service_session_opened, publish_service_session_closed, publish_service_session_reverted,
|
||||
publish_service_task_completed, publish_service_task_uncompleted,
|
||||
publish_project_session_opened, publish_project_session_closed, publish_project_session_reverted,
|
||||
publish_project_task_completed, publish_project_task_uncompleted,
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Mutation:
|
||||
@strawberry.mutation(description="Revert an active service session back to scheduled (deletes the active session)")
|
||||
async def revert_service_session(self, input: RevertServiceSessionInput, info: Info) -> bool:
|
||||
# Use Oathkeeper authentication
|
||||
profile = getattr(info.context.request, "profile", None)
|
||||
if not profile or not isinstance(profile, TeamProfile):
|
||||
raise ValidationError("Authentication required. Only team members can revert service sessions.")
|
||||
|
||||
service_pk = UUID(str(input.service_id))
|
||||
|
||||
svc = SessionService()
|
||||
result = await database_sync_to_async(svc.revert_session)(
|
||||
entity_type="service",
|
||||
entity_id=service_pk,
|
||||
actor=profile,
|
||||
)
|
||||
|
||||
# Publish event
|
||||
await publish_service_session_reverted(
|
||||
session_id=str(result.session_id),
|
||||
service_id=str(result.entity_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@strawberry.mutation(description="Open a service session for a scheduled service")
|
||||
async def open_service_session(self, input: OpenServiceSessionInput, info: Info) -> ServiceSessionType:
|
||||
# Use Oathkeeper authentication
|
||||
profile = getattr(info.context.request, "profile", None)
|
||||
if not profile or not isinstance(profile, TeamProfile):
|
||||
raise ValidationError("Authentication required. Only team members can open service sessions.")
|
||||
|
||||
service_pk = UUID(str(input.service_id))
|
||||
|
||||
svc = SessionService()
|
||||
result = await database_sync_to_async(svc.open_session)(
|
||||
entity_type="service",
|
||||
entity_id=service_pk,
|
||||
actor=profile,
|
||||
)
|
||||
|
||||
async def load_session() -> ServiceSession:
|
||||
return await database_sync_to_async(
|
||||
lambda: (
|
||||
ServiceSession.objects
|
||||
.select_related("service", "account", "account_address", "customer", "scope", "created_by",
|
||||
"closed_by")
|
||||
.prefetch_related("completed_tasks")
|
||||
.get(pk=result.session_id)
|
||||
)
|
||||
)()
|
||||
|
||||
session = await load_session()
|
||||
|
||||
# Publish event
|
||||
await publish_service_session_opened(
|
||||
session_id=str(result.session_id),
|
||||
service_id=str(result.entity_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ServiceSessionType, cast(object, session))
|
||||
|
||||
@strawberry.mutation(description="Close the active service session and record completed tasks")
|
||||
async def close_service_session(self, input: CloseServiceSessionInput, info: Info) -> ServiceSessionType:
|
||||
# Use Oathkeeper authentication
|
||||
profile = getattr(info.context.request, "profile", None)
|
||||
if not profile or not isinstance(profile, TeamProfile):
|
||||
raise ValidationError("Authentication required. Only team members can close service sessions.")
|
||||
|
||||
service_pk = UUID(str(input.service_id))
|
||||
task_pks: List[UUID] = [UUID(str(x)) for x in input.task_ids]
|
||||
|
||||
def load_tasks() -> List[Task]:
|
||||
qs = Task.objects.filter(pk__in=task_pks)
|
||||
return list(qs)
|
||||
|
||||
tasks = await database_sync_to_async(load_tasks)()
|
||||
if len(tasks) != len(task_pks):
|
||||
raise ValidationError("One or more task IDs are invalid.")
|
||||
|
||||
svc = SessionService()
|
||||
result = await database_sync_to_async(svc.close_session)(
|
||||
entity_type="service",
|
||||
entity_id=service_pk,
|
||||
actor=profile,
|
||||
tasks=tasks,
|
||||
)
|
||||
|
||||
async def load_session() -> ServiceSession:
|
||||
return await database_sync_to_async(
|
||||
lambda: (
|
||||
ServiceSession.objects
|
||||
.select_related("service", "account", "account_address", "customer", "scope", "created_by",
|
||||
"closed_by")
|
||||
.prefetch_related("completed_tasks")
|
||||
.get(pk=result.session_id)
|
||||
)
|
||||
)()
|
||||
|
||||
session = await load_session()
|
||||
|
||||
# Get account name and service date for notifications
|
||||
account_name = session.account.name if session.account else None
|
||||
service_date = str(session.service.date) if session.service else None
|
||||
|
||||
# Publish event
|
||||
await publish_service_session_closed(
|
||||
session_id=str(result.session_id),
|
||||
service_id=str(result.entity_id),
|
||||
triggered_by=profile,
|
||||
metadata={
|
||||
'account_name': account_name,
|
||||
'date': service_date
|
||||
}
|
||||
)
|
||||
|
||||
return cast(ServiceSessionType, cast(object, session))
|
||||
|
||||
@strawberry.mutation(description="Add a task completion to an active service session")
|
||||
async def add_task_completion(self, info: Info, service_id: strawberry.ID, task_id: strawberry.ID,
|
||||
notes: str | None = None) -> ServiceSessionType:
|
||||
# Use Oathkeeper authentication
|
||||
profile = getattr(info.context.request, "profile", None)
|
||||
if not profile or not isinstance(profile, TeamProfile):
|
||||
raise ValidationError("Authentication required. Only team members can add task completions.")
|
||||
|
||||
svc = SessionService()
|
||||
|
||||
task_pk = UUID(str(task_id))
|
||||
service_pk = UUID(str(service_id))
|
||||
|
||||
# Load task to get name for event
|
||||
task = await database_sync_to_async(Task.objects.get)(pk=task_pk)
|
||||
|
||||
session_id = await database_sync_to_async(svc.add_task_completion)(
|
||||
service_id=service_pk,
|
||||
task_id=task_pk,
|
||||
actor=profile,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
async def load_session() -> ServiceSession:
|
||||
return await database_sync_to_async(
|
||||
lambda: (
|
||||
ServiceSession.objects
|
||||
.select_related("service", "account", "account_address", "customer", "scope", "created_by",
|
||||
"closed_by")
|
||||
.prefetch_related("completed_tasks")
|
||||
.get(pk=session_id)
|
||||
)
|
||||
)()
|
||||
|
||||
session = await load_session()
|
||||
|
||||
# Publish event
|
||||
await publish_service_task_completed(
|
||||
task_id=str(task_pk),
|
||||
service_id=str(service_pk),
|
||||
task_name=task.checklist_description,
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ServiceSessionType, cast(object, session))
|
||||
|
||||
@strawberry.mutation(description="Remove a task completion from an active service session")
|
||||
async def remove_task_completion(self, info: Info, service_id: strawberry.ID,
|
||||
task_id: strawberry.ID) -> ServiceSessionType:
|
||||
# Use Oathkeeper authentication
|
||||
profile = getattr(info.context.request, "profile", None)
|
||||
if not profile or not isinstance(profile, TeamProfile):
|
||||
raise ValidationError("Authentication required. Only team members can remove task completions.")
|
||||
|
||||
svc = SessionService()
|
||||
|
||||
task_pk = UUID(str(task_id))
|
||||
service_pk = UUID(str(service_id))
|
||||
|
||||
# Load task to get name for event
|
||||
task = await database_sync_to_async(Task.objects.get)(pk=task_pk)
|
||||
|
||||
session_id = await database_sync_to_async(svc.remove_task_completion)(
|
||||
service_id=service_pk,
|
||||
task_id=task_pk,
|
||||
)
|
||||
|
||||
async def load_session() -> ServiceSession:
|
||||
return await database_sync_to_async(
|
||||
lambda: (
|
||||
ServiceSession.objects
|
||||
.select_related("service", "account", "account_address", "customer", "scope", "created_by",
|
||||
"closed_by")
|
||||
.prefetch_related("completed_tasks")
|
||||
.get(pk=session_id)
|
||||
)
|
||||
)()
|
||||
|
||||
session = await load_session()
|
||||
|
||||
# Publish event
|
||||
await publish_service_task_uncompleted(
|
||||
task_id=str(task_pk),
|
||||
service_id=str(service_pk),
|
||||
task_name=task.checklist_description,
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ServiceSessionType, cast(object, session))
|
||||
|
||||
@strawberry.mutation(description="Add a task completion to an active project session")
|
||||
async def add_project_task_completion(self, info: Info, project_id: strawberry.ID, task_id: strawberry.ID,
|
||||
notes: str | None = None) -> ProjectSessionType:
|
||||
# Use Oathkeeper authentication
|
||||
profile = getattr(info.context.request, "profile", None)
|
||||
if not profile or not isinstance(profile, TeamProfile):
|
||||
raise ValidationError("Authentication required. Only team members can add project task completions.")
|
||||
|
||||
svc = SessionService()
|
||||
|
||||
# Load task to get name and validate it exists
|
||||
task_pk = UUID(str(task_id))
|
||||
project_pk = UUID(str(project_id))
|
||||
|
||||
try:
|
||||
task = await database_sync_to_async(ProjectScopeTask.objects.get)(pk=task_pk)
|
||||
except ProjectScopeTask.DoesNotExist:
|
||||
raise ValidationError("Invalid project task ID.")
|
||||
|
||||
session_id = await database_sync_to_async(svc.add_project_task_completion)(
|
||||
project_id=project_pk,
|
||||
task_id=task_pk,
|
||||
actor=profile,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
async def load_session() -> ProjectSession:
|
||||
return await database_sync_to_async(
|
||||
lambda: (
|
||||
ProjectSession.objects
|
||||
.select_related("project", "account", "account_address", "customer", "scope", "created_by",
|
||||
"closed_by")
|
||||
.prefetch_related("completed_tasks")
|
||||
.get(pk=session_id)
|
||||
)
|
||||
)()
|
||||
|
||||
session = await load_session()
|
||||
|
||||
# Publish event
|
||||
await publish_project_task_completed(
|
||||
task_id=str(task_pk),
|
||||
project_id=str(project_pk),
|
||||
task_name=task.checklist_description,
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ProjectSessionType, cast(object, session))
|
||||
|
||||
@strawberry.mutation(description="Remove a task completion from an active project session")
|
||||
async def remove_project_task_completion(self, info: Info, project_id: strawberry.ID,
|
||||
task_id: strawberry.ID) -> ProjectSessionType:
|
||||
# Use Oathkeeper authentication
|
||||
profile = getattr(info.context.request, "profile", None)
|
||||
if not profile or not isinstance(profile, TeamProfile):
|
||||
raise ValidationError("Authentication required. Only team members can remove project task completions.")
|
||||
|
||||
svc = SessionService()
|
||||
|
||||
task_pk = UUID(str(task_id))
|
||||
project_pk = UUID(str(project_id))
|
||||
|
||||
# Load task to get name for event
|
||||
task = await database_sync_to_async(ProjectScopeTask.objects.get)(pk=task_pk)
|
||||
|
||||
session_id = await database_sync_to_async(svc.remove_project_task_completion)(
|
||||
project_id=project_pk,
|
||||
task_id=task_pk,
|
||||
)
|
||||
|
||||
async def load_session() -> ProjectSession:
|
||||
return await database_sync_to_async(
|
||||
lambda: (
|
||||
ProjectSession.objects
|
||||
.select_related("project", "account", "account_address", "customer", "scope", "created_by",
|
||||
"closed_by")
|
||||
.prefetch_related("completed_tasks")
|
||||
.get(pk=session_id)
|
||||
)
|
||||
)()
|
||||
|
||||
session = await load_session()
|
||||
|
||||
# Publish event
|
||||
await publish_project_task_uncompleted(
|
||||
task_id=str(task_pk),
|
||||
project_id=str(project_pk),
|
||||
task_name=task.checklist_description,
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ProjectSessionType, cast(object, session))
|
||||
|
||||
@strawberry.mutation(description="Start a new ProjectSession for a scheduled project")
|
||||
async def open_project_session(self, input: ProjectSessionStartInput, info: Info) -> ProjectSessionType:
|
||||
# Use Oathkeeper authentication
|
||||
profile = getattr(info.context.request, "profile", None)
|
||||
if not profile or not isinstance(profile, TeamProfile):
|
||||
raise ValidationError("Authentication required. Only team members can start project sessions.")
|
||||
|
||||
project_pk = UUID(str(input.project_id))
|
||||
|
||||
svc = SessionService()
|
||||
result = await database_sync_to_async(svc.open_session)(
|
||||
entity_type="project",
|
||||
entity_id=project_pk,
|
||||
actor=profile,
|
||||
)
|
||||
|
||||
async def load_session() -> ProjectSession:
|
||||
return await database_sync_to_async(
|
||||
lambda: (
|
||||
ProjectSession.objects
|
||||
.select_related("project", "account", "account_address", "customer", "scope", "created_by",
|
||||
"closed_by")
|
||||
.prefetch_related("completed_tasks")
|
||||
.get(pk=result.session_id)
|
||||
)
|
||||
)()
|
||||
|
||||
session = await load_session()
|
||||
|
||||
# Notify listeners that the project was updated (status change, etc.)
|
||||
await pubsub.publish("project_updated", result.entity_id)
|
||||
await pubsub.publish("project_session_created", result.session_id)
|
||||
|
||||
# Publish event
|
||||
await publish_project_session_opened(
|
||||
session_id=str(result.session_id),
|
||||
project_id=str(result.entity_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ProjectSessionType, cast(object, session))
|
||||
|
||||
@strawberry.mutation(description="Close the active ProjectSession")
|
||||
async def close_project_session(self, input: ProjectSessionCloseInput, info: Info) -> ProjectSessionType:
|
||||
# Use Oathkeeper authentication
|
||||
profile = getattr(info.context.request, "profile", None)
|
||||
if not profile or not isinstance(profile, TeamProfile):
|
||||
raise ValidationError("Authentication required. Only team members can close project sessions.")
|
||||
|
||||
project_pk = UUID(str(input.project_id))
|
||||
task_ids_raw = input.completed_task_ids or []
|
||||
task_pks: List[UUID] = [UUID(str(x)) for x in task_ids_raw]
|
||||
|
||||
# Load ProjectScopeTask objects for the provided IDs
|
||||
def load_tasks() -> List[ProjectScopeTask]:
|
||||
qs = ProjectScopeTask.objects.filter(pk__in=task_pks)
|
||||
return list(qs)
|
||||
|
||||
tasks: List[ProjectScopeTask] = []
|
||||
if task_pks:
|
||||
tasks = await database_sync_to_async(load_tasks)()
|
||||
if len(tasks) != len(task_pks):
|
||||
raise ValidationError("One or more project task IDs are invalid.")
|
||||
|
||||
# Let the service manage select_for_update inside its @transaction.atomic
|
||||
svc = SessionService()
|
||||
result = await database_sync_to_async(svc.close_session)(
|
||||
entity_type="project",
|
||||
entity_id=project_pk,
|
||||
actor=profile,
|
||||
tasks=tasks if task_pks else None,
|
||||
)
|
||||
|
||||
async def load_session() -> ProjectSession:
|
||||
return await database_sync_to_async(
|
||||
lambda: (
|
||||
ProjectSession.objects
|
||||
.select_related(
|
||||
"project", "account", "account_address", "customer", "scope", "created_by", "closed_by"
|
||||
)
|
||||
.prefetch_related("completed_tasks")
|
||||
.get(pk=result.session_id)
|
||||
)
|
||||
)()
|
||||
|
||||
session = await load_session()
|
||||
|
||||
await pubsub.publish("project_updated", result.entity_id)
|
||||
await pubsub.publish("project_session_closed", result.session_id)
|
||||
|
||||
# Get account/customer name and project date for notifications
|
||||
if session.account:
|
||||
account_name = session.account.name
|
||||
elif session.customer:
|
||||
account_name = session.customer.name
|
||||
else:
|
||||
account_name = None
|
||||
project_date = str(session.project.date) if session.project and session.project.date else None
|
||||
|
||||
# Publish event
|
||||
await publish_project_session_closed(
|
||||
session_id=str(result.session_id),
|
||||
project_id=str(result.entity_id),
|
||||
triggered_by=profile,
|
||||
metadata={
|
||||
'account_name': account_name,
|
||||
'date': project_date
|
||||
}
|
||||
)
|
||||
|
||||
return cast(ProjectSessionType, cast(object, session))
|
||||
|
||||
@strawberry.mutation(description="Revert the active ProjectSession back to scheduled (deletes the active session)")
|
||||
async def revert_project_session(self, input: ProjectSessionRevertInput, info: Info) -> bool:
|
||||
# Use Oathkeeper authentication
|
||||
profile = getattr(info.context.request, "profile", None)
|
||||
if not profile or not isinstance(profile, TeamProfile):
|
||||
raise ValidationError("Authentication required. Only team members can revert project sessions.")
|
||||
|
||||
project_pk = UUID(str(input.project_id))
|
||||
|
||||
svc = SessionService()
|
||||
result = await database_sync_to_async(svc.revert_session)(
|
||||
entity_type="project",
|
||||
entity_id=project_pk,
|
||||
actor=profile,
|
||||
)
|
||||
|
||||
# Publish project updated to reflect status change
|
||||
await pubsub.publish("project_updated", result.entity_id)
|
||||
|
||||
# Publish event
|
||||
await publish_project_session_reverted(
|
||||
session_id=str(result.session_id),
|
||||
project_id=str(result.entity_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
221
core/graphql/mutations/session_image.py
Normal file
221
core/graphql/mutations/session_image.py
Normal file
@ -0,0 +1,221 @@
|
||||
from typing import Optional, cast
|
||||
import io
|
||||
import strawberry
|
||||
from strawberry import Info
|
||||
from strawberry.file_uploads import Upload
|
||||
from strawberry.relay import GlobalID
|
||||
from channels.db import database_sync_to_async
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
from core.models.session import ServiceSession, ProjectSession
|
||||
from core.models.session_image import ServiceSessionImage, ProjectSessionImage
|
||||
from core.graphql.types.session_image import (
|
||||
ServiceSessionImageType,
|
||||
ProjectSessionImageType,
|
||||
)
|
||||
from core.graphql.inputs.session_image import (
|
||||
ServiceSessionImageUpdateInput,
|
||||
ProjectSessionImageUpdateInput,
|
||||
)
|
||||
from core.graphql.utils import update_object, delete_object, _decode_global_id
|
||||
from core.services.events import (
|
||||
publish_session_image_uploaded,
|
||||
publish_session_image_updated,
|
||||
publish_session_image_deleted,
|
||||
publish_session_media_internal_flagged,
|
||||
)
|
||||
|
||||
|
||||
def _verify_image_bytes(data: bytes) -> None:
|
||||
"""
|
||||
Verify the uploaded bytes are a valid image payload using Pillow.
|
||||
Uses a safe import for UnidentifiedImageError for broader compatibility.
|
||||
"""
|
||||
from PIL import Image as PilImage
|
||||
try:
|
||||
from PIL import UnidentifiedImageError as _UIE # type: ignore
|
||||
except (ImportError, AttributeError):
|
||||
_UIE = None
|
||||
|
||||
invalid_img_exc = (_UIE, OSError, ValueError) if _UIE else (OSError, ValueError)
|
||||
|
||||
try:
|
||||
PilImage.open(io.BytesIO(data)).verify()
|
||||
except invalid_img_exc:
|
||||
raise ValidationError("Uploaded file is not a valid image.")
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Mutation:
|
||||
@strawberry.mutation(description="Upload an image to a ServiceSession")
|
||||
async def upload_service_session_image(
|
||||
self,
|
||||
info: Info,
|
||||
session_id: GlobalID,
|
||||
file: Upload,
|
||||
title: Optional[str] = None,
|
||||
notes: Optional[str] = None,
|
||||
internal: bool = True,
|
||||
) -> ServiceSessionImageType:
|
||||
req_profile = getattr(info.context.request, "profile", None)
|
||||
if not req_profile:
|
||||
raise ValidationError("Authentication required.")
|
||||
if not file or not getattr(file, "filename", None):
|
||||
raise ValidationError("No file provided.")
|
||||
|
||||
filename: str = file.filename
|
||||
content_type: str = getattr(file, "content_type", "") or ""
|
||||
data = await file.read()
|
||||
if not data:
|
||||
raise ValidationError("Empty file upload.")
|
||||
|
||||
_verify_image_bytes(data)
|
||||
|
||||
sess_pk = _decode_global_id(session_id)
|
||||
|
||||
def _create_img_sync() -> ServiceSessionImage:
|
||||
sess = ServiceSession.objects.get(pk=sess_pk)
|
||||
img = ServiceSessionImage(
|
||||
title=title or "",
|
||||
notes=notes or "",
|
||||
service_session=sess,
|
||||
uploaded_by_team_profile=req_profile,
|
||||
content_type=content_type,
|
||||
internal=internal,
|
||||
)
|
||||
img.image.save(filename, ContentFile(data), save=True)
|
||||
return img
|
||||
|
||||
instance: ServiceSessionImage = await database_sync_to_async(_create_img_sync)()
|
||||
|
||||
# Publish events
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_session_image_uploaded(
|
||||
image_id=str(instance.id),
|
||||
session_id=str(instance.service_session_id),
|
||||
is_internal=internal,
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ServiceSessionImageType, instance)
|
||||
|
||||
@strawberry.mutation(description="Upload an image to a ProjectSession")
|
||||
async def upload_project_session_image(
|
||||
self,
|
||||
info: Info,
|
||||
session_id: GlobalID,
|
||||
file: Upload,
|
||||
title: Optional[str] = None,
|
||||
notes: Optional[str] = None,
|
||||
internal: bool = True,
|
||||
) -> ProjectSessionImageType:
|
||||
req_profile = getattr(info.context.request, "profile", None)
|
||||
if not req_profile:
|
||||
raise ValidationError("Authentication required.")
|
||||
if not file or not getattr(file, "filename", None):
|
||||
raise ValidationError("No file provided.")
|
||||
|
||||
filename: str = file.filename
|
||||
content_type: str = getattr(file, "content_type", "") or ""
|
||||
data = await file.read()
|
||||
if not data:
|
||||
raise ValidationError("Empty file upload.")
|
||||
|
||||
_verify_image_bytes(data)
|
||||
|
||||
sess_pk = _decode_global_id(session_id)
|
||||
|
||||
def _create_img_sync() -> ProjectSessionImage:
|
||||
sess = ProjectSession.objects.get(pk=sess_pk)
|
||||
img = ProjectSessionImage(
|
||||
title=title or "",
|
||||
notes=notes or "",
|
||||
project_session=sess,
|
||||
uploaded_by_team_profile=req_profile,
|
||||
content_type=content_type,
|
||||
internal=internal,
|
||||
)
|
||||
img.image.save(filename, ContentFile(data), save=True)
|
||||
return img
|
||||
|
||||
instance: ProjectSessionImage = await database_sync_to_async(_create_img_sync)()
|
||||
|
||||
# Publish events
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_session_image_uploaded(
|
||||
image_id=str(instance.id),
|
||||
session_id=str(instance.project_session_id),
|
||||
is_internal=internal,
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ProjectSessionImageType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing ServiceSession image (e.g., title)")
|
||||
async def update_service_session_image(
|
||||
self, info: Info, input: ServiceSessionImageUpdateInput
|
||||
) -> ServiceSessionImageType:
|
||||
payload = {"id": input.id, "title": input.title, "notes": input.notes, "internal": input.internal}
|
||||
instance = await update_object(payload, ServiceSessionImage)
|
||||
|
||||
# Publish events
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_session_image_updated(
|
||||
image_id=str(instance.id),
|
||||
session_id=str(instance.service_session_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ServiceSessionImageType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing ProjectSession image (e.g., title)")
|
||||
async def update_project_session_image(
|
||||
self, info: Info, input: ProjectSessionImageUpdateInput
|
||||
) -> ProjectSessionImageType:
|
||||
payload = {"id": input.id, "title": input.title, "notes": input.notes, "internal": input.internal}
|
||||
instance = await update_object(payload, ProjectSessionImage)
|
||||
|
||||
# Publish events
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_session_image_updated(
|
||||
image_id=str(instance.id),
|
||||
session_id=str(instance.project_session_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ProjectSessionImageType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete a ServiceSession image")
|
||||
async def delete_service_session_image(self, info: Info, id: strawberry.ID) -> strawberry.ID:
|
||||
# Delete the instance (delete_object returns the instance before deletion)
|
||||
instance = await delete_object(id, ServiceSessionImage)
|
||||
if not instance:
|
||||
raise ValueError(f"ServiceSessionImage with ID {id} does not exist")
|
||||
|
||||
# Publish events
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_session_image_deleted(
|
||||
image_id=str(instance.id),
|
||||
session_id=str(instance.service_session_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return id
|
||||
|
||||
@strawberry.mutation(description="Delete a ProjectSession image")
|
||||
async def delete_project_session_image(self, info: Info, id: strawberry.ID) -> strawberry.ID:
|
||||
# Delete the instance (delete_object returns the instance before deletion)
|
||||
instance = await delete_object(id, ProjectSessionImage)
|
||||
if not instance:
|
||||
raise ValueError(f"ProjectSessionImage with ID {id} does not exist")
|
||||
|
||||
# Publish events
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_session_image_deleted(
|
||||
image_id=str(instance.id),
|
||||
session_id=str(instance.project_session_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return id
|
||||
109
core/graphql/mutations/session_note.py
Normal file
109
core/graphql/mutations/session_note.py
Normal file
@ -0,0 +1,109 @@
|
||||
from typing import cast
|
||||
import strawberry
|
||||
from strawberry.types import Info
|
||||
from core.graphql.inputs.session_note import (
|
||||
ServiceSessionNoteInput,
|
||||
ServiceSessionNoteUpdateInput,
|
||||
ProjectSessionNoteInput,
|
||||
ProjectSessionNoteUpdateInput,
|
||||
)
|
||||
from core.graphql.types.session_note import (
|
||||
ServiceSessionNoteType,
|
||||
ProjectSessionNoteType,
|
||||
)
|
||||
from core.models.session import ServiceSessionNote, ProjectSessionNote
|
||||
from core.graphql.utils import create_object, update_object, delete_object
|
||||
from core.services.events import (
|
||||
publish_session_note_created, publish_session_note_updated, publish_session_note_deleted,
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Mutation:
|
||||
@strawberry.mutation(description="Create a new service session note")
|
||||
async def create_service_session_note(self, input: ServiceSessionNoteInput, info: Info) -> ServiceSessionNoteType:
|
||||
instance = await create_object(input, ServiceSessionNote)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_session_note_created(
|
||||
note_id=str(instance.id),
|
||||
session_id=str(instance.session_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ServiceSessionNoteType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing service session note")
|
||||
async def update_service_session_note(self, input: ServiceSessionNoteUpdateInput, info: Info) -> ServiceSessionNoteType:
|
||||
instance = await update_object(input, ServiceSessionNote)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_session_note_updated(
|
||||
note_id=str(instance.id),
|
||||
session_id=str(instance.session_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ServiceSessionNoteType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete a service session note")
|
||||
async def delete_service_session_note(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
instance = await delete_object(id, ServiceSessionNote)
|
||||
if not instance:
|
||||
raise ValueError(f"ServiceSessionNote with ID {id} does not exist")
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_session_note_deleted(
|
||||
note_id=str(id),
|
||||
session_id=str(instance.session_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return id
|
||||
|
||||
@strawberry.mutation(description="Create a new project session note")
|
||||
async def create_project_session_note(self, input: ProjectSessionNoteInput, info: Info) -> ProjectSessionNoteType:
|
||||
instance = await create_object(input, ProjectSessionNote)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_session_note_created(
|
||||
note_id=str(instance.id),
|
||||
session_id=str(instance.session_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ProjectSessionNoteType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing project session note")
|
||||
async def update_project_session_note(self, input: ProjectSessionNoteUpdateInput, info: Info) -> ProjectSessionNoteType:
|
||||
instance = await update_object(input, ProjectSessionNote)
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_session_note_updated(
|
||||
note_id=str(instance.id),
|
||||
session_id=str(instance.session_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ProjectSessionNoteType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete a project session note")
|
||||
async def delete_project_session_note(self, id: strawberry.ID, info: Info) -> strawberry.ID:
|
||||
instance = await delete_object(id, ProjectSessionNote)
|
||||
if not instance:
|
||||
raise ValueError(f"ProjectSessionNote with ID {id} does not exist")
|
||||
|
||||
# Publish event for notifications
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_session_note_deleted(
|
||||
note_id=str(id),
|
||||
session_id=str(instance.session_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return id
|
||||
330
core/graphql/mutations/session_video.py
Normal file
330
core/graphql/mutations/session_video.py
Normal file
@ -0,0 +1,330 @@
|
||||
from typing import Optional, cast
|
||||
import strawberry
|
||||
from strawberry import Info
|
||||
from strawberry.file_uploads import Upload
|
||||
from strawberry.relay import GlobalID
|
||||
from channels.db import database_sync_to_async
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
from core.models.session import ServiceSession, ProjectSession
|
||||
from core.models.session_video import ServiceSessionVideo, ProjectSessionVideo
|
||||
from core.graphql.types.session_video import (
|
||||
ServiceSessionVideoType,
|
||||
ProjectSessionVideoType,
|
||||
)
|
||||
from core.services.video import (
|
||||
verify_video_bytes,
|
||||
extract_video_metadata,
|
||||
generate_video_thumbnail,
|
||||
)
|
||||
from core.graphql.utils import update_object, delete_object, _decode_global_id
|
||||
from core.graphql.inputs.session_video import (
|
||||
ServiceSessionVideoUpdateInput,
|
||||
ProjectSessionVideoUpdateInput,
|
||||
)
|
||||
from core.services.events import (
|
||||
publish_session_video_uploaded,
|
||||
publish_session_video_updated,
|
||||
publish_session_video_deleted,
|
||||
publish_session_media_internal_flagged,
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Mutation:
|
||||
@strawberry.mutation(description="Upload a video to a ServiceSession")
|
||||
async def upload_service_session_video(
|
||||
self,
|
||||
info: Info,
|
||||
session_id: GlobalID,
|
||||
file: Upload,
|
||||
title: Optional[str] = None,
|
||||
notes: Optional[str] = None,
|
||||
internal: bool = True,
|
||||
) -> ServiceSessionVideoType:
|
||||
"""
|
||||
Upload a video file to a ServiceSession.
|
||||
|
||||
Accepts video formats: MP4, MOV, WebM, AVI, MKV
|
||||
Maximum file size: 250 MB
|
||||
"""
|
||||
req_profile = getattr(info.context.request, "profile", None)
|
||||
if not req_profile:
|
||||
raise ValidationError("Authentication required.")
|
||||
if not file or not getattr(file, "filename", None):
|
||||
raise ValidationError("No file provided.")
|
||||
|
||||
filename: str = file.filename
|
||||
data = await file.read()
|
||||
if not data:
|
||||
raise ValidationError("Empty file upload.")
|
||||
|
||||
# Validate video file and get content type
|
||||
content_type = verify_video_bytes(data, filename)
|
||||
|
||||
sess_pk = _decode_global_id(session_id)
|
||||
|
||||
def _create_video_sync() -> ServiceSessionVideo:
|
||||
from django.core.files import File
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
sess = ServiceSession.objects.get(pk=sess_pk)
|
||||
|
||||
# Write video to temp file for ffmpeg processing (required for S3 storage)
|
||||
video_ext = os.path.splitext(filename)[1] or '.mp4'
|
||||
video_fd, video_tmp_path = tempfile.mkstemp(suffix=video_ext)
|
||||
thumb_fd, thumb_tmp_path = tempfile.mkstemp(suffix='.jpg')
|
||||
|
||||
try:
|
||||
# Write video bytes to temp file
|
||||
os.write(video_fd, data)
|
||||
os.close(video_fd)
|
||||
os.close(thumb_fd)
|
||||
|
||||
# Extract metadata from temp file (before saving to S3)
|
||||
metadata = extract_video_metadata(video_tmp_path)
|
||||
|
||||
# Generate thumbnail from temp file
|
||||
thumbnail_generated = generate_video_thumbnail(video_tmp_path, thumb_tmp_path, timestamp=1.0)
|
||||
|
||||
video = ServiceSessionVideo(
|
||||
title=title or "",
|
||||
notes=notes or "",
|
||||
service_session=sess,
|
||||
uploaded_by_team_profile=req_profile,
|
||||
content_type=content_type,
|
||||
internal=internal,
|
||||
)
|
||||
|
||||
# Set metadata before saving
|
||||
if metadata:
|
||||
video.width, video.height, video.duration_seconds = metadata
|
||||
|
||||
# Save video to storage (S3 or local)
|
||||
video.video.save(filename, ContentFile(data), save=True)
|
||||
|
||||
# Save thumbnail if generated
|
||||
if thumbnail_generated and os.path.exists(thumb_tmp_path):
|
||||
with open(thumb_tmp_path, 'rb') as thumb_file:
|
||||
video.thumbnail.save(
|
||||
f'thumb_{video.id}.jpg',
|
||||
File(thumb_file),
|
||||
save=False
|
||||
)
|
||||
|
||||
video.save()
|
||||
return video
|
||||
|
||||
finally:
|
||||
# Clean up temp files
|
||||
if os.path.exists(video_tmp_path):
|
||||
os.unlink(video_tmp_path)
|
||||
if os.path.exists(thumb_tmp_path):
|
||||
os.unlink(thumb_tmp_path)
|
||||
|
||||
instance: ServiceSessionVideo = await database_sync_to_async(_create_video_sync)()
|
||||
|
||||
# Publish events
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_session_video_uploaded(
|
||||
video_id=str(instance.id),
|
||||
session_id=str(instance.service_session_id),
|
||||
is_internal=internal,
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
# If marked as internal, also publish internal flag event
|
||||
if internal:
|
||||
await publish_session_media_internal_flagged(
|
||||
media_id=str(instance.id),
|
||||
media_type='SessionVideo',
|
||||
session_id=str(instance.service_session_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ServiceSessionVideoType, instance)
|
||||
|
||||
@strawberry.mutation(description="Upload a video to a ProjectSession")
|
||||
async def upload_project_session_video(
|
||||
self,
|
||||
info: Info,
|
||||
session_id: GlobalID,
|
||||
file: Upload,
|
||||
title: Optional[str] = None,
|
||||
notes: Optional[str] = None,
|
||||
internal: bool = True,
|
||||
) -> ProjectSessionVideoType:
|
||||
"""
|
||||
Upload a video file to a ProjectSession.
|
||||
|
||||
Accepts video formats: MP4, MOV, WebM, AVI, MKV
|
||||
Maximum file size: 250 MB
|
||||
"""
|
||||
req_profile = getattr(info.context.request, "profile", None)
|
||||
if not req_profile:
|
||||
raise ValidationError("Authentication required.")
|
||||
if not file or not getattr(file, "filename", None):
|
||||
raise ValidationError("No file provided.")
|
||||
|
||||
filename: str = file.filename
|
||||
data = await file.read()
|
||||
if not data:
|
||||
raise ValidationError("Empty file upload.")
|
||||
|
||||
# Validate video file and get content type
|
||||
content_type = verify_video_bytes(data, filename)
|
||||
|
||||
sess_pk = _decode_global_id(session_id)
|
||||
|
||||
def _create_video_sync() -> ProjectSessionVideo:
|
||||
from django.core.files import File
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
sess = ProjectSession.objects.get(pk=sess_pk)
|
||||
|
||||
# Write video to temp file for ffmpeg processing (required for S3 storage)
|
||||
video_ext = os.path.splitext(filename)[1] or '.mp4'
|
||||
video_fd, video_tmp_path = tempfile.mkstemp(suffix=video_ext)
|
||||
thumb_fd, thumb_tmp_path = tempfile.mkstemp(suffix='.jpg')
|
||||
|
||||
try:
|
||||
# Write video bytes to temp file
|
||||
os.write(video_fd, data)
|
||||
os.close(video_fd)
|
||||
os.close(thumb_fd)
|
||||
|
||||
# Extract metadata from temp file (before saving to S3)
|
||||
metadata = extract_video_metadata(video_tmp_path)
|
||||
|
||||
# Generate thumbnail from temp file
|
||||
thumbnail_generated = generate_video_thumbnail(video_tmp_path, thumb_tmp_path, timestamp=1.0)
|
||||
|
||||
video = ProjectSessionVideo(
|
||||
title=title or "",
|
||||
notes=notes or "",
|
||||
project_session=sess,
|
||||
uploaded_by_team_profile=req_profile,
|
||||
content_type=content_type,
|
||||
internal=internal,
|
||||
)
|
||||
|
||||
# Set metadata before saving
|
||||
if metadata:
|
||||
video.width, video.height, video.duration_seconds = metadata
|
||||
|
||||
# Save video to storage (S3 or local)
|
||||
video.video.save(filename, ContentFile(data), save=True)
|
||||
|
||||
# Save thumbnail if generated
|
||||
if thumbnail_generated and os.path.exists(thumb_tmp_path):
|
||||
with open(thumb_tmp_path, 'rb') as thumb_file:
|
||||
video.thumbnail.save(
|
||||
f'thumb_{video.id}.jpg',
|
||||
File(thumb_file),
|
||||
save=False
|
||||
)
|
||||
|
||||
video.save()
|
||||
return video
|
||||
|
||||
finally:
|
||||
# Clean up temp files
|
||||
if os.path.exists(video_tmp_path):
|
||||
os.unlink(video_tmp_path)
|
||||
if os.path.exists(thumb_tmp_path):
|
||||
os.unlink(thumb_tmp_path)
|
||||
|
||||
instance: ProjectSessionVideo = await database_sync_to_async(_create_video_sync)()
|
||||
|
||||
# Publish events
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_session_video_uploaded(
|
||||
video_id=str(instance.id),
|
||||
session_id=str(instance.project_session_id),
|
||||
is_internal=internal,
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
# If marked as internal, also publish internal flag event
|
||||
if internal:
|
||||
await publish_session_media_internal_flagged(
|
||||
media_id=str(instance.id),
|
||||
media_type='SessionVideo',
|
||||
session_id=str(instance.project_session_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ProjectSessionVideoType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing ServiceSession video (e.g., title)")
|
||||
async def update_service_session_video(
|
||||
self, info: Info, input: ServiceSessionVideoUpdateInput
|
||||
) -> ServiceSessionVideoType:
|
||||
payload = {"id": input.id, "title": input.title, "notes": input.notes, "internal": input.internal}
|
||||
instance = await update_object(payload, ServiceSessionVideo)
|
||||
|
||||
# Publish events
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_session_video_updated(
|
||||
video_id=str(instance.id),
|
||||
session_id=str(instance.service_session_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ServiceSessionVideoType, instance)
|
||||
|
||||
@strawberry.mutation(description="Update an existing ProjectSession video (e.g., title)")
|
||||
async def update_project_session_video(
|
||||
self, info: Info, input: ProjectSessionVideoUpdateInput
|
||||
) -> ProjectSessionVideoType:
|
||||
payload = {"id": input.id, "title": input.title, "notes": input.notes, "internal": input.internal}
|
||||
instance = await update_object(payload, ProjectSessionVideo)
|
||||
|
||||
# Publish events
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_session_video_updated(
|
||||
video_id=str(instance.id),
|
||||
session_id=str(instance.project_session_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return cast(ProjectSessionVideoType, instance)
|
||||
|
||||
@strawberry.mutation(description="Delete a ServiceSession video")
|
||||
async def delete_service_session_video(self, info: Info, id: strawberry.ID) -> strawberry.ID:
|
||||
"""Delete a video from a ServiceSession."""
|
||||
# Delete the instance (delete_object returns the instance before deletion)
|
||||
instance = await delete_object(id, ServiceSessionVideo)
|
||||
if not instance:
|
||||
raise ValueError(f"ServiceSessionVideo with ID {id} does not exist")
|
||||
|
||||
# Publish events
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_session_video_deleted(
|
||||
video_id=str(instance.id),
|
||||
session_id=str(instance.service_session_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return id
|
||||
|
||||
@strawberry.mutation(description="Delete a ProjectSession video")
|
||||
async def delete_project_session_video(self, info: Info, id: strawberry.ID) -> strawberry.ID:
|
||||
"""Delete a video from a ProjectSession."""
|
||||
# Delete the instance (delete_object returns the instance before deletion)
|
||||
instance = await delete_object(id, ProjectSessionVideo)
|
||||
if not instance:
|
||||
raise ValueError(f"ProjectSessionVideo with ID {id} does not exist")
|
||||
|
||||
# Publish events
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
await publish_session_video_deleted(
|
||||
video_id=str(instance.id),
|
||||
session_id=str(instance.project_session_id),
|
||||
triggered_by=profile
|
||||
)
|
||||
|
||||
return id
|
||||
49
core/graphql/pubsub.py
Normal file
49
core/graphql/pubsub.py
Normal file
@ -0,0 +1,49 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator, Any, AsyncIterator
|
||||
|
||||
from channels.layers import get_channel_layer
|
||||
|
||||
|
||||
class PubSub:
|
||||
"""
|
||||
A PubSub implementation that uses the Django Channels layer.
|
||||
"""
|
||||
def __init__(self):
|
||||
self.channel_layer = get_channel_layer()
|
||||
|
||||
async def publish(self, channel: str, message: Any):
|
||||
"""
|
||||
Publishes a message to the given channel.
|
||||
"""
|
||||
await self.channel_layer.group_send(
|
||||
channel,
|
||||
{
|
||||
"type": "channel.message",
|
||||
"message": message,
|
||||
},
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def subscribe(self, channel: str) -> AsyncGenerator[AsyncIterator[Any], None]:
|
||||
"""
|
||||
Subscribes to a channel and yields an async iterator over messages.
|
||||
Designed to be used with 'async with'.
|
||||
"""
|
||||
channel_name = await self.channel_layer.new_channel()
|
||||
await self.channel_layer.group_add(channel, channel_name)
|
||||
|
||||
async def _subscriber():
|
||||
while True:
|
||||
message = await self.channel_layer.receive(channel_name)
|
||||
if message.get("type") == "channel.message":
|
||||
yield message["message"]
|
||||
|
||||
try:
|
||||
yield _subscriber()
|
||||
finally:
|
||||
# This cleanup code will run automatically when the 'async with' block is exited.
|
||||
await self.channel_layer.group_discard(channel, channel_name)
|
||||
|
||||
|
||||
# Create a single global instance for the application to use.
|
||||
pubsub = PubSub()
|
||||
18
core/graphql/queries/__init__.py
Normal file
18
core/graphql/queries/__init__.py
Normal file
@ -0,0 +1,18 @@
|
||||
from core.graphql.queries.customer import *
|
||||
from core.graphql.queries.account import *
|
||||
from core.graphql.queries.profile import *
|
||||
from core.graphql.queries.project import *
|
||||
from core.graphql.queries.service import *
|
||||
from core.graphql.queries.labor import *
|
||||
from core.graphql.queries.revenue import *
|
||||
from core.graphql.queries.schedule import *
|
||||
from core.graphql.queries.invoice import *
|
||||
from core.graphql.queries.report import *
|
||||
from core.graphql.queries.account_punchlist import *
|
||||
from core.graphql.queries.project_punchlist import *
|
||||
from core.graphql.queries.scope import *
|
||||
from core.graphql.queries.scope_template import *
|
||||
from core.graphql.queries.project_scope import *
|
||||
from core.graphql.queries.project_scope_template import *
|
||||
from core.graphql.queries.session import *
|
||||
from core.graphql.queries.session_image import *
|
||||
13
core/graphql/queries/account.py
Normal file
13
core/graphql/queries/account.py
Normal file
@ -0,0 +1,13 @@
|
||||
import strawberry
|
||||
import strawberry_django as sd
|
||||
from typing import List, Optional
|
||||
from core.graphql.types.account import AccountType, AccountAddressType, AccountContactType
|
||||
from core.graphql.filters.account import AccountFilter, AccountContactFilter
|
||||
|
||||
@strawberry.type
|
||||
class Query:
|
||||
account: Optional[AccountType] = sd.node()
|
||||
account_address: Optional[AccountAddressType] = sd.node()
|
||||
account_contact: Optional[AccountContactType] = sd.node()
|
||||
accounts: List[AccountType] = sd.field(filters=AccountFilter)
|
||||
account_contacts: List[AccountContactType] = sd.field(filters=AccountContactFilter)
|
||||
12
core/graphql/queries/account_punchlist.py
Normal file
12
core/graphql/queries/account_punchlist.py
Normal file
@ -0,0 +1,12 @@
|
||||
import strawberry
|
||||
import strawberry_django as sd
|
||||
from typing import List, Optional
|
||||
from core.graphql.types.account_punchlist import AccountPunchlistType
|
||||
from core.graphql.filters.account_punchlist import AccountPunchlistFilter
|
||||
|
||||
@strawberry.type
|
||||
class Query:
|
||||
account_punchlist: Optional[AccountPunchlistType] = sd.node()
|
||||
account_punchlists: List[AccountPunchlistType] = sd.field(
|
||||
filters=AccountPunchlistFilter
|
||||
)
|
||||
13
core/graphql/queries/customer.py
Normal file
13
core/graphql/queries/customer.py
Normal file
@ -0,0 +1,13 @@
|
||||
import strawberry
|
||||
import strawberry_django as sd
|
||||
from typing import List, Optional
|
||||
from core.graphql.types.customer import CustomerType, CustomerAddressType, CustomerContactType
|
||||
from core.graphql.filters.customer import CustomerFilter, CustomerContactFilter
|
||||
|
||||
@strawberry.type
|
||||
class Query:
|
||||
customer: Optional[CustomerType] = sd.node()
|
||||
customer_address: Optional[CustomerAddressType] = sd.node()
|
||||
customer_contact: Optional[CustomerContactType] = sd.node()
|
||||
customers: List[CustomerType] = sd.field(filters=CustomerFilter)
|
||||
customer_contacts: List[CustomerContactType] = sd.field(filters=CustomerContactFilter)
|
||||
274
core/graphql/queries/dashboard.py
Normal file
274
core/graphql/queries/dashboard.py
Normal file
@ -0,0 +1,274 @@
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
import strawberry
|
||||
from strawberry import ID
|
||||
from django.db.models import Prefetch
|
||||
from asgiref.sync import sync_to_async
|
||||
from core.graphql.types.dashboard import (
|
||||
AdminDashboardData,
|
||||
TeamDashboardData,
|
||||
CustomerDashboardData,
|
||||
)
|
||||
from core.models.service import Service
|
||||
from core.models.project import Project
|
||||
from core.models.invoice import Invoice
|
||||
from core.models.report import Report
|
||||
from core.models.scope_template import ScopeTemplate, AreaTemplate, TaskTemplate
|
||||
from core.models.project_scope_template import (
|
||||
ProjectScopeTemplate,
|
||||
ProjectAreaTemplate,
|
||||
ProjectTaskTemplate,
|
||||
)
|
||||
|
||||
|
||||
def parse_month_range(month: str) -> tuple[date, date]:
|
||||
"""Parse a month string like '2024-01' into start and end dates."""
|
||||
year, month_num = map(int, month.split('-'))
|
||||
start = date(year, month_num, 1)
|
||||
|
||||
# Calculate end of month
|
||||
if month_num == 12:
|
||||
end = date(year + 1, 1, 1)
|
||||
else:
|
||||
end = date(year, month_num + 1, 1)
|
||||
|
||||
# End is exclusive, so subtract one day for inclusive range
|
||||
from datetime import timedelta
|
||||
end = end - timedelta(days=1)
|
||||
|
||||
return start, end
|
||||
|
||||
|
||||
def _fetch_admin_dashboard_sync(
|
||||
start: date,
|
||||
end: date,
|
||||
invoice_status: Optional[str],
|
||||
) -> AdminDashboardData:
|
||||
"""Synchronous database fetching for admin dashboard."""
|
||||
# Services - optimized with prefetch for team_members
|
||||
services = list(
|
||||
Service.objects
|
||||
.filter(date__gte=start, date__lte=end)
|
||||
.select_related('account_address', 'account_address__account')
|
||||
.prefetch_related('team_members')
|
||||
.order_by('date', 'id')
|
||||
)
|
||||
|
||||
# Projects - optimized with prefetch for team_members
|
||||
projects = list(
|
||||
Project.objects
|
||||
.filter(date__gte=start, date__lte=end)
|
||||
.select_related('account_address', 'account_address__account', 'customer')
|
||||
.prefetch_related('team_members')
|
||||
.order_by('date', 'id')
|
||||
)
|
||||
|
||||
# Invoices - show all (pages need full list, not month-filtered)
|
||||
invoices_qs = Invoice.objects.select_related('customer')
|
||||
if invoice_status:
|
||||
invoices_qs = invoices_qs.filter(status=invoice_status)
|
||||
invoices = list(invoices_qs.order_by('-date', '-id'))
|
||||
|
||||
# Reports - show all (pages need full list, not month-filtered)
|
||||
reports = list(
|
||||
Report.objects
|
||||
.select_related('team_member')
|
||||
.order_by('-date', '-id')
|
||||
)
|
||||
|
||||
# Service Scope Templates - with nested areas and tasks prefetched
|
||||
task_prefetch = Prefetch(
|
||||
'task_templates',
|
||||
queryset=TaskTemplate.objects.order_by('order', 'id')
|
||||
)
|
||||
area_prefetch = Prefetch(
|
||||
'area_templates',
|
||||
queryset=AreaTemplate.objects.prefetch_related(task_prefetch).order_by('order', 'name')
|
||||
)
|
||||
service_scope_templates = list(
|
||||
ScopeTemplate.objects
|
||||
.prefetch_related(area_prefetch)
|
||||
.order_by('name')
|
||||
)
|
||||
|
||||
# Project Scope Templates - with nested categories and tasks prefetched
|
||||
project_task_prefetch = Prefetch(
|
||||
'task_templates',
|
||||
queryset=ProjectTaskTemplate.objects.order_by('order', 'id')
|
||||
)
|
||||
category_prefetch = Prefetch(
|
||||
'category_templates',
|
||||
queryset=ProjectAreaTemplate.objects.prefetch_related(project_task_prefetch).order_by('order', 'name')
|
||||
)
|
||||
project_scope_templates = list(
|
||||
ProjectScopeTemplate.objects
|
||||
.prefetch_related(category_prefetch)
|
||||
.order_by('name')
|
||||
)
|
||||
|
||||
return AdminDashboardData(
|
||||
services=services,
|
||||
projects=projects,
|
||||
invoices=invoices,
|
||||
reports=reports,
|
||||
service_scope_templates=service_scope_templates,
|
||||
project_scope_templates=project_scope_templates,
|
||||
)
|
||||
|
||||
|
||||
def _fetch_team_dashboard_sync(
|
||||
team_profile_id: str,
|
||||
start: date,
|
||||
end: date,
|
||||
) -> TeamDashboardData:
|
||||
"""Synchronous database fetching for team dashboard."""
|
||||
# Services assigned to this team member
|
||||
services = list(
|
||||
Service.objects
|
||||
.filter(
|
||||
team_members__id=team_profile_id,
|
||||
date__gte=start,
|
||||
date__lte=end
|
||||
)
|
||||
.select_related('account_address', 'account_address__account')
|
||||
.prefetch_related('team_members')
|
||||
.order_by('date', 'id')
|
||||
)
|
||||
|
||||
# Projects assigned to this team member
|
||||
projects = list(
|
||||
Project.objects
|
||||
.filter(
|
||||
team_members__id=team_profile_id,
|
||||
date__gte=start,
|
||||
date__lte=end
|
||||
)
|
||||
.select_related('account_address', 'account_address__account', 'customer')
|
||||
.prefetch_related('team_members')
|
||||
.order_by('date', 'id')
|
||||
)
|
||||
|
||||
# Reports for this team member
|
||||
reports = list(
|
||||
Report.objects
|
||||
.filter(
|
||||
team_member_id=team_profile_id,
|
||||
date__gte=start,
|
||||
date__lte=end
|
||||
)
|
||||
.select_related('team_member')
|
||||
.order_by('-date', '-id')
|
||||
)
|
||||
|
||||
return TeamDashboardData(
|
||||
services=services,
|
||||
projects=projects,
|
||||
reports=reports,
|
||||
)
|
||||
|
||||
|
||||
def _fetch_customer_dashboard_sync(
|
||||
customer_id: str,
|
||||
) -> CustomerDashboardData:
|
||||
"""Synchronous database fetching for customer dashboard."""
|
||||
# Services for customer's accounts
|
||||
services = list(
|
||||
Service.objects
|
||||
.filter(account_address__account__customer_id=customer_id)
|
||||
.select_related('account_address', 'account_address__account')
|
||||
.prefetch_related('team_members')
|
||||
.order_by('-date', '-id')[:100] # Limit for performance
|
||||
)
|
||||
|
||||
# Projects for customer
|
||||
projects = list(
|
||||
Project.objects
|
||||
.filter(customer_id=customer_id)
|
||||
.select_related('account_address', 'account_address__account', 'customer')
|
||||
.prefetch_related('team_members')
|
||||
.order_by('-date', '-id')[:100] # Limit for performance
|
||||
)
|
||||
|
||||
# Invoices for customer
|
||||
invoices = list(
|
||||
Invoice.objects
|
||||
.filter(customer_id=customer_id)
|
||||
.select_related('customer')
|
||||
.order_by('-date', '-id')[:100] # Limit for performance
|
||||
)
|
||||
|
||||
return CustomerDashboardData(
|
||||
services=services,
|
||||
projects=projects,
|
||||
invoices=invoices,
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Query:
|
||||
@strawberry.field(
|
||||
name="adminDashboard",
|
||||
description="Consolidated dashboard data for admin/team leader users. "
|
||||
"Returns all services, projects, invoices, reports, and scope templates "
|
||||
"for the given month in a single optimized query."
|
||||
)
|
||||
async def admin_dashboard(
|
||||
self,
|
||||
info,
|
||||
month: str,
|
||||
invoice_status: Optional[str] = None,
|
||||
) -> AdminDashboardData:
|
||||
"""Fetch all admin dashboard data in a single optimized query.
|
||||
|
||||
Args:
|
||||
month: Month string in format 'YYYY-MM' (e.g., '2024-01')
|
||||
invoice_status: Optional invoice status filter (e.g., 'SENT', 'PAID')
|
||||
|
||||
Returns:
|
||||
AdminDashboardData with all dashboard entities
|
||||
"""
|
||||
start, end = parse_month_range(month)
|
||||
return await sync_to_async(_fetch_admin_dashboard_sync)(start, end, invoice_status)
|
||||
|
||||
@strawberry.field(
|
||||
name="teamDashboard",
|
||||
description="Consolidated dashboard data for team member users. "
|
||||
"Returns services and projects assigned to the requesting user."
|
||||
)
|
||||
async def team_dashboard(
|
||||
self,
|
||||
info,
|
||||
team_profile_id: ID,
|
||||
month: str,
|
||||
) -> TeamDashboardData:
|
||||
"""Fetch all team dashboard data in a single optimized query.
|
||||
|
||||
Args:
|
||||
team_profile_id: The team member's profile ID
|
||||
month: Month string in format 'YYYY-MM' (e.g., '2024-01')
|
||||
|
||||
Returns:
|
||||
TeamDashboardData with services and projects for the team member
|
||||
"""
|
||||
start, end = parse_month_range(month)
|
||||
return await sync_to_async(_fetch_team_dashboard_sync)(team_profile_id, start, end)
|
||||
|
||||
@strawberry.field(
|
||||
name="customerDashboard",
|
||||
description="Consolidated dashboard data for customer users. "
|
||||
"Returns services, projects, and invoices for the customer."
|
||||
)
|
||||
async def customer_dashboard(
|
||||
self,
|
||||
info,
|
||||
customer_id: ID,
|
||||
) -> CustomerDashboardData:
|
||||
"""Fetch all customer dashboard data in a single optimized query.
|
||||
|
||||
Args:
|
||||
customer_id: The customer's profile ID
|
||||
|
||||
Returns:
|
||||
CustomerDashboardData with services, projects, and invoices
|
||||
"""
|
||||
return await sync_to_async(_fetch_customer_dashboard_sync)(customer_id)
|
||||
206
core/graphql/queries/event.py
Normal file
206
core/graphql/queries/event.py
Normal file
@ -0,0 +1,206 @@
|
||||
import strawberry
|
||||
from typing import List, Optional
|
||||
from strawberry.types import Info
|
||||
from channels.db import database_sync_to_async
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from core.graphql.types.event import EventType, NotificationRuleType, NotificationType, NotificationDeliveryType
|
||||
from core.models.events import Event, NotificationRule, Notification, NotificationDelivery
|
||||
from core.models.enums import NotificationStatusChoices
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Query:
|
||||
@strawberry.field(description="Get all events")
|
||||
async def events(
|
||||
self,
|
||||
info: Info,
|
||||
limit: Optional[int] = 50,
|
||||
offset: Optional[int] = 0
|
||||
) -> List[EventType]:
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
if not profile:
|
||||
raise PermissionError("Authentication required")
|
||||
|
||||
events = await database_sync_to_async(
|
||||
lambda: list(Event.objects.all().order_by('-created_at')[offset:offset + limit])
|
||||
)()
|
||||
|
||||
return events
|
||||
|
||||
@strawberry.field(description="Get event by ID")
|
||||
async def event(
|
||||
self,
|
||||
info: Info,
|
||||
id: strawberry.ID
|
||||
) -> Optional[EventType]:
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
if not profile:
|
||||
raise PermissionError("Authentication required")
|
||||
|
||||
try:
|
||||
event = await database_sync_to_async(Event.objects.get)(pk=id)
|
||||
return event
|
||||
except Event.DoesNotExist:
|
||||
return None
|
||||
|
||||
@strawberry.field(description="Get all notification rules")
|
||||
async def notification_rules(
|
||||
self,
|
||||
info: Info,
|
||||
is_active: Optional[bool] = None
|
||||
) -> List[NotificationRuleType]:
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
if not profile:
|
||||
raise PermissionError("Authentication required")
|
||||
|
||||
# Only admins can view notification rules
|
||||
from core.models.profile import TeamProfile
|
||||
from core.models.enums import RoleChoices
|
||||
if not isinstance(profile, TeamProfile) or profile.role != RoleChoices.ADMIN:
|
||||
raise PermissionError("Admin access required")
|
||||
|
||||
queryset = NotificationRule.objects.prefetch_related(
|
||||
'target_team_profiles',
|
||||
'target_customer_profiles'
|
||||
)
|
||||
if is_active is not None:
|
||||
queryset = queryset.filter(is_active=is_active)
|
||||
|
||||
rules = await database_sync_to_async(lambda: list(queryset.order_by('name')))()
|
||||
return rules
|
||||
|
||||
@strawberry.field(description="Get notification rule by ID")
|
||||
async def notification_rule(
|
||||
self,
|
||||
info: Info,
|
||||
id: strawberry.ID
|
||||
) -> Optional[NotificationRuleType]:
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
if not profile:
|
||||
raise PermissionError("Authentication required")
|
||||
|
||||
# Only admins can view notification rules
|
||||
from core.models.profile import TeamProfile
|
||||
from core.models.enums import RoleChoices
|
||||
if not isinstance(profile, TeamProfile) or profile.role != RoleChoices.ADMIN:
|
||||
raise PermissionError("Admin access required")
|
||||
|
||||
try:
|
||||
rule = await database_sync_to_async(
|
||||
lambda: NotificationRule.objects.prefetch_related(
|
||||
'target_team_profiles',
|
||||
'target_customer_profiles'
|
||||
).get(pk=id)
|
||||
)()
|
||||
return rule
|
||||
except NotificationRule.DoesNotExist:
|
||||
return None
|
||||
|
||||
@strawberry.field(description="Get notifications for current user")
|
||||
async def my_notifications(
|
||||
self,
|
||||
info: Info,
|
||||
unread_only: Optional[bool] = False,
|
||||
limit: Optional[int] = 50,
|
||||
offset: Optional[int] = 0
|
||||
) -> List[NotificationType]:
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
if not profile:
|
||||
raise PermissionError("Authentication required")
|
||||
|
||||
@database_sync_to_async
|
||||
def get_notifications():
|
||||
# Get content type for the profile
|
||||
content_type = ContentType.objects.get_for_model(type(profile))
|
||||
|
||||
# Build query
|
||||
queryset = Notification.objects.filter(
|
||||
recipient_content_type=content_type,
|
||||
recipient_object_id=profile.id
|
||||
)
|
||||
|
||||
if unread_only:
|
||||
queryset = queryset.filter(read_at__isnull=True)
|
||||
|
||||
# Get notifications
|
||||
return list(
|
||||
queryset.select_related('event', 'rule')
|
||||
.order_by('-created_at')[offset:offset + limit]
|
||||
)
|
||||
|
||||
return await get_notifications()
|
||||
|
||||
@strawberry.field(description="Get unread notification count for current user")
|
||||
async def my_unread_notification_count(self, info: Info) -> int:
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
if not profile:
|
||||
return 0
|
||||
|
||||
# Get content type for the profile
|
||||
content_type = await database_sync_to_async(ContentType.objects.get_for_model)(profile)
|
||||
|
||||
# Count unread notifications
|
||||
count = await database_sync_to_async(
|
||||
Notification.objects.filter(
|
||||
recipient_content_type=content_type,
|
||||
recipient_object_id=profile.id,
|
||||
read_at__isnull=True
|
||||
).count
|
||||
)()
|
||||
|
||||
return count
|
||||
|
||||
@strawberry.field(description="Get notification by ID")
|
||||
async def notification(
|
||||
self,
|
||||
info: Info,
|
||||
id: strawberry.ID
|
||||
) -> Optional[NotificationType]:
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
if not profile:
|
||||
raise PermissionError("Authentication required")
|
||||
|
||||
@database_sync_to_async
|
||||
def get_and_verify():
|
||||
notification = Notification.objects.select_related('event', 'rule').get(pk=id)
|
||||
|
||||
# Verify user has access to this notification
|
||||
content_type = ContentType.objects.get_for_model(type(profile))
|
||||
|
||||
if (notification.recipient_content_type != content_type or
|
||||
str(notification.recipient_object_id) != str(profile.id)):
|
||||
raise PermissionError("Not authorized to view this notification")
|
||||
|
||||
return notification
|
||||
|
||||
try:
|
||||
return await get_and_verify()
|
||||
except Notification.DoesNotExist:
|
||||
return None
|
||||
|
||||
@strawberry.field(description="Get notification delivery status")
|
||||
async def notification_deliveries(
|
||||
self,
|
||||
info: Info,
|
||||
notification_id: strawberry.ID
|
||||
) -> List[NotificationDeliveryType]:
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
if not profile:
|
||||
raise PermissionError("Authentication required")
|
||||
|
||||
# Only admins can view delivery status
|
||||
from core.models.profile import TeamProfile
|
||||
from core.models.enums import RoleChoices
|
||||
if not isinstance(profile, TeamProfile) or profile.role != RoleChoices.ADMIN:
|
||||
raise PermissionError("Admin access required")
|
||||
|
||||
deliveries = await database_sync_to_async(
|
||||
lambda: list(
|
||||
NotificationDelivery.objects.filter(notification_id=notification_id)
|
||||
.select_related('notification')
|
||||
.order_by('-created_at')
|
||||
)
|
||||
)()
|
||||
|
||||
return deliveries
|
||||
10
core/graphql/queries/invoice.py
Normal file
10
core/graphql/queries/invoice.py
Normal file
@ -0,0 +1,10 @@
|
||||
import strawberry
|
||||
import strawberry_django as sd
|
||||
from typing import List, Optional
|
||||
from core.graphql.types.invoice import InvoiceType
|
||||
from core.graphql.filters.invoice import InvoiceFilter
|
||||
|
||||
@strawberry.type
|
||||
class Query:
|
||||
invoice: Optional[InvoiceType] = sd.node()
|
||||
invoices: List[InvoiceType] = sd.field(filters=InvoiceFilter)
|
||||
10
core/graphql/queries/labor.py
Normal file
10
core/graphql/queries/labor.py
Normal file
@ -0,0 +1,10 @@
|
||||
import strawberry
|
||||
import strawberry_django as sd
|
||||
from typing import List, Optional
|
||||
from core.graphql.types.labor import LaborType
|
||||
from core.graphql.filters.labor import LaborFilter
|
||||
|
||||
@strawberry.type
|
||||
class Query:
|
||||
labor: Optional[LaborType] = sd.node()
|
||||
labors: List[LaborType] = sd.field(filters=LaborFilter)
|
||||
148
core/graphql/queries/messaging.py
Normal file
148
core/graphql/queries/messaging.py
Normal file
@ -0,0 +1,148 @@
|
||||
from typing import List, Optional, Iterable
|
||||
import strawberry
|
||||
import strawberry_django as sd
|
||||
from strawberry import ID
|
||||
from strawberry_django.relay import DjangoCursorConnection
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from core.graphql.filters.messaging import ConversationFilter, MessageFilter
|
||||
from core.graphql.types.messaging import ConversationType, MessageType
|
||||
from core.models.messaging import Conversation, Message
|
||||
from core.models.profile import TeamProfile, CustomerProfile
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Query:
|
||||
"""Messaging queries"""
|
||||
|
||||
conversation: Optional[ConversationType] = sd.node()
|
||||
conversations: List[ConversationType] = sd.field(filters=ConversationFilter)
|
||||
|
||||
message: Optional[MessageType] = sd.node()
|
||||
messages: List[MessageType] = sd.field(filters=MessageFilter)
|
||||
|
||||
@sd.connection(
|
||||
DjangoCursorConnection["ConversationType"],
|
||||
name="getMyConversations",
|
||||
description="Return conversations for the authenticated user (inbox)",
|
||||
filters=ConversationFilter,
|
||||
)
|
||||
def get_my_conversations(
|
||||
self,
|
||||
info,
|
||||
include_archived: bool = False,
|
||||
) -> Iterable["Conversation"]:
|
||||
"""
|
||||
Get all conversations for the current authenticated user.
|
||||
Returns conversations ordered by last message timestamp.
|
||||
"""
|
||||
# Get profile directly from context (not Django User model)
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
if not profile:
|
||||
return Conversation.objects.none()
|
||||
|
||||
# Determine the profile's content type
|
||||
content_type = ContentType.objects.get_for_model(type(profile))
|
||||
|
||||
# Build query
|
||||
queryset = Conversation.objects.filter(
|
||||
participants__participant_content_type=content_type,
|
||||
participants__participant_object_id=profile.id,
|
||||
)
|
||||
|
||||
# Filter archived conversations unless explicitly requested
|
||||
if not include_archived:
|
||||
queryset = queryset.filter(participants__is_archived=False)
|
||||
|
||||
return queryset.prefetch_related(
|
||||
'participants',
|
||||
'participants__participant_content_type',
|
||||
).distinct().order_by('-last_message_at', '-created_at')
|
||||
|
||||
@sd.connection(
|
||||
DjangoCursorConnection["ConversationType"],
|
||||
name="getConversationsByEntity",
|
||||
description="Return conversations linked to a specific entity (Project, Service, Account, etc.)",
|
||||
filters=ConversationFilter,
|
||||
)
|
||||
def get_conversations_by_entity(
|
||||
self,
|
||||
entity_type: str,
|
||||
entity_id: ID,
|
||||
) -> Iterable["Conversation"]:
|
||||
"""
|
||||
Get all conversations linked to a specific entity.
|
||||
entity_type: Model name (e.g., 'Project', 'Service', 'Account')
|
||||
entity_id: UUID of the entity
|
||||
"""
|
||||
from django.apps import apps
|
||||
|
||||
try:
|
||||
# Get the content type for the entity
|
||||
model = apps.get_model('core', entity_type)
|
||||
content_type = ContentType.objects.get_for_model(model)
|
||||
|
||||
return Conversation.objects.filter(
|
||||
entity_content_type=content_type,
|
||||
entity_object_id=entity_id
|
||||
).prefetch_related(
|
||||
'participants',
|
||||
'participants__participant_content_type',
|
||||
).order_by('-last_message_at')
|
||||
except Exception:
|
||||
return Conversation.objects.none()
|
||||
|
||||
@strawberry.field(description="Get unread message count for the authenticated user")
|
||||
async def unread_message_count(self, info) -> int:
|
||||
"""
|
||||
Get total unread message count across all conversations for the current user.
|
||||
"""
|
||||
from channels.db import database_sync_to_async
|
||||
|
||||
# Get profile directly from context (not Django User model)
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
if not profile:
|
||||
return 0
|
||||
|
||||
@database_sync_to_async
|
||||
def get_count():
|
||||
# Determine the profile's content type
|
||||
content_type = ContentType.objects.get_for_model(type(profile))
|
||||
|
||||
# Sum unread counts from all participant records
|
||||
from core.models.messaging import ConversationParticipant
|
||||
from django.db.models import Sum
|
||||
|
||||
total = ConversationParticipant.objects.filter(
|
||||
participant_content_type=content_type,
|
||||
participant_object_id=profile.id,
|
||||
is_archived=False
|
||||
).aggregate(total=Sum('unread_count'))['total']
|
||||
|
||||
return total if total else 0
|
||||
|
||||
return await get_count()
|
||||
|
||||
@sd.connection(
|
||||
DjangoCursorConnection["MessageType"],
|
||||
name="getMessagesByConversation",
|
||||
description="Return messages for a specific conversation",
|
||||
filters=MessageFilter,
|
||||
)
|
||||
def get_messages_by_conversation(
|
||||
self,
|
||||
conversation_id: ID,
|
||||
include_system: bool = True,
|
||||
) -> Iterable["Message"]:
|
||||
"""
|
||||
Get all messages for a specific conversation.
|
||||
"""
|
||||
queryset = Message.objects.filter(conversation_id=conversation_id)
|
||||
|
||||
if not include_system:
|
||||
queryset = queryset.filter(is_system_message=False)
|
||||
|
||||
return queryset.prefetch_related(
|
||||
'read_receipts',
|
||||
'sender_content_type',
|
||||
).order_by('created_at')
|
||||
27
core/graphql/queries/profile.py
Normal file
27
core/graphql/queries/profile.py
Normal file
@ -0,0 +1,27 @@
|
||||
import strawberry
|
||||
import strawberry_django as sd
|
||||
from typing import List, Optional, Union
|
||||
from core.graphql.types.profile import CustomerProfileType, TeamProfileType
|
||||
from core.graphql.filters.profile import CustomerProfileFilter
|
||||
from strawberry.types import Info
|
||||
|
||||
@strawberry.type
|
||||
class Query:
|
||||
customer_profile: Optional[CustomerProfileType] = sd.node()
|
||||
customer_profiles: List[CustomerProfileType] = sd.field(
|
||||
filters=CustomerProfileFilter
|
||||
)
|
||||
|
||||
team_profile: Optional[TeamProfileType] = sd.node()
|
||||
team_profiles: List[TeamProfileType] = sd.field()
|
||||
|
||||
@strawberry.field(description="Get the currently authenticated user's profile")
|
||||
def me(self, info: Info) -> Optional[Union[CustomerProfileType, TeamProfileType]]:
|
||||
"""
|
||||
Returns the current user's Django profile (Team or Customer).
|
||||
Profile is set by OryHeaderAuthenticationMiddleware from Oathkeeper headers.
|
||||
"""
|
||||
profile = getattr(info.context.request, 'profile', None)
|
||||
if not profile:
|
||||
return None
|
||||
return profile
|
||||
35
core/graphql/queries/project.py
Normal file
35
core/graphql/queries/project.py
Normal file
@ -0,0 +1,35 @@
|
||||
from typing import List, Optional, Iterable
|
||||
import strawberry
|
||||
import strawberry_django as sd
|
||||
from strawberry import ID
|
||||
from strawberry_django.relay import DjangoCursorConnection
|
||||
from core.graphql.filters.project import ProjectFilter
|
||||
from core.graphql.types.project import ProjectType
|
||||
from core.graphql.enums import DateOrdering
|
||||
from core.models.project import Project
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Query:
|
||||
project: Optional[ProjectType] = sd.node()
|
||||
projects: List[ProjectType] = sd.field(filters=ProjectFilter)
|
||||
|
||||
@sd.connection(
|
||||
DjangoCursorConnection["ProjectType"],
|
||||
name="getProjectsByTeamMember",
|
||||
description="Return projects that include the given TeamProfile ID as a team member",
|
||||
filters=ProjectFilter,
|
||||
)
|
||||
def get_projects_by_team_member(
|
||||
self,
|
||||
team_profile_id: ID,
|
||||
ordering: Optional[DateOrdering] = DateOrdering.DESC,
|
||||
) -> Iterable["Project"]:
|
||||
order_prefix = "" if ordering == DateOrdering.ASC else "-"
|
||||
return (
|
||||
Project.objects
|
||||
.filter(team_members__id=team_profile_id)
|
||||
.select_related('account_address', 'account_address__account', 'customer')
|
||||
.prefetch_related('team_members')
|
||||
.order_by(f"{order_prefix}date", f"{order_prefix}id")
|
||||
)
|
||||
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