public-ready-init

This commit is contained in:
Damien Coles 2026-01-26 11:09:40 -05:00
commit fd5d81304e
244 changed files with 28322 additions and 0 deletions

49
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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}')

View 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
View 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
View 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
View 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
View 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
View File

645
core/admin.py Normal file
View 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
View 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
View File

@ -0,0 +1 @@
# Chat module for AI assistant integration

261
core/chat/consumers.py Normal file
View 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
View 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
View 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
View 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"

View 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 *

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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)

View 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

View 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

View 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

View 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

View 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)

View 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

View 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)

View 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)

View 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)

View 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)

View 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 *

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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 *

View 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

View 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

View 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

View 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()

View 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

View 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

View 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

View 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

View 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

View 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

View 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)

View 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)

View 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

View 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

View 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

View 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

View 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)

View 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 (FriSun)"
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)

View 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

View 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

View 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

View 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
View 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()

View 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 *

View 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)

View 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
)

View 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)

View 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)

View 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

View 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)

View 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)

View 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')

View 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

View 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