clean up version
This commit is contained in:
parent
bcb9c86541
commit
e119312a9c
@ -1,64 +1,56 @@
|
|||||||
# Python
|
.git
|
||||||
|
.gitignore
|
||||||
|
.opencode/
|
||||||
|
.qwen/
|
||||||
|
.ruff_cache/
|
||||||
|
.venv/
|
||||||
|
node_modules/
|
||||||
|
e2e/
|
||||||
|
playwright.config.ts
|
||||||
|
tsconfig.e2e.json
|
||||||
|
|
||||||
__pycache__
|
__pycache__
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*.pyo
|
||||||
*.so
|
|
||||||
.Python
|
|
||||||
env/
|
|
||||||
venv/
|
|
||||||
.venv/
|
|
||||||
ENV/
|
|
||||||
build/
|
|
||||||
develop-eggs/
|
|
||||||
dist/
|
|
||||||
downloads/
|
|
||||||
eggs/
|
|
||||||
.eggs/
|
|
||||||
lib/
|
|
||||||
lib64/
|
|
||||||
parts/
|
|
||||||
sdist/
|
|
||||||
var/
|
|
||||||
wheels/
|
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.installed.cfg
|
|
||||||
*.egg
|
*.egg
|
||||||
|
|
||||||
# Django
|
|
||||||
*.log
|
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
db.sqlite3-journal
|
db.sqlite3-journal
|
||||||
media/
|
|
||||||
staticfiles/
|
staticfiles/
|
||||||
|
media/
|
||||||
|
logs/
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.staging
|
||||||
|
.env.staging.example
|
||||||
|
.env.production
|
||||||
|
.env.production.example
|
||||||
|
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
# IDE
|
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Git
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
|
|
||||||
# Docker
|
|
||||||
Dockerfile
|
|
||||||
docker-compose.yml
|
|
||||||
.dockerignore
|
|
||||||
|
|
||||||
# Documentation
|
|
||||||
README.md
|
|
||||||
docs/
|
docs/
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
|
||||||
# Tests
|
docker-compose.yml
|
||||||
.pytest_cache/
|
docker-compose.staging.yml
|
||||||
.coverage
|
docker-compose.prod.yml
|
||||||
htmlcov/
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
# Environment
|
Caddyfile
|
||||||
.env.example
|
Caddyfile.prod
|
||||||
|
deploy.staging.sh
|
||||||
|
deploy.prod.sh
|
||||||
|
build-and-push.sh
|
||||||
|
|||||||
100
.env.production.example
Normal file
100
.env.production.example
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
# ============================================================
|
||||||
|
# PX360 Production Environment Configuration
|
||||||
|
# ============================================================
|
||||||
|
# Copy this file to .env.production and fill in the values.
|
||||||
|
# NEVER commit .env.production to version control.
|
||||||
|
# This is for the production server with external PostgreSQL.
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# --- Django ---
|
||||||
|
DJANGO_SETTINGS_MODULE=config.settings.prod
|
||||||
|
DEBUG=False
|
||||||
|
SECRET_KEY=CHANGE-ME-generate-with-python3-c-from-django.core.management.utils-import-get_random_secret_key-print-get_random_secret_key
|
||||||
|
ALLOWED_HOSTS=your-production-domain.com
|
||||||
|
ADMIN_URL=CHANGE-ME-use-a-non-obvious-url/
|
||||||
|
|
||||||
|
# --- PostgreSQL (External Server) ---
|
||||||
|
DATABASE_URL=postgresql://px360:CHANGE-ME@your-db-host:5432/px360
|
||||||
|
DB_HOST=your-db-host
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USER=px360
|
||||||
|
|
||||||
|
# --- Celery ---
|
||||||
|
CELERY_BROKER_URL=redis://redis:6379/0
|
||||||
|
CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||||
|
CELERY_TASK_ALWAYS_EAGER=False
|
||||||
|
|
||||||
|
# --- Email ---
|
||||||
|
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
|
||||||
|
EMAIL_HOST=smtp.gmail.com
|
||||||
|
EMAIL_PORT=587
|
||||||
|
EMAIL_USE_TLS=True
|
||||||
|
EMAIL_HOST_USER=
|
||||||
|
EMAIL_HOST_PASSWORD=
|
||||||
|
DEFAULT_FROM_EMAIL=noreply@px360.sa
|
||||||
|
|
||||||
|
# --- AI Configuration ---
|
||||||
|
OPENROUTER_API_KEY=
|
||||||
|
AI_MODEL=z-ai/glm-4.5-air:free
|
||||||
|
AI_TEMPERATURE=0.3
|
||||||
|
AI_MAX_TOKENS=500
|
||||||
|
|
||||||
|
# --- Notifications ---
|
||||||
|
SMS_ENABLED=False
|
||||||
|
SMS_PROVIDER=console
|
||||||
|
WHATSAPP_ENABLED=False
|
||||||
|
WHATSAPP_PROVIDER=console
|
||||||
|
EMAIL_ENABLED=True
|
||||||
|
EMAIL_PROVIDER=console
|
||||||
|
|
||||||
|
# --- Twilio ---
|
||||||
|
TWILIO_ACCOUNT_SID=
|
||||||
|
TWILIO_AUTH_TOKEN=
|
||||||
|
TWILIO_PHONE_NUMBER=
|
||||||
|
TWILIO_MESSAGING_SERVICE_SID=
|
||||||
|
|
||||||
|
# --- External API Notifications ---
|
||||||
|
EMAIL_API_ENABLED=False
|
||||||
|
EMAIL_API_URL=
|
||||||
|
EMAIL_API_KEY=
|
||||||
|
SMS_API_ENABLED=False
|
||||||
|
SMS_API_URL=
|
||||||
|
SMS_API_KEY=
|
||||||
|
|
||||||
|
# --- Mshastra SMS ---
|
||||||
|
MSHASTRA_USERNAME=
|
||||||
|
MSHASTRA_PASSWORD=
|
||||||
|
MSHASTRA_SENDER_ID=
|
||||||
|
|
||||||
|
# --- HIS API ---
|
||||||
|
HIS_API_URL=https://his.alhammadi.med.sa/SSRCE/API/FetchPatientVisitTimeStamps
|
||||||
|
HIS_API_USERNAME=
|
||||||
|
HIS_API_PASSWORD=
|
||||||
|
HIS_API_KEY=
|
||||||
|
|
||||||
|
# --- Integration APIs ---
|
||||||
|
MOH_API_URL=
|
||||||
|
MOH_API_KEY=
|
||||||
|
CHI_API_URL=
|
||||||
|
CHI_API_KEY=
|
||||||
|
|
||||||
|
# --- Social Media ---
|
||||||
|
YOUTUBE_API_KEY=
|
||||||
|
YOUTUBE_CHANNEL_ID=
|
||||||
|
FACEBOOK_PAGE_ID=
|
||||||
|
FACEBOOK_ACCESS_TOKEN=
|
||||||
|
INSTAGRAM_ACCOUNT_ID=
|
||||||
|
INSTAGRAM_ACCESS_TOKEN=
|
||||||
|
TWITTER_BEARER_TOKEN=
|
||||||
|
TWITTER_USERNAME=
|
||||||
|
LINKEDIN_ACCESS_TOKEN=
|
||||||
|
LINKEDIN_ORGANIZATION_ID=
|
||||||
|
GOOGLE_CREDENTIALS_FILE=client_secret.json
|
||||||
|
GOOGLE_TOKEN_FILE=token.json
|
||||||
|
GOOGLE_LOCATIONS=
|
||||||
|
|
||||||
|
# --- OpenRouter ---
|
||||||
|
OPENROUTER_API_KEY=
|
||||||
|
OPENROUTER_MODEL=anthropic/claude-3-haiku
|
||||||
|
ANALYSIS_BATCH_SIZE=10
|
||||||
|
ANALYSIS_ENABLED=True
|
||||||
99
.env.staging.example
Normal file
99
.env.staging.example
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
# ============================================================
|
||||||
|
# PX360 Staging Environment Configuration
|
||||||
|
# ============================================================
|
||||||
|
# Copy this file to .env.staging and fill in the values.
|
||||||
|
# NEVER commit .env.staging to version control.
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# --- Django ---
|
||||||
|
DJANGO_SETTINGS_MODULE=config.settings.prod
|
||||||
|
DEBUG=False
|
||||||
|
SECRET_KEY=CHANGE-ME-generate-with-python3-c-from-django.core.management.utils-import-get_random_secret_key-print-get_random_secret_key
|
||||||
|
ALLOWED_HOSTS=px360test.tenhal.sa
|
||||||
|
ADMIN_URL=admin/
|
||||||
|
|
||||||
|
# --- PostgreSQL ---
|
||||||
|
POSTGRES_DB=px360
|
||||||
|
POSTGRES_USER=px360
|
||||||
|
POSTGRES_PASSWORD=CHANGE-ME-use-openssl-rand-base64-32
|
||||||
|
DATABASE_URL=postgresql://px360:CHANGE-ME@db:5432/px360
|
||||||
|
|
||||||
|
# --- Celery ---
|
||||||
|
CELERY_BROKER_URL=redis://redis:6379/0
|
||||||
|
CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||||
|
CELERY_TASK_ALWAYS_EAGER=False
|
||||||
|
|
||||||
|
# --- Email ---
|
||||||
|
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
|
||||||
|
EMAIL_HOST=smtp.gmail.com
|
||||||
|
EMAIL_PORT=587
|
||||||
|
EMAIL_USE_TLS=True
|
||||||
|
EMAIL_HOST_USER=
|
||||||
|
EMAIL_HOST_PASSWORD=
|
||||||
|
DEFAULT_FROM_EMAIL=noreply@px360.sa
|
||||||
|
|
||||||
|
# --- AI Configuration ---
|
||||||
|
OPENROUTER_API_KEY=
|
||||||
|
AI_MODEL=z-ai/glm-4.5-air:free
|
||||||
|
AI_TEMPERATURE=0.3
|
||||||
|
AI_MAX_TOKENS=500
|
||||||
|
|
||||||
|
# --- Notifications ---
|
||||||
|
SMS_ENABLED=False
|
||||||
|
SMS_PROVIDER=console
|
||||||
|
WHATSAPP_ENABLED=False
|
||||||
|
WHATSAPP_PROVIDER=console
|
||||||
|
EMAIL_ENABLED=True
|
||||||
|
EMAIL_PROVIDER=console
|
||||||
|
|
||||||
|
# --- Twilio ---
|
||||||
|
TWILIO_ACCOUNT_SID=
|
||||||
|
TWILIO_AUTH_TOKEN=
|
||||||
|
TWILIO_PHONE_NUMBER=
|
||||||
|
TWILIO_MESSAGING_SERVICE_SID=
|
||||||
|
|
||||||
|
# --- External API Notifications ---
|
||||||
|
EMAIL_API_ENABLED=False
|
||||||
|
EMAIL_API_URL=
|
||||||
|
EMAIL_API_KEY=
|
||||||
|
SMS_API_ENABLED=False
|
||||||
|
SMS_API_URL=
|
||||||
|
SMS_API_KEY=
|
||||||
|
|
||||||
|
# --- Mshastra SMS ---
|
||||||
|
MSHASTRA_USERNAME=
|
||||||
|
MSHASTRA_PASSWORD=
|
||||||
|
MSHASTRA_SENDER_ID=
|
||||||
|
|
||||||
|
# --- HIS API ---
|
||||||
|
HIS_API_URL=https://his.alhammadi.med.sa/SSRCE/API/FetchPatientVisitTimeStamps
|
||||||
|
HIS_API_USERNAME=
|
||||||
|
HIS_API_PASSWORD=
|
||||||
|
HIS_API_KEY=
|
||||||
|
|
||||||
|
# --- Integration APIs ---
|
||||||
|
MOH_API_URL=
|
||||||
|
MOH_API_KEY=
|
||||||
|
CHI_API_URL=
|
||||||
|
CHI_API_KEY=
|
||||||
|
|
||||||
|
# --- Social Media ---
|
||||||
|
YOUTUBE_API_KEY=
|
||||||
|
YOUTUBE_CHANNEL_ID=
|
||||||
|
FACEBOOK_PAGE_ID=
|
||||||
|
FACEBOOK_ACCESS_TOKEN=
|
||||||
|
INSTAGRAM_ACCOUNT_ID=
|
||||||
|
INSTAGRAM_ACCESS_TOKEN=
|
||||||
|
TWITTER_BEARER_TOKEN=
|
||||||
|
TWITTER_USERNAME=
|
||||||
|
LINKEDIN_ACCESS_TOKEN=
|
||||||
|
LINKEDIN_ORGANIZATION_ID=
|
||||||
|
GOOGLE_CREDENTIALS_FILE=client_secret.json
|
||||||
|
GOOGLE_TOKEN_FILE=token.json
|
||||||
|
GOOGLE_LOCATIONS=
|
||||||
|
|
||||||
|
# --- OpenRouter ---
|
||||||
|
OPENROUTER_API_KEY=
|
||||||
|
OPENROUTER_MODEL=anthropic/claude-3-haiku
|
||||||
|
ANALYSIS_BATCH_SIZE=10
|
||||||
|
ANALYSIS_ENABLED=True
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -39,6 +39,8 @@ logs/
|
|||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
.env.staging
|
||||||
|
.env.production
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
115
.opencode/package-lock.json
generated
Normal file
115
.opencode/package-lock.json
generated
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
{
|
||||||
|
"name": ".opencode",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"@opencode-ai/plugin": "1.4.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@opencode-ai/plugin": {
|
||||||
|
"version": "1.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.4.3.tgz",
|
||||||
|
"integrity": "sha512-Ob/3tVSIeuMRJBr2O23RtrnC5djRe01Lglx+TwGEmjrH9yDBJ2tftegYLnNEjRoMuzITgq9LD8168p4pzv+U/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@opencode-ai/sdk": "1.4.3",
|
||||||
|
"zod": "4.1.8"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@opentui/core": ">=0.1.97",
|
||||||
|
"@opentui/solid": ">=0.1.97"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@opentui/core": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@opentui/solid": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@opencode-ai/sdk": {
|
||||||
|
"version": "1.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.4.3.tgz",
|
||||||
|
"integrity": "sha512-X0CAVbwoGAjTY2iecpWkx2B+GAa2jSaQKYpJ+xILopeF/OGKZUN15mjqci+L7cEuwLHV5wk3x2TStUOVCa5p0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cross-spawn": "7.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cross-spawn": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"path-key": "^3.1.0",
|
||||||
|
"shebang-command": "^2.0.0",
|
||||||
|
"which": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/isexe": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/path-key": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shebang-command": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"shebang-regex": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shebang-regex": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/which": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"isexe": "^2.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-which": "bin/node-which"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zod": {
|
||||||
|
"version": "4.1.8",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
Caddyfile
Normal file
34
Caddyfile
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
px360test.tenhal.sa {
|
||||||
|
encode gzip
|
||||||
|
|
||||||
|
handle_path /static/* {
|
||||||
|
root * /srv/static
|
||||||
|
file_server {
|
||||||
|
precompressed br gzip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_path /media/* {
|
||||||
|
root * /srv/media
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
reverse_proxy web:8000
|
||||||
|
}
|
||||||
|
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/access.log {
|
||||||
|
roll_size 10mb
|
||||||
|
roll_keep 5
|
||||||
|
}
|
||||||
|
format json
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
X-Content-Type-Options nosniff
|
||||||
|
X-Frame-Options DENY
|
||||||
|
X-XSS-Protection "1; mode=block"
|
||||||
|
Referrer-Policy strict-origin-when-cross-origin
|
||||||
|
}
|
||||||
|
}
|
||||||
35
Caddyfile.prod
Normal file
35
Caddyfile.prod
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
your-production-domain.com {
|
||||||
|
encode gzip
|
||||||
|
|
||||||
|
handle_path /static/* {
|
||||||
|
root * /srv/static
|
||||||
|
file_server {
|
||||||
|
precompressed br gzip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_path /media/* {
|
||||||
|
root * /srv/media
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
reverse_proxy web:8000
|
||||||
|
}
|
||||||
|
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/access.log {
|
||||||
|
roll_size 10mb
|
||||||
|
roll_keep 10
|
||||||
|
}
|
||||||
|
format json
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
X-Content-Type-Options nosniff
|
||||||
|
X-Frame-Options DENY
|
||||||
|
X-XSS-Protection "1; mode=block"
|
||||||
|
Referrer-Policy strict-origin-when-cross-origin
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
}
|
||||||
|
}
|
||||||
53
Dockerfile
53
Dockerfile
@ -1,42 +1,59 @@
|
|||||||
# Use Python 3.12 slim image
|
FROM python:3.12-slim AS builder
|
||||||
FROM python:3.12-slim
|
|
||||||
|
|
||||||
# Set environment variables
|
|
||||||
ENV PYTHONUNBUFFERED=1 \
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
PYTHONDONTWRITEBYTECODE=1 \
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PIP_NO_CACHE_DIR=1 \
|
PIP_NO_CACHE_DIR=1 \
|
||||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||||
|
|
||||||
# Set work directory
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
postgresql-client \
|
|
||||||
gcc \
|
gcc \
|
||||||
python3-dev \
|
python3-dev \
|
||||||
musl-dev \
|
musl-dev \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy requirements
|
|
||||||
COPY pyproject.toml ./
|
COPY pyproject.toml ./
|
||||||
|
|
||||||
# Install Python dependencies
|
|
||||||
RUN pip install --upgrade pip setuptools wheel && \
|
RUN pip install --upgrade pip setuptools wheel && \
|
||||||
pip install -e ".[dev]"
|
pip install -e "."
|
||||||
|
|
||||||
# Copy project
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Create necessary directories
|
|
||||||
RUN mkdir -p logs media staticfiles
|
|
||||||
|
|
||||||
# Collect static files
|
|
||||||
RUN python manage.py collectstatic --noinput || true
|
RUN python manage.py collectstatic --noinput || true
|
||||||
|
|
||||||
# Expose port
|
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
DJANGO_SETTINGS_MODULE=config.settings.prod
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libpq5 \
|
||||||
|
postgresql-client \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN groupadd -r appuser && useradd -r -g appuser appuser
|
||||||
|
|
||||||
|
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
||||||
|
COPY --from=builder /usr/local/bin /usr/local/bin
|
||||||
|
COPY --from=builder /app /app
|
||||||
|
|
||||||
|
RUN mkdir -p logs media staticfiles && \
|
||||||
|
chown -R appuser:appuser /app
|
||||||
|
|
||||||
|
COPY entrypoint.prod.sh /app/entrypoint.prod.sh
|
||||||
|
RUN chmod +x /app/entrypoint.prod.sh
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Default command
|
ENTRYPOINT ["/app/entrypoint.prod.sh"]
|
||||||
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]
|
|
||||||
|
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "120"]
|
||||||
|
|||||||
@ -156,7 +156,7 @@ YOUTUBE_REDIRECT_URI = "http://127.0.0.1:8000/social/callback/YT/"
|
|||||||
# Ensure you have your client_secrets.json file at this location
|
# Ensure you have your client_secrets.json file at this location
|
||||||
GMB_CLIENT_SECRETS_FILE = BASE_DIR / "secrets" / "gmb_client_secrets.json"
|
GMB_CLIENT_SECRETS_FILE = BASE_DIR / "secrets" / "gmb_client_secrets.json"
|
||||||
GMB_REDIRECT_URI = "http://127.0.0.1:8000/social/callback/GO/"
|
GMB_REDIRECT_URI = "http://127.0.0.1:8000/social/callback/GO/"
|
||||||
|
m
|
||||||
|
|
||||||
# Data upload settings
|
# Data upload settings
|
||||||
# Increased limit to support bulk patient imports from HIS
|
# Increased limit to support bulk patient imports from HIS
|
||||||
|
|||||||
@ -256,7 +256,7 @@ celery -A config beat -l info
|
|||||||
- PX Admin: Full system access
|
- PX Admin: Full system access
|
||||||
- Hospital Admin: Hospital-level access
|
- Hospital Admin: Hospital-level access
|
||||||
- Department Manager: Department-level access
|
- Department Manager: Department-level access
|
||||||
- PX Coordinator: Action management
|
- PX Staff: Action management
|
||||||
- Physician/Nurse/Staff: Limited access
|
- Physician/Nurse/Staff: Limited access
|
||||||
- Viewer: Read-only access
|
- Viewer: Read-only access
|
||||||
|
|
||||||
|
|||||||
79
analyze_complaint_sources.py
Normal file
79
analyze_complaint_sources.py
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Analyze complaint source values from 'جهة الشكوى' column across all years.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import os
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
years = [2022, 2023, 2024, 2025]
|
||||||
|
|
||||||
|
# per-year data
|
||||||
|
year_data = {year: defaultdict(int) for year in years}
|
||||||
|
# overall data
|
||||||
|
all_data = defaultdict(lambda: {"total_count": 0, "years": set(), "sheets": set()})
|
||||||
|
|
||||||
|
for year in years:
|
||||||
|
file_path = f"data/Complaints Report - {year}.xlsx"
|
||||||
|
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
print(f"❌ File not found: {file_path}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"📊 Processing {year}: {file_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
xls = pd.ExcelFile(file_path)
|
||||||
|
print(f" Sheets: {xls.sheet_names}")
|
||||||
|
|
||||||
|
for sheet_name in xls.sheet_names:
|
||||||
|
try:
|
||||||
|
df = pd.read_excel(file_path, sheet_name=sheet_name)
|
||||||
|
print(f" Sheet '{sheet_name}': {len(df)} rows, columns: {list(df.columns)}")
|
||||||
|
|
||||||
|
if "جهة الشكوى" in df.columns:
|
||||||
|
value_counts = df["جهة الشكوى"].value_counts(dropna=False)
|
||||||
|
|
||||||
|
for value, count in value_counts.items():
|
||||||
|
value_str = str(value) if pd.notna(value) else "(NULL/Empty)"
|
||||||
|
year_data[year][value_str] += count
|
||||||
|
all_data[value_str]["total_count"] += count
|
||||||
|
all_data[value_str]["years"].add(year)
|
||||||
|
all_data[value_str]["sheets"].add(f"{year}/{sheet_name}")
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ No 'جهة الشكوى' column")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ Error: {e}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error: {e}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("=" * 100)
|
||||||
|
print("PER-YEAR BREAKDOWN")
|
||||||
|
print("=" * 100)
|
||||||
|
for year in years:
|
||||||
|
print(
|
||||||
|
f"\n--- Year {year} ({len(year_data[year])} unique sources, {sum(year_data[year].values())} total complaints) ---"
|
||||||
|
)
|
||||||
|
sorted_year = sorted(year_data[year].items(), key=lambda x: x[1], reverse=True)
|
||||||
|
for value, count in sorted_year:
|
||||||
|
print(f" {count:>6} | {value}")
|
||||||
|
|
||||||
|
print("\n")
|
||||||
|
print("=" * 100)
|
||||||
|
print("CONSOLIDATED - ALL UNIQUE VALUES ACROSS ALL YEARS")
|
||||||
|
print("=" * 100)
|
||||||
|
print(f"{'Total':<8} {'Value':<50} {'Appears In':<30}")
|
||||||
|
print("-" * 100)
|
||||||
|
|
||||||
|
sorted_all = sorted(all_data.items(), key=lambda x: x[1]["total_count"], reverse=True)
|
||||||
|
for value, data in sorted_all:
|
||||||
|
years_str = ", ".join(sorted([str(y) for y in data["years"]]))
|
||||||
|
print(f"{data['total_count']:<8} {value:<50} {years_str:<30}")
|
||||||
|
|
||||||
|
print(f"\n{'=' * 100}")
|
||||||
|
print(f"SUMMARY: {len(sorted_all)} unique values across all years")
|
||||||
|
print(f"{'=' * 100}")
|
||||||
@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Management command to create default roles and groups for PX360.
|
Management command to create default roles and groups for PX360.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.contrib.auth.models import Group, Permission
|
from django.contrib.auth.models import Group, Permission
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
@ -9,124 +10,116 @@ from apps.accounts.models import Role
|
|||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = 'Create default roles and groups for PX360 system'
|
help = "Create default roles and groups for PX360 system"
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
"""Create default roles"""
|
"""Create default roles"""
|
||||||
|
|
||||||
roles_config = [
|
roles_config = [
|
||||||
{
|
{
|
||||||
'name': 'px_admin',
|
"name": "px_admin",
|
||||||
'display_name': 'PX Admin',
|
"display_name": "PX Admin",
|
||||||
'description': 'Full system access. Can manage all hospitals, departments, and configurations.',
|
"description": "Full system access. Can manage all hospitals, departments, and configurations.",
|
||||||
'level': 100,
|
"level": 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'hospital_admin',
|
"name": "hospital_admin",
|
||||||
'display_name': 'Hospital Admin',
|
"display_name": "Hospital Admin",
|
||||||
'description': 'Hospital-level access. Can manage their hospital and its departments.',
|
"description": "Hospital-level access. Can manage their hospital and its departments.",
|
||||||
'level': 80,
|
"level": 80,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'department_manager',
|
"name": "department_manager",
|
||||||
'display_name': 'Department Manager',
|
"display_name": "Department Manager",
|
||||||
'description': 'Department-level access. Can manage their department.',
|
"description": "Department-level access. Can manage their department.",
|
||||||
'level': 60,
|
"level": 60,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'px_coordinator',
|
"name": "px_staff",
|
||||||
'display_name': 'PX Coordinator',
|
"display_name": "PX Staff",
|
||||||
'description': 'Can manage PX actions, complaints, and surveys.',
|
"description": "Can manage PX actions, complaints, and surveys.",
|
||||||
'level': 50,
|
"level": 50,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'physician',
|
"name": "physician",
|
||||||
'display_name': 'Physician',
|
"display_name": "Physician",
|
||||||
'description': 'Can view patient feedback and their own ratings.',
|
"description": "Can view patient feedback and their own ratings.",
|
||||||
'level': 40,
|
"level": 40,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'nurse',
|
"name": "nurse",
|
||||||
'display_name': 'Nurse',
|
"display_name": "Nurse",
|
||||||
'description': 'Can view department feedback.',
|
"description": "Can view department feedback.",
|
||||||
'level': 30,
|
"level": 30,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'staff',
|
"name": "staff",
|
||||||
'display_name': 'Staff',
|
"display_name": "Staff",
|
||||||
'description': 'Basic staff access.',
|
"description": "Basic staff access.",
|
||||||
'level': 20,
|
"level": 20,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'viewer',
|
"name": "viewer",
|
||||||
'display_name': 'Viewer',
|
"display_name": "Viewer",
|
||||||
'description': 'Read-only access to reports and dashboards.',
|
"description": "Read-only access to reports and dashboards.",
|
||||||
'level': 10,
|
"level": 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'px_source_user',
|
"name": "executive",
|
||||||
'display_name': 'PX Source User',
|
"display_name": "Executive",
|
||||||
'description': 'External source users who can create complaints and inquiries from their assigned source. Limited access to their own created data only.',
|
"description": "C-Suite and top management. System-wide analytics, executive summaries, and predictive insights access.",
|
||||||
'level': 5,
|
"level": 90,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "px_source_user",
|
||||||
|
"display_name": "PX Source User",
|
||||||
|
"description": "External source users who can create complaints and inquiries from their assigned source. Limited access to their own created data only.",
|
||||||
|
"level": 5,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
created_count = 0
|
created_count = 0
|
||||||
updated_count = 0
|
updated_count = 0
|
||||||
|
|
||||||
for role_data in roles_config:
|
for role_data in roles_config:
|
||||||
# Get or create group
|
# Get or create group
|
||||||
group, group_created = Group.objects.get_or_create(
|
group, group_created = Group.objects.get_or_create(name=role_data["display_name"])
|
||||||
name=role_data['display_name']
|
|
||||||
)
|
|
||||||
|
|
||||||
if group_created:
|
if group_created:
|
||||||
self.stdout.write(
|
self.stdout.write(self.style.SUCCESS(f"Created group: {group.name}"))
|
||||||
self.style.SUCCESS(f"Created group: {group.name}")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get or create role
|
# Get or create role
|
||||||
role, role_created = Role.objects.get_or_create(
|
role, role_created = Role.objects.get_or_create(
|
||||||
name=role_data['name'],
|
name=role_data["name"],
|
||||||
defaults={
|
defaults={
|
||||||
'display_name': role_data['display_name'],
|
"display_name": role_data["display_name"],
|
||||||
'description': role_data['description'],
|
"description": role_data["description"],
|
||||||
'group': group,
|
"group": group,
|
||||||
'level': role_data['level'],
|
"level": role_data["level"],
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if role_created:
|
if role_created:
|
||||||
created_count += 1
|
created_count += 1
|
||||||
self.stdout.write(
|
self.stdout.write(self.style.SUCCESS(f"✓ Created role: {role.display_name} (level {role.level})"))
|
||||||
self.style.SUCCESS(f"✓ Created role: {role.display_name} (level {role.level})")
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# Update existing role
|
# Update existing role
|
||||||
role.display_name = role_data['display_name']
|
role.display_name = role_data["display_name"]
|
||||||
role.description = role_data['description']
|
role.description = role_data["description"]
|
||||||
role.level = role_data['level']
|
role.level = role_data["level"]
|
||||||
role.group = group
|
role.group = group
|
||||||
role.save()
|
role.save()
|
||||||
updated_count += 1
|
updated_count += 1
|
||||||
self.stdout.write(
|
self.stdout.write(self.style.WARNING(f"↻ Updated role: {role.display_name}"))
|
||||||
self.style.WARNING(f"↻ Updated role: {role.display_name}")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Assign permissions based on role level
|
# Assign permissions based on role level
|
||||||
self._assign_permissions(role, group)
|
self._assign_permissions(role, group)
|
||||||
|
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
self.style.SUCCESS(
|
self.style.SUCCESS(f"\n✓ Roles setup complete: {created_count} created, {updated_count} updated")
|
||||||
f"\n✓ Roles setup complete: {created_count} created, {updated_count} updated"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
self.stdout.write(
|
self.stdout.write(self.style.SUCCESS(f"Total roles: {Role.objects.count()}"))
|
||||||
self.style.SUCCESS(
|
|
||||||
f"Total roles: {Role.objects.count()}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _assign_permissions(self, role, group):
|
def _assign_permissions(self, role, group):
|
||||||
"""
|
"""
|
||||||
Assign permissions to group based on role level.
|
Assign permissions to group based on role level.
|
||||||
@ -134,53 +127,61 @@ class Command(BaseCommand):
|
|||||||
"""
|
"""
|
||||||
# Clear existing permissions
|
# Clear existing permissions
|
||||||
group.permissions.clear()
|
group.permissions.clear()
|
||||||
|
|
||||||
# Get all permissions
|
# Get all permissions
|
||||||
all_permissions = Permission.objects.all()
|
all_permissions = Permission.objects.all()
|
||||||
|
|
||||||
# PX Admin gets all permissions
|
# PX Admin gets all permissions
|
||||||
if role.name == 'px_admin':
|
if role.name == "px_admin":
|
||||||
group.permissions.set(all_permissions)
|
group.permissions.set(all_permissions)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Hospital Admin gets most permissions except user management
|
# Hospital Admin gets most permissions except user management
|
||||||
if role.name == 'hospital_admin':
|
if role.name == "hospital_admin":
|
||||||
permissions = Permission.objects.exclude(
|
permissions = Permission.objects.exclude(
|
||||||
content_type__app_label='auth',
|
content_type__app_label="auth", codename__in=["add_user", "delete_user", "change_user"]
|
||||||
codename__in=['add_user', 'delete_user', 'change_user']
|
|
||||||
)
|
)
|
||||||
group.permissions.set(permissions)
|
group.permissions.set(permissions)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Department Manager gets department-level permissions
|
# Department Manager gets department-level permissions
|
||||||
if role.name == 'department_manager':
|
if role.name == "department_manager":
|
||||||
# Add view permissions for most models
|
# Add view permissions for most models
|
||||||
view_permissions = Permission.objects.filter(
|
view_permissions = Permission.objects.filter(codename__startswith="view_")
|
||||||
codename__startswith='view_'
|
|
||||||
)
|
|
||||||
group.permissions.set(view_permissions)
|
group.permissions.set(view_permissions)
|
||||||
return
|
return
|
||||||
|
|
||||||
# PX Coordinator gets complaint and action permissions
|
# PX Staff gets complaint and action permissions
|
||||||
if role.name == 'px_coordinator':
|
if role.name == "px_staff":
|
||||||
coordinator_permissions = Permission.objects.filter(
|
staff_permissions = Permission.objects.filter(
|
||||||
content_type__app_label__in=['complaints', 'px_action_center', 'surveys']
|
content_type__app_label__in=["complaints", "px_action_center", "surveys"]
|
||||||
)
|
)
|
||||||
group.permissions.set(coordinator_permissions)
|
group.permissions.set(staff_permissions)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Executive gets view permissions system-wide + executive_summary access
|
||||||
|
if role.name == "executive":
|
||||||
|
view_permissions = Permission.objects.filter(codename__startswith="view_")
|
||||||
|
exec_perms = Permission.objects.filter(content_type__app_label="executive_summary")
|
||||||
|
group.permissions.set(list(view_permissions) + list(exec_perms))
|
||||||
|
return
|
||||||
|
|
||||||
# PX Source User gets limited complaint/inquiry permissions
|
# PX Source User gets limited complaint/inquiry permissions
|
||||||
if role.name == 'px_source_user':
|
if role.name == "px_source_user":
|
||||||
source_user_perms = Permission.objects.filter(
|
source_user_perms = Permission.objects.filter(
|
||||||
content_type__app_label__in=['complaints'],
|
content_type__app_label__in=["complaints"],
|
||||||
codename__in=['add_complaint', 'view_complaint', 'change_complaint',
|
codename__in=[
|
||||||
'add_inquiry', 'view_inquiry', 'change_inquiry']
|
"add_complaint",
|
||||||
|
"view_complaint",
|
||||||
|
"change_complaint",
|
||||||
|
"add_inquiry",
|
||||||
|
"view_inquiry",
|
||||||
|
"change_inquiry",
|
||||||
|
],
|
||||||
)
|
)
|
||||||
group.permissions.set(source_user_perms)
|
group.permissions.set(source_user_perms)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Others get basic view permissions
|
# Others get basic view permissions
|
||||||
view_permissions = Permission.objects.filter(
|
view_permissions = Permission.objects.filter(codename__startswith="view_")
|
||||||
codename__startswith='view_'
|
|
||||||
)
|
|
||||||
group.permissions.set(view_permissions)
|
group.permissions.set(view_permissions)
|
||||||
|
|||||||
@ -153,14 +153,18 @@ class User(AbstractUser, TimeStampedModel):
|
|||||||
"""Check if user is Department Manager"""
|
"""Check if user is Department Manager"""
|
||||||
return self.has_role("Department Manager")
|
return self.has_role("Department Manager")
|
||||||
|
|
||||||
def is_px_coordinator(self):
|
def is_px_staff(self):
|
||||||
"""Check if user is PX Coordinator"""
|
"""Check if user is PX Staff"""
|
||||||
return self.has_role("PX Coordinator")
|
return self.has_role("PX Staff")
|
||||||
|
|
||||||
def is_source_user(self):
|
def is_source_user(self):
|
||||||
"""Check if user is a PX Source User"""
|
"""Check if user is a PX Source User"""
|
||||||
return self.has_role("PX Source User")
|
return self.has_role("PX Source User")
|
||||||
|
|
||||||
|
def is_executive(self):
|
||||||
|
"""Check if user is Executive (C-Suite/Top Management)"""
|
||||||
|
return self.has_role("Executive")
|
||||||
|
|
||||||
def get_source_user_profile_active(self):
|
def get_source_user_profile_active(self):
|
||||||
"""Get active source user profile if exists"""
|
"""Get active source user profile if exists"""
|
||||||
if hasattr(self, "source_user_profile"):
|
if hasattr(self, "source_user_profile"):
|
||||||
@ -451,11 +455,12 @@ class Role(models.Model):
|
|||||||
("px_admin", _("PX Admin")),
|
("px_admin", _("PX Admin")),
|
||||||
("hospital_admin", _("Hospital Admin")),
|
("hospital_admin", _("Hospital Admin")),
|
||||||
("department_manager", _("Department Manager")),
|
("department_manager", _("Department Manager")),
|
||||||
("px_coordinator", _("PX Coordinator")),
|
("px_staff", _("PX Staff")),
|
||||||
("physician", _("Physician")),
|
("physician", _("Physician")),
|
||||||
("nurse", _("Nurse")),
|
("nurse", _("Nurse")),
|
||||||
("staff", _("Staff")),
|
("staff", _("Staff")),
|
||||||
("viewer", _("Viewer")),
|
("viewer", _("Viewer")),
|
||||||
|
("executive", _("Executive")),
|
||||||
]
|
]
|
||||||
|
|
||||||
name = models.CharField(max_length=50, unique=True, choices=ROLE_CHOICES)
|
name = models.CharField(max_length=50, unique=True, choices=ROLE_CHOICES)
|
||||||
|
|||||||
@ -32,11 +32,22 @@ class IsDepartmentManager(permissions.BasePermission):
|
|||||||
Department Managers have access to their department's data.
|
Department Managers have access to their department's data.
|
||||||
"""
|
"""
|
||||||
message = "You must be a Department Manager to perform this action."
|
message = "You must be a Department Manager to perform this action."
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
return request.user and request.user.is_authenticated and request.user.is_department_manager()
|
return request.user and request.user.is_authenticated and request.user.is_department_manager()
|
||||||
|
|
||||||
|
|
||||||
|
class IsExecutive(permissions.BasePermission):
|
||||||
|
"""
|
||||||
|
Permission class to check if user is Executive (C-Suite/Top Management).
|
||||||
|
Executives have system-wide read-only access with analytics capabilities.
|
||||||
|
"""
|
||||||
|
message = "You must be an Executive to perform this action."
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
return request.user and request.user.is_authenticated and request.user.is_executive()
|
||||||
|
|
||||||
|
|
||||||
class IsPXAdminOrHospitalAdmin(permissions.BasePermission):
|
class IsPXAdminOrHospitalAdmin(permissions.BasePermission):
|
||||||
"""
|
"""
|
||||||
Permission class for PX Admin or Hospital Admin.
|
Permission class for PX Admin or Hospital Admin.
|
||||||
|
|||||||
@ -214,7 +214,7 @@ def kpi_report_generate(request):
|
|||||||
hospitals = Hospital.objects.none()
|
hospitals = Hospital.objects.none()
|
||||||
|
|
||||||
current_year = datetime.now().year
|
current_year = datetime.now().year
|
||||||
years = list(range(current_year, current_year - 3, -1))
|
years = list(range(current_year, 2021, -1))
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"hospitals": hospitals,
|
"hospitals": hospitals,
|
||||||
|
|||||||
@ -86,6 +86,10 @@ class ExecutiveSummaryGenerator:
|
|||||||
|
|
||||||
CACHE_TIMEOUT = 3600 # 1 hour
|
CACHE_TIMEOUT = 3600 # 1 hour
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _cache_key(cls, hospital_id=None, department_id=None, period="30d") -> str:
|
||||||
|
return f"exec_summary:{hospital_id or 'all'}:{department_id or 'all'}:{period}"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _gather_data(user, hospital_id=None, department_id=None, period="30d") -> Dict[str, Any]:
|
def _gather_data(user, hospital_id=None, department_id=None, period="30d") -> Dict[str, Any]:
|
||||||
"""Collect all data needed for the summary."""
|
"""Collect all data needed for the summary."""
|
||||||
@ -225,7 +229,7 @@ class ExecutiveSummaryGenerator:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def generate(cls, user, hospital_id=None, department_id=None, period="30d", force_refresh=False) -> Dict[str, Any]:
|
def generate(cls, user, hospital_id=None, department_id=None, period="30d", force_refresh=False) -> Dict[str, Any]:
|
||||||
"""Generate or return cached executive summary."""
|
"""Generate or return cached executive summary."""
|
||||||
cache_key = f"exec_summary_{user.id}_{hospital_id}_{department_id}_{period}"
|
cache_key = cls._cache_key(hospital_id, department_id, period)
|
||||||
if not force_refresh:
|
if not force_refresh:
|
||||||
cached = cache.get(cache_key)
|
cached = cache.get(cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
@ -332,6 +336,10 @@ class EarlyWarningSystem:
|
|||||||
|
|
||||||
CACHE_TIMEOUT = 1800 # 30 minutes
|
CACHE_TIMEOUT = 1800 # 30 minutes
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _cache_key(cls, hospital_id=None, limit=10) -> str:
|
||||||
|
return f"early_warning:{hospital_id or 'all'}:{limit}"
|
||||||
|
|
||||||
RISK_WEIGHTS = {
|
RISK_WEIGHTS = {
|
||||||
"complaint_volume_spike": 25,
|
"complaint_volume_spike": 25,
|
||||||
"survey_score_decline": 25,
|
"survey_score_decline": 25,
|
||||||
@ -343,7 +351,7 @@ class EarlyWarningSystem:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def detect(cls, user, hospital_id=None, limit=10) -> List[Dict[str, Any]]:
|
def detect(cls, user, hospital_id=None, limit=10) -> List[Dict[str, Any]]:
|
||||||
"""Scan all active departments and return those with risk scores > threshold."""
|
"""Scan all active departments and return those with risk scores > threshold."""
|
||||||
cache_key = f"early_warning_{user.id}_{hospital_id}_{limit}"
|
cache_key = cls._cache_key(hospital_id, limit)
|
||||||
cached = cache.get(cache_key)
|
cached = cache.get(cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
return cached
|
return cached
|
||||||
@ -359,12 +367,208 @@ class EarlyWarningSystem:
|
|||||||
elif hasattr(user, "hospital") and user.hospital and not getattr(user, "is_px_admin", lambda: False)():
|
elif hasattr(user, "hospital") and user.hospital and not getattr(user, "is_px_admin", lambda: False)():
|
||||||
depts = depts.filter(hospital=user.hospital)
|
depts = depts.filter(hospital=user.hospital)
|
||||||
|
|
||||||
results = []
|
dept_ids = list(depts.values_list("id", flat=True))
|
||||||
|
if not dept_ids:
|
||||||
|
cache.set(cache_key, [], cls.CACHE_TIMEOUT)
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Bulk fetch all metrics in a few queries instead of O(N) per department
|
||||||
|
# 1. Complaint counts by department (current + previous)
|
||||||
|
curr_complaints = {
|
||||||
|
item["department_id"]: item["c"]
|
||||||
|
for item in Complaint.objects.filter(department_id__in=dept_ids, created_at__gte=current_start)
|
||||||
|
.values("department_id")
|
||||||
|
.annotate(c=Count("id"))
|
||||||
|
}
|
||||||
|
prev_complaints = {
|
||||||
|
item["department_id"]: item["c"]
|
||||||
|
for item in Complaint.objects.filter(
|
||||||
|
department_id__in=dept_ids, created_at__gte=prev_start, created_at__lt=current_start
|
||||||
|
)
|
||||||
|
.values("department_id")
|
||||||
|
.annotate(c=Count("id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Survey averages by department (current + previous)
|
||||||
|
curr_surveys = {
|
||||||
|
item["journey_instance__department_id"]: item["a"]
|
||||||
|
for item in SurveyInstance.objects.filter(
|
||||||
|
journey_instance__department_id__in=dept_ids,
|
||||||
|
completed_at__gte=current_start,
|
||||||
|
status="completed",
|
||||||
|
total_score__isnull=False,
|
||||||
|
)
|
||||||
|
.values("journey_instance__department_id")
|
||||||
|
.annotate(a=Avg("total_score"))
|
||||||
|
}
|
||||||
|
prev_surveys = {
|
||||||
|
item["journey_instance__department_id"]: item["a"]
|
||||||
|
for item in SurveyInstance.objects.filter(
|
||||||
|
journey_instance__department_id__in=dept_ids,
|
||||||
|
completed_at__gte=prev_start,
|
||||||
|
completed_at__lt=current_start,
|
||||||
|
status="completed",
|
||||||
|
total_score__isnull=False,
|
||||||
|
)
|
||||||
|
.values("journey_instance__department_id")
|
||||||
|
.annotate(a=Avg("total_score"))
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. SLA breach counts (current + previous)
|
||||||
|
curr_breached = {
|
||||||
|
item["department_id"]: item["c"]
|
||||||
|
for item in Complaint.objects.filter(
|
||||||
|
department_id__in=dept_ids, created_at__gte=current_start, is_overdue=True
|
||||||
|
)
|
||||||
|
.values("department_id")
|
||||||
|
.annotate(c=Count("id"))
|
||||||
|
}
|
||||||
|
prev_breached = {
|
||||||
|
item["department_id"]: item["c"]
|
||||||
|
for item in Complaint.objects.filter(
|
||||||
|
department_id__in=dept_ids, created_at__gte=prev_start, created_at__lt=current_start, is_overdue=True
|
||||||
|
)
|
||||||
|
.values("department_id")
|
||||||
|
.annotate(c=Count("id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Negative feedback counts (current + previous)
|
||||||
|
curr_neg_fb = {
|
||||||
|
item["department_id"]: item["c"]
|
||||||
|
for item in Feedback.objects.filter(
|
||||||
|
department_id__in=dept_ids, created_at__gte=current_start, sentiment="negative"
|
||||||
|
)
|
||||||
|
.values("department_id")
|
||||||
|
.annotate(c=Count("id"))
|
||||||
|
}
|
||||||
|
prev_neg_fb = {
|
||||||
|
item["department_id"]: item["c"]
|
||||||
|
for item in Feedback.objects.filter(
|
||||||
|
department_id__in=dept_ids,
|
||||||
|
created_at__gte=prev_start,
|
||||||
|
created_at__lt=current_start,
|
||||||
|
sentiment="negative",
|
||||||
|
)
|
||||||
|
.values("department_id")
|
||||||
|
.annotate(c=Count("id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
# 5. Overdue actions counts (current + previous)
|
||||||
|
curr_overdue_actions = {
|
||||||
|
item["department_id"]: item["c"]
|
||||||
|
for item in PXAction.objects.filter(
|
||||||
|
department_id__in=dept_ids, created_at__gte=current_start, is_overdue=True
|
||||||
|
)
|
||||||
|
.values("department_id")
|
||||||
|
.annotate(c=Count("id"))
|
||||||
|
}
|
||||||
|
prev_overdue_actions = {
|
||||||
|
item["department_id"]: item["c"]
|
||||||
|
for item in PXAction.objects.filter(
|
||||||
|
department_id__in=dept_ids, created_at__gte=prev_start, created_at__lt=current_start, is_overdue=True
|
||||||
|
)
|
||||||
|
.values("department_id")
|
||||||
|
.annotate(c=Count("id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
results = []
|
||||||
for dept in depts:
|
for dept in depts:
|
||||||
signals = cls._evaluate_department(dept, current_start, prev_start, now)
|
did = dept.id
|
||||||
risk_score = signals["risk_score"]
|
signals = {}
|
||||||
if risk_score > 20: # threshold — show anything above 20%
|
risk_score = 0
|
||||||
|
|
||||||
|
# 1. Complaint volume spike
|
||||||
|
cc = curr_complaints.get(did, 0)
|
||||||
|
pc = prev_complaints.get(did, 0)
|
||||||
|
change_pct = ((cc - pc) / pc * 100) if pc > 0 else (100 if cc > 0 else 0)
|
||||||
|
volume_score = min(change_pct / 50 * 100, 100)
|
||||||
|
risk_score += volume_score * cls.RISK_WEIGHTS["complaint_volume_spike"] / 100
|
||||||
|
signals["complaint_volume_spike"] = {
|
||||||
|
"current": cc,
|
||||||
|
"previous": pc,
|
||||||
|
"change_pct": round(change_pct, 1),
|
||||||
|
"score": round(volume_score, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Survey score decline
|
||||||
|
csa = curr_surveys.get(did, 0) or 0
|
||||||
|
psa = prev_surveys.get(did, 0) or 0
|
||||||
|
survey_change = ((csa - psa) / psa * 100) if psa > 0 else 0
|
||||||
|
decline_score = max(0, min(-survey_change / 20 * 100, 100))
|
||||||
|
risk_score += decline_score * cls.RISK_WEIGHTS["survey_score_decline"] / 100
|
||||||
|
signals["survey_score_decline"] = {
|
||||||
|
"current_avg": round(csa, 2),
|
||||||
|
"previous_avg": round(psa, 2),
|
||||||
|
"change_pct": round(survey_change, 1),
|
||||||
|
"score": round(decline_score, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. SLA breach increase
|
||||||
|
cb = curr_breached.get(did, 0)
|
||||||
|
ct = curr_complaints.get(did, 0)
|
||||||
|
pb = prev_breached.get(did, 0)
|
||||||
|
pt = prev_complaints.get(did, 0)
|
||||||
|
curr_breach_rate = (cb / ct * 100) if ct > 0 else 0
|
||||||
|
prev_breach_rate = (pb / pt * 100) if pt > 0 else 0
|
||||||
|
breach_change = (curr_breach_rate - prev_breach_rate) if prev_breach_rate > 0 else curr_breach_rate
|
||||||
|
sla_score = min(max(breach_change / 20 * 100, 0), 100)
|
||||||
|
risk_score += sla_score * cls.RISK_WEIGHTS["sla_breach_increase"] / 100
|
||||||
|
signals["sla_breach_increase"] = {
|
||||||
|
"current_rate": round(curr_breach_rate, 1),
|
||||||
|
"previous_rate": round(prev_breach_rate, 1),
|
||||||
|
"change_pp": round(breach_change, 1),
|
||||||
|
"score": round(sla_score, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Negative feedback rise
|
||||||
|
cn = curr_neg_fb.get(did, 0)
|
||||||
|
pn = prev_neg_fb.get(did, 0)
|
||||||
|
fb_change = ((cn - pn) / pn * 100) if pn > 0 else (100 if cn > 0 else 0)
|
||||||
|
fb_score = min(max(fb_change / 50 * 100, 0), 100)
|
||||||
|
risk_score += fb_score * cls.RISK_WEIGHTS["negative_feedback_rise"] / 100
|
||||||
|
signals["negative_feedback_rise"] = {
|
||||||
|
"current": cn,
|
||||||
|
"previous": pn,
|
||||||
|
"change_pct": round(fb_change, 1),
|
||||||
|
"score": round(fb_score, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 5. Overdue actions rise
|
||||||
|
co = curr_overdue_actions.get(did, 0)
|
||||||
|
po = prev_overdue_actions.get(did, 0)
|
||||||
|
overdue_change = ((co - po) / po * 100) if po > 0 else (100 if co > 0 else 0)
|
||||||
|
overdue_score = min(max(overdue_change / 50 * 100, 0), 100)
|
||||||
|
risk_score += overdue_score * cls.RISK_WEIGHTS["overdue_actions_rise"] / 100
|
||||||
|
signals["overdue_actions_rise"] = {
|
||||||
|
"current": co,
|
||||||
|
"previous": po,
|
||||||
|
"change_pct": round(overdue_change, 1),
|
||||||
|
"score": round(overdue_score, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
signals["risk_score"] = round(risk_score, 1)
|
||||||
|
signals["risk_level"] = (
|
||||||
|
"critical"
|
||||||
|
if risk_score >= 70
|
||||||
|
else "high"
|
||||||
|
if risk_score >= 50
|
||||||
|
else "medium"
|
||||||
|
if risk_score >= 30
|
||||||
|
else "low"
|
||||||
|
)
|
||||||
|
signals["active_signals"] = sum(
|
||||||
|
1
|
||||||
|
for k in [
|
||||||
|
"complaint_volume_spike",
|
||||||
|
"survey_score_decline",
|
||||||
|
"sla_breach_increase",
|
||||||
|
"negative_feedback_rise",
|
||||||
|
"overdue_actions_rise",
|
||||||
|
]
|
||||||
|
if signals.get(k, {}).get("score", 0) > 30
|
||||||
|
)
|
||||||
|
|
||||||
|
if risk_score > 20:
|
||||||
signals["department_id"] = str(dept.id)
|
signals["department_id"] = str(dept.id)
|
||||||
signals["department_name"] = dept.name_en if hasattr(dept, "name_en") else str(dept)
|
signals["department_name"] = dept.name_en if hasattr(dept, "name_en") else str(dept)
|
||||||
results.append(signals)
|
results.append(signals)
|
||||||
@ -375,158 +579,6 @@ class EarlyWarningSystem:
|
|||||||
cache.set(cache_key, results, cls.CACHE_TIMEOUT)
|
cache.set(cache_key, results, cls.CACHE_TIMEOUT)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _evaluate_department(cls, dept, current_start, prev_start, now) -> Dict[str, Any]:
|
|
||||||
signals = {}
|
|
||||||
risk_score = 0
|
|
||||||
|
|
||||||
# 1. Complaint volume spike
|
|
||||||
current_complaints = Complaint.objects.filter(department=dept, created_at__gte=current_start).count()
|
|
||||||
prev_complaints = Complaint.objects.filter(
|
|
||||||
department=dept, created_at__gte=prev_start, created_at__lt=current_start
|
|
||||||
).count()
|
|
||||||
|
|
||||||
if prev_complaints > 0:
|
|
||||||
change_pct = ((current_complaints - prev_complaints) / prev_complaints) * 100
|
|
||||||
elif current_complaints > 0:
|
|
||||||
change_pct = 100
|
|
||||||
else:
|
|
||||||
change_pct = 0
|
|
||||||
|
|
||||||
volume_score = min(change_pct / 50 * 100, 100) # 50% spike = 100
|
|
||||||
risk_score += volume_score * cls.RISK_WEIGHTS["complaint_volume_spike"] / 100
|
|
||||||
signals["complaint_volume_spike"] = {
|
|
||||||
"current": current_complaints,
|
|
||||||
"previous": prev_complaints,
|
|
||||||
"change_pct": round(change_pct, 1),
|
|
||||||
"score": round(volume_score, 1),
|
|
||||||
}
|
|
||||||
|
|
||||||
# 2. Survey score decline
|
|
||||||
current_surveys = SurveyInstance.objects.filter(
|
|
||||||
journey_instance__department=dept,
|
|
||||||
completed_at__gte=current_start,
|
|
||||||
status="completed",
|
|
||||||
total_score__isnull=False,
|
|
||||||
)
|
|
||||||
prev_surveys = SurveyInstance.objects.filter(
|
|
||||||
journey_instance__department=dept,
|
|
||||||
completed_at__gte=prev_start,
|
|
||||||
completed_at__lt=current_start,
|
|
||||||
status="completed",
|
|
||||||
total_score__isnull=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
curr_avg = current_surveys.aggregate(a=Avg("total_score"))["a"] or 0
|
|
||||||
prev_avg = prev_surveys.aggregate(a=Avg("total_score"))["a"] or 0
|
|
||||||
|
|
||||||
if prev_avg > 0:
|
|
||||||
survey_change = ((curr_avg - prev_avg) / prev_avg) * 100
|
|
||||||
else:
|
|
||||||
survey_change = 0
|
|
||||||
|
|
||||||
decline_score = max(0, min(-survey_change / 20 * 100, 100)) # 20% drop = 100
|
|
||||||
risk_score += decline_score * cls.RISK_WEIGHTS["survey_score_decline"] / 100
|
|
||||||
signals["survey_score_decline"] = {
|
|
||||||
"current_avg": round(curr_avg, 2),
|
|
||||||
"previous_avg": round(prev_avg, 2),
|
|
||||||
"change_pct": round(survey_change, 1),
|
|
||||||
"score": round(decline_score, 1),
|
|
||||||
}
|
|
||||||
|
|
||||||
# 3. SLA breach increase
|
|
||||||
curr_breached = Complaint.objects.filter(
|
|
||||||
department=dept, created_at__gte=current_start, is_overdue=True
|
|
||||||
).count()
|
|
||||||
curr_total_c = Complaint.objects.filter(department=dept, created_at__gte=current_start).count()
|
|
||||||
prev_breached = Complaint.objects.filter(
|
|
||||||
department=dept, created_at__gte=prev_start, created_at__lt=current_start, is_overdue=True
|
|
||||||
).count()
|
|
||||||
prev_total_c = Complaint.objects.filter(
|
|
||||||
department=dept, created_at__gte=prev_start, created_at__lt=current_start
|
|
||||||
).count()
|
|
||||||
|
|
||||||
curr_breach_rate = (curr_breached / curr_total_c * 100) if curr_total_c > 0 else 0
|
|
||||||
prev_breach_rate = (prev_breached / prev_total_c * 100) if prev_total_c > 0 else 0
|
|
||||||
|
|
||||||
if prev_breach_rate > 0:
|
|
||||||
breach_change = curr_breach_rate - prev_breach_rate
|
|
||||||
else:
|
|
||||||
breach_change = curr_breach_rate
|
|
||||||
|
|
||||||
sla_score = min(max(breach_change / 20 * 100, 0), 100)
|
|
||||||
risk_score += sla_score * cls.RISK_WEIGHTS["sla_breach_increase"] / 100
|
|
||||||
signals["sla_breach_increase"] = {
|
|
||||||
"current_rate": round(curr_breach_rate, 1),
|
|
||||||
"previous_rate": round(prev_breach_rate, 1),
|
|
||||||
"change_pp": round(breach_change, 1),
|
|
||||||
"score": round(sla_score, 1),
|
|
||||||
}
|
|
||||||
|
|
||||||
# 4. Negative feedback rise
|
|
||||||
curr_neg_fb = Feedback.objects.filter(
|
|
||||||
department=dept, created_at__gte=current_start, sentiment="negative"
|
|
||||||
).count()
|
|
||||||
prev_neg_fb = Feedback.objects.filter(
|
|
||||||
department=dept, created_at__gte=prev_start, created_at__lt=current_start, sentiment="negative"
|
|
||||||
).count()
|
|
||||||
|
|
||||||
if prev_neg_fb > 0:
|
|
||||||
fb_change = ((curr_neg_fb - prev_neg_fb) / prev_neg_fb) * 100
|
|
||||||
elif curr_neg_fb > 0:
|
|
||||||
fb_change = 100
|
|
||||||
else:
|
|
||||||
fb_change = 0
|
|
||||||
|
|
||||||
fb_score = min(max(fb_change / 50 * 100, 0), 100)
|
|
||||||
risk_score += fb_score * cls.RISK_WEIGHTS["negative_feedback_rise"] / 100
|
|
||||||
signals["negative_feedback_rise"] = {
|
|
||||||
"current": curr_neg_fb,
|
|
||||||
"previous": prev_neg_fb,
|
|
||||||
"change_pct": round(fb_change, 1),
|
|
||||||
"score": round(fb_score, 1),
|
|
||||||
}
|
|
||||||
|
|
||||||
# 5. Overdue actions rise
|
|
||||||
curr_overdue = PXAction.objects.filter(department=dept, created_at__gte=current_start, is_overdue=True).count()
|
|
||||||
prev_overdue = PXAction.objects.filter(
|
|
||||||
department=dept, created_at__gte=prev_start, created_at__lt=current_start, is_overdue=True
|
|
||||||
).count()
|
|
||||||
|
|
||||||
if prev_overdue > 0:
|
|
||||||
overdue_change = ((curr_overdue - prev_overdue) / prev_overdue) * 100
|
|
||||||
elif curr_overdue > 0:
|
|
||||||
overdue_change = 100
|
|
||||||
else:
|
|
||||||
overdue_change = 0
|
|
||||||
|
|
||||||
overdue_score = min(max(overdue_change / 50 * 100, 0), 100)
|
|
||||||
risk_score += overdue_score * cls.RISK_WEIGHTS["overdue_actions_rise"] / 100
|
|
||||||
signals["overdue_actions_rise"] = {
|
|
||||||
"current": curr_overdue,
|
|
||||||
"previous": prev_overdue,
|
|
||||||
"change_pct": round(overdue_change, 1),
|
|
||||||
"score": round(overdue_score, 1),
|
|
||||||
}
|
|
||||||
|
|
||||||
signals["risk_score"] = round(risk_score, 1)
|
|
||||||
signals["risk_level"] = (
|
|
||||||
"critical" if risk_score >= 70 else "high" if risk_score >= 50 else "medium" if risk_score >= 30 else "low"
|
|
||||||
)
|
|
||||||
signals["active_signals"] = sum(
|
|
||||||
1
|
|
||||||
for k in [
|
|
||||||
"complaint_volume_spike",
|
|
||||||
"survey_score_decline",
|
|
||||||
"sla_breach_increase",
|
|
||||||
"negative_feedback_rise",
|
|
||||||
"overdue_actions_rise",
|
|
||||||
]
|
|
||||||
if signals.get(k, {}).get("score", 0) > 30
|
|
||||||
)
|
|
||||||
|
|
||||||
return signals
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 3. Predictive Complaint Volume — Time-Series Forecasting
|
# 3. Predictive Complaint Volume — Time-Series Forecasting
|
||||||
@ -541,9 +593,13 @@ class ComplaintVolumeForecaster:
|
|||||||
|
|
||||||
CACHE_TIMEOUT = 3600 # 1 hour
|
CACHE_TIMEOUT = 3600 # 1 hour
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _cache_key(cls, hospital_id=None, forecast_days=30) -> str:
|
||||||
|
return f"complaint_forecast:{hospital_id or 'all'}:{forecast_days}"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def forecast(cls, user, hospital_id=None, forecast_days=30) -> Dict[str, Any]:
|
def forecast(cls, user, hospital_id=None, forecast_days=30) -> Dict[str, Any]:
|
||||||
cache_key = f"complaint_forecast_{user.id}_{hospital_id}_{forecast_days}"
|
cache_key = cls._cache_key(hospital_id, forecast_days)
|
||||||
cached = cache.get(cache_key)
|
cached = cache.get(cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
return cached
|
return cached
|
||||||
@ -693,10 +749,14 @@ class SLABreachPredictor:
|
|||||||
|
|
||||||
CACHE_TIMEOUT = 900 # 15 minutes
|
CACHE_TIMEOUT = 900 # 15 minutes
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _cache_key(cls, hospital_id=None, limit=20) -> str:
|
||||||
|
return f"sla_breach_pred:{hospital_id or 'all'}:{limit}"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def predict(cls, user, hospital_id=None, limit=20) -> List[Dict[str, Any]]:
|
def predict(cls, user, hospital_id=None, limit=20) -> List[Dict[str, Any]]:
|
||||||
"""Return complaints ranked by breach probability."""
|
"""Return complaints ranked by breach probability."""
|
||||||
cache_key = f"sla_breach_pred_{user.id}_{hospital_id}_{limit}"
|
cache_key = cls._cache_key(hospital_id, limit)
|
||||||
cached = cache.get(cache_key)
|
cached = cache.get(cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
return cached
|
return cached
|
||||||
@ -713,10 +773,63 @@ class SLABreachPredictor:
|
|||||||
qs = qs.filter(hospital=user.hospital)
|
qs = qs.filter(hospital=user.hospital)
|
||||||
|
|
||||||
qs = qs.select_related("hospital", "department", "source", "assigned_to").order_by("due_at")
|
qs = qs.select_related("hospital", "department", "source", "assigned_to").order_by("due_at")
|
||||||
|
complaints = list(qs[: limit * 2])
|
||||||
|
|
||||||
|
# Pre-compute workloads in ONE query instead of N
|
||||||
|
assignee_ids = {c.assigned_to_id for c in complaints if c.assigned_to_id}
|
||||||
|
workload_map = {}
|
||||||
|
if assignee_ids:
|
||||||
|
workload_qs = (
|
||||||
|
Complaint.objects.filter(
|
||||||
|
assigned_to_id__in=assignee_ids,
|
||||||
|
status__in=["open", "in_progress"],
|
||||||
|
)
|
||||||
|
.values("assigned_to_id")
|
||||||
|
.annotate(c=Count("id"))
|
||||||
|
)
|
||||||
|
workload_map = {item["assigned_to_id"]: item["c"] for item in workload_qs}
|
||||||
|
|
||||||
|
# Pre-compute average resolution time per (severity, department) in ONE query
|
||||||
|
dept_ids = {c.department_id for c in complaints if c.department_id}
|
||||||
|
severities = {c.severity for c in complaints if c.severity}
|
||||||
|
resolution_map = {}
|
||||||
|
if severities:
|
||||||
|
base_res = (
|
||||||
|
Complaint.objects.filter(
|
||||||
|
status__in=["resolved", "closed"],
|
||||||
|
severity__in=severities,
|
||||||
|
resolved_at__isnull=False,
|
||||||
|
created_at__isnull=False,
|
||||||
|
)
|
||||||
|
.annotate(rt=F("resolved_at") - F("created_at"))
|
||||||
|
.values("severity", "department_id")
|
||||||
|
.annotate(avg=Avg("rt"))
|
||||||
|
)
|
||||||
|
for item in base_res:
|
||||||
|
key = (item["severity"], item["department_id"])
|
||||||
|
avg_val = item["avg"]
|
||||||
|
resolution_map[key] = avg_val.total_seconds() / 3600 if avg_val else None
|
||||||
|
|
||||||
|
# Also compute severity-only averages for departments with no match
|
||||||
|
base_res_sev_only = (
|
||||||
|
Complaint.objects.filter(
|
||||||
|
status__in=["resolved", "closed"],
|
||||||
|
severity__in=severities,
|
||||||
|
resolved_at__isnull=False,
|
||||||
|
created_at__isnull=False,
|
||||||
|
)
|
||||||
|
.annotate(rt=F("resolved_at") - F("created_at"))
|
||||||
|
.values("severity")
|
||||||
|
.annotate(avg=Avg("rt"))
|
||||||
|
)
|
||||||
|
for item in base_res_sev_only:
|
||||||
|
key = (item["severity"], None)
|
||||||
|
avg_val = item["avg"]
|
||||||
|
resolution_map[key] = avg_val.total_seconds() / 3600 if avg_val else None
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for complaint in qs[: limit * 2]: # fetch extra to filter/sort
|
for complaint in complaints:
|
||||||
prediction = cls._predict_complaint_breach(complaint, now)
|
prediction = cls._predict_complaint_breach(complaint, now, workload_map, resolution_map)
|
||||||
if prediction["breach_probability"] > 30: # only show > 30% risk
|
if prediction["breach_probability"] > 30: # only show > 30% risk
|
||||||
prediction["complaint_id"] = str(complaint.id)
|
prediction["complaint_id"] = str(complaint.id)
|
||||||
prediction["title"] = complaint.title
|
prediction["title"] = complaint.title
|
||||||
@ -744,8 +857,8 @@ class SLABreachPredictor:
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _predict_complaint_breach(cls, complaint, now) -> Dict[str, Any]:
|
def _predict_complaint_breach(cls, complaint, now, workload_map, resolution_map) -> Dict[str, Any]:
|
||||||
"""Calculate breach probability for a single complaint."""
|
"""Calculate breach probability for a single complaint using pre-computed bulk data."""
|
||||||
probability = 0
|
probability = 0
|
||||||
factors = []
|
factors = []
|
||||||
|
|
||||||
@ -791,11 +904,7 @@ class SLABreachPredictor:
|
|||||||
probability += 15
|
probability += 15
|
||||||
factors.append("Unassigned — no owner yet")
|
factors.append("Unassigned — no owner yet")
|
||||||
else:
|
else:
|
||||||
# Check assignee workload
|
workload = workload_map.get(complaint.assigned_to_id, 0)
|
||||||
workload = Complaint.objects.filter(
|
|
||||||
assigned_to=complaint.assigned_to,
|
|
||||||
status__in=["open", "in_progress"],
|
|
||||||
).count()
|
|
||||||
if workload >= 10:
|
if workload >= 10:
|
||||||
probability += 12
|
probability += 12
|
||||||
factors.append(f"Assignee has {workload} active cases — overloaded")
|
factors.append(f"Assignee has {workload} active cases — overloaded")
|
||||||
@ -804,34 +913,25 @@ class SLABreachPredictor:
|
|||||||
factors.append(f"Assignee has {workload} active cases — moderate load")
|
factors.append(f"Assignee has {workload} active cases — moderate load")
|
||||||
|
|
||||||
# Factor 4: Historical resolution time for similar complaints (0-20 points)
|
# Factor 4: Historical resolution time for similar complaints (0-20 points)
|
||||||
similar = Complaint.objects.filter(
|
avg_hrs = None
|
||||||
status__in=["resolved", "closed"],
|
if complaint.department_id:
|
||||||
severity=complaint.severity,
|
avg_hrs = resolution_map.get((complaint.severity, complaint.department_id))
|
||||||
)
|
if avg_hrs is None:
|
||||||
if complaint.department:
|
avg_hrs = resolution_map.get((complaint.severity, None))
|
||||||
similar = similar.filter(department=complaint.department)
|
|
||||||
|
|
||||||
if similar.exists():
|
if avg_hrs is not None:
|
||||||
avg_resolve_hours = (
|
total_sla_hrs = (
|
||||||
similar.filter(resolved_at__isnull=False, created_at__isnull=False)
|
(complaint.due_at - complaint.created_at).total_seconds() / 3600
|
||||||
.annotate(rt=F("resolved_at") - F("created_at"))
|
if complaint.due_at and complaint.created_at
|
||||||
.aggregate(avg=Avg("rt"))["avg"]
|
else 24
|
||||||
)
|
)
|
||||||
|
|
||||||
if avg_resolve_hours:
|
if avg_hrs > total_sla_hrs * 0.9:
|
||||||
avg_hrs = avg_resolve_hours.total_seconds() / 3600
|
probability += 18
|
||||||
total_sla_hrs = (
|
factors.append(f"Similar complaints avg {avg_hrs:.0f}h to resolve — exceeds SLA")
|
||||||
(complaint.due_at - complaint.created_at).total_seconds() / 3600
|
elif avg_hrs > total_sla_hrs * 0.7:
|
||||||
if complaint.due_at and complaint.created_at
|
probability += 10
|
||||||
else 24
|
factors.append(f"Similar complaints avg {avg_hrs:.0f}h — close to SLA limit")
|
||||||
)
|
|
||||||
|
|
||||||
if avg_hrs > total_sla_hrs * 0.9:
|
|
||||||
probability += 18
|
|
||||||
factors.append(f"Similar complaints avg {avg_hrs:.0f}h to resolve — exceeds SLA")
|
|
||||||
elif avg_hrs > total_sla_hrs * 0.7:
|
|
||||||
probability += 10
|
|
||||||
factors.append(f"Similar complaints avg {avg_hrs:.0f}h — close to SLA limit")
|
|
||||||
|
|
||||||
# Factor 5: Age without progress (0-10 points)
|
# Factor 5: Age without progress (0-10 points)
|
||||||
if complaint.created_at:
|
if complaint.created_at:
|
||||||
@ -876,10 +976,14 @@ class ActionRecommendationEngine:
|
|||||||
|
|
||||||
CACHE_TIMEOUT = 3600 # 1 hour
|
CACHE_TIMEOUT = 3600 # 1 hour
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _cache_key(cls, hospital_id=None, department_id=None, limit=5) -> str:
|
||||||
|
return f"action_recommendations:{hospital_id or 'all'}:{department_id or 'all'}:{limit}"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def generate_recommendations(cls, user, hospital_id=None, department_id=None, limit=5) -> List[Dict[str, Any]]:
|
def generate_recommendations(cls, user, hospital_id=None, department_id=None, limit=5) -> List[Dict[str, Any]]:
|
||||||
"""Generate AI-powered action recommendations from complaint analysis."""
|
"""Generate AI-powered action recommendations from complaint analysis."""
|
||||||
cache_key = f"action_recommendations_{user.id}_{hospital_id}_{department_id}_{limit}"
|
cache_key = cls._cache_key(hospital_id, department_id, limit)
|
||||||
cached = cache.get(cache_key)
|
cached = cache.get(cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
return cached
|
return cached
|
||||||
|
|||||||
@ -363,14 +363,20 @@ class UnifiedAnalyticsService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_complaints_trend(queryset, start_date, end_date) -> Dict[str, Any]:
|
def _get_complaints_trend(queryset, start_date, end_date) -> Dict[str, Any]:
|
||||||
"""Get complaints trend over time (grouped by day)"""
|
"""Get complaints trend over time (grouped by day) using a single DB query."""
|
||||||
|
from django.db.models.functions import TruncDate
|
||||||
|
|
||||||
|
raw_data = list(
|
||||||
|
queryset.annotate(day=TruncDate("created_at")).values("day").annotate(count=Count("id")).order_by("day")
|
||||||
|
)
|
||||||
|
date_map = {str(item["day"]): item["count"] for item in raw_data}
|
||||||
|
|
||||||
data = []
|
data = []
|
||||||
current_date = start_date
|
current_date = start_date.date() if hasattr(start_date, "date") else start_date
|
||||||
while current_date <= end_date:
|
end = end_date.date() if hasattr(end_date, "date") else end_date
|
||||||
next_date = current_date + timedelta(days=1)
|
while current_date <= end:
|
||||||
count = queryset.filter(created_at__gte=current_date, created_at__lt=next_date).count()
|
data.append({"date": current_date.strftime("%Y-%m-%d"), "count": date_map.get(str(current_date), 0)})
|
||||||
data.append({"date": current_date.strftime("%Y-%m-%d"), "count": count})
|
current_date += timedelta(days=1)
|
||||||
current_date = next_date
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"type": "line",
|
"type": "line",
|
||||||
@ -404,19 +410,23 @@ class UnifiedAnalyticsService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_survey_satisfaction_trend(queryset, start_date, end_date) -> Dict[str, Any]:
|
def _get_survey_satisfaction_trend(queryset, start_date, end_date) -> Dict[str, Any]:
|
||||||
"""Get survey satisfaction trend over time"""
|
"""Get survey satisfaction trend over time using a single DB query."""
|
||||||
|
from django.db.models.functions import TruncDate
|
||||||
|
|
||||||
|
raw_data = list(
|
||||||
|
queryset.annotate(day=TruncDate("completed_at"))
|
||||||
|
.values("day")
|
||||||
|
.annotate(avg_score=Avg("total_score"))
|
||||||
|
.order_by("day")
|
||||||
|
)
|
||||||
|
date_map = {str(item["day"]): round(item["avg_score"] or 0, 2) for item in raw_data}
|
||||||
|
|
||||||
data = []
|
data = []
|
||||||
current_date = start_date
|
current_date = start_date.date() if hasattr(start_date, "date") else start_date
|
||||||
while current_date <= end_date:
|
end = end_date.date() if hasattr(end_date, "date") else end_date
|
||||||
next_date = current_date + timedelta(days=1)
|
while current_date <= end:
|
||||||
avg_score = (
|
data.append({"date": current_date.strftime("%Y-%m-%d"), "score": date_map.get(str(current_date), 0)})
|
||||||
queryset.filter(completed_at__gte=current_date, completed_at__lt=next_date).aggregate(
|
current_date += timedelta(days=1)
|
||||||
avg=Avg("total_score")
|
|
||||||
)["avg"]
|
|
||||||
or 0
|
|
||||||
)
|
|
||||||
data.append({"date": current_date.strftime("%Y-%m-%d"), "score": round(avg_score, 2)})
|
|
||||||
current_date = next_date
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"type": "line",
|
"type": "line",
|
||||||
@ -524,8 +534,29 @@ class UnifiedAnalyticsService:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Aggregate totals across all staff
|
||||||
|
totals = {
|
||||||
|
"complaints_total": sum(s["complaints"]["total"] for s in staff_metrics),
|
||||||
|
"complaints_internal": sum(s["complaints"]["internal"] for s in staff_metrics),
|
||||||
|
"complaints_external": sum(s["complaints"]["external"] for s in staff_metrics),
|
||||||
|
"complaints_open": sum(s["complaints"]["status"]["open"] for s in staff_metrics),
|
||||||
|
"complaints_in_progress": sum(s["complaints"]["status"]["in_progress"] for s in staff_metrics),
|
||||||
|
"complaints_resolved": sum(s["complaints"]["status"]["resolved"] for s in staff_metrics),
|
||||||
|
"complaints_closed": sum(s["complaints"]["status"]["closed"] for s in staff_metrics),
|
||||||
|
"inquiries_total": sum(s["inquiries"]["total"] for s in staff_metrics),
|
||||||
|
"inquiries_open": sum(s["inquiries"]["status"]["open"] for s in staff_metrics),
|
||||||
|
"inquiries_in_progress": sum(s["inquiries"]["status"]["in_progress"] for s in staff_metrics),
|
||||||
|
"inquiries_resolved": sum(s["inquiries"]["status"]["resolved"] for s in staff_metrics),
|
||||||
|
"inquiries_closed": sum(s["inquiries"]["status"]["closed"] for s in staff_metrics),
|
||||||
|
"inquiries_within_24h": sum(s["inquiries"]["response_time"]["within_24h"] for s in staff_metrics),
|
||||||
|
"inquiries_within_48h": sum(s["inquiries"]["response_time"]["within_48h"] for s in staff_metrics),
|
||||||
|
"inquiries_within_72h": sum(s["inquiries"]["response_time"]["within_72h"] for s in staff_metrics),
|
||||||
|
"inquiries_over_72h": sum(s["inquiries"]["response_time"]["more_than_72h"] for s in staff_metrics),
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"staff_metrics": staff_metrics,
|
"staff_metrics": staff_metrics,
|
||||||
|
"totals": totals,
|
||||||
"start_date": start_date.isoformat(),
|
"start_date": start_date.isoformat(),
|
||||||
"end_date": end_date.isoformat(),
|
"end_date": end_date.isoformat(),
|
||||||
"date_range": date_range,
|
"date_range": date_range,
|
||||||
@ -552,26 +583,44 @@ class UnifiedAnalyticsService:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Source breakdown
|
# Source breakdown — single query
|
||||||
internal_count = complaints_qs.filter(source__name_en="staff").count()
|
source_counts = complaints_qs.values("source__name_en").annotate(count=Count("id"))
|
||||||
|
source_map = {item["source__name_en"] or "": item["count"] for item in source_counts}
|
||||||
|
internal_count = source_map.get("staff", 0)
|
||||||
external_count = total - internal_count
|
external_count = total - internal_count
|
||||||
|
|
||||||
# Status breakdown
|
# Status breakdown — single query
|
||||||
|
status_counts_qs = complaints_qs.values("status").annotate(count=Count("id"))
|
||||||
|
status_map = {item["status"]: item["count"] for item in status_counts_qs}
|
||||||
status_counts = {
|
status_counts = {
|
||||||
"open": complaints_qs.filter(status="open").count(),
|
"open": status_map.get("open", 0),
|
||||||
"in_progress": complaints_qs.filter(status="in_progress").count(),
|
"in_progress": status_map.get("in_progress", 0),
|
||||||
"resolved": complaints_qs.filter(status="resolved").count(),
|
"resolved": status_map.get("resolved", 0),
|
||||||
"closed": complaints_qs.filter(status="closed").count(),
|
"closed": status_map.get("closed", 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Activation time (assigned_at - created_at)
|
# Activation time & response time using subquery to avoid N+1
|
||||||
|
from django.db.models import OuterRef, Subquery
|
||||||
|
from apps.complaints.models import ComplaintUpdate
|
||||||
|
|
||||||
|
first_update_subquery = (
|
||||||
|
ComplaintUpdate.objects.filter(complaint_id=OuterRef("pk")).order_by("created_at").values("created_at")[:1]
|
||||||
|
)
|
||||||
|
annotated_qs = complaints_qs.annotate(first_update_at=Subquery(first_update_subquery))
|
||||||
|
values = annotated_qs.values_list("assigned_at", "created_at", "first_update_at")
|
||||||
|
|
||||||
activation_within_2h = 0
|
activation_within_2h = 0
|
||||||
activation_more_than_2h = 0
|
activation_more_than_2h = 0
|
||||||
not_assigned = 0
|
not_assigned = 0
|
||||||
|
response_within_24h = 0
|
||||||
|
response_within_48h = 0
|
||||||
|
response_within_72h = 0
|
||||||
|
response_more_than_72h = 0
|
||||||
|
not_responded = 0
|
||||||
|
|
||||||
for complaint in complaints_qs:
|
for assigned_at, created_at, first_update_at in values:
|
||||||
if complaint.assigned_at:
|
if assigned_at:
|
||||||
activation_time = (complaint.assigned_at - complaint.created_at).total_seconds()
|
activation_time = (assigned_at - created_at).total_seconds()
|
||||||
if activation_time <= 7200: # 2 hours
|
if activation_time <= 7200: # 2 hours
|
||||||
activation_within_2h += 1
|
activation_within_2h += 1
|
||||||
else:
|
else:
|
||||||
@ -579,17 +628,8 @@ class UnifiedAnalyticsService:
|
|||||||
else:
|
else:
|
||||||
not_assigned += 1
|
not_assigned += 1
|
||||||
|
|
||||||
# Response time (time to first update)
|
if first_update_at:
|
||||||
response_within_24h = 0
|
response_time = (first_update_at - created_at).total_seconds()
|
||||||
response_within_48h = 0
|
|
||||||
response_within_72h = 0
|
|
||||||
response_more_than_72h = 0
|
|
||||||
not_responded = 0
|
|
||||||
|
|
||||||
for complaint in complaints_qs:
|
|
||||||
first_update = complaint.updates.first()
|
|
||||||
if first_update:
|
|
||||||
response_time = (first_update.created_at - complaint.created_at).total_seconds()
|
|
||||||
if response_time <= 86400: # 24 hours
|
if response_time <= 86400: # 24 hours
|
||||||
response_within_24h += 1
|
response_within_24h += 1
|
||||||
elif response_time <= 172800: # 48 hours
|
elif response_time <= 172800: # 48 hours
|
||||||
|
|||||||
@ -141,3 +141,122 @@ def precompute_dashboard_cache_task(self):
|
|||||||
logger.info(f"Precomputed analytics for admin={admin.id}, hospital={hospital.id}")
|
logger.info(f"Precomputed analytics for admin={admin.id}, hospital={hospital.id}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Failed to precompute for admin={admin.id}, hospital={hospital.id}: {e}")
|
logger.exception(f"Failed to precompute for admin={admin.id}, hospital={hospital.id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Hourly AI Analytics Pre-computation Tasks
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _get_system_admin():
|
||||||
|
"""Return a mock admin user for background tasks."""
|
||||||
|
|
||||||
|
class _User:
|
||||||
|
def __init__(self, uid):
|
||||||
|
self.id = uid
|
||||||
|
self.hospital = None
|
||||||
|
self.department = None
|
||||||
|
|
||||||
|
def is_px_admin(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return _User("system")
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, ignore_result=True, max_retries=2, default_retry_delay=60)
|
||||||
|
def precompute_executive_summary_task(self, hospital_id=None, period="30d"):
|
||||||
|
"""Pre-compute executive summary for a single hospital."""
|
||||||
|
from apps.analytics.services.ai_analytics import ExecutiveSummaryGenerator
|
||||||
|
|
||||||
|
user = _get_system_admin()
|
||||||
|
try:
|
||||||
|
result = ExecutiveSummaryGenerator.generate(user, hospital_id=hospital_id, period=period, force_refresh=True)
|
||||||
|
logger.info(
|
||||||
|
f"Precomputed executive summary: period={period} hospital={hospital_id} "
|
||||||
|
f"risk={result.get('risk_level', 'unknown')}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Failed precompute_executive_summary_task: {e}")
|
||||||
|
self.retry(exc=e)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, ignore_result=True, max_retries=2, default_retry_delay=60)
|
||||||
|
def precompute_early_warning_task(self, hospital_id=None, limit=5):
|
||||||
|
"""Pre-compute early warning system for a single hospital."""
|
||||||
|
from apps.analytics.services.ai_analytics import EarlyWarningSystem
|
||||||
|
|
||||||
|
user = _get_system_admin()
|
||||||
|
try:
|
||||||
|
results = EarlyWarningSystem.detect(user, hospital_id=hospital_id, limit=limit)
|
||||||
|
logger.info(f"Precomputed early warnings: hospital={hospital_id} count={len(results)}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Failed precompute_early_warning_task: {e}")
|
||||||
|
self.retry(exc=e)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, ignore_result=True, max_retries=2, default_retry_delay=60)
|
||||||
|
def precompute_complaint_forecast_task(self, hospital_id=None, forecast_days=30):
|
||||||
|
"""Pre-compute complaint volume forecast for a single hospital."""
|
||||||
|
from apps.analytics.services.ai_analytics import ComplaintVolumeForecaster
|
||||||
|
|
||||||
|
user = _get_system_admin()
|
||||||
|
try:
|
||||||
|
result = ComplaintVolumeForecaster.forecast(user, hospital_id=hospital_id, forecast_days=forecast_days)
|
||||||
|
logger.info(f"Precomputed complaint forecast: hospital={hospital_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Failed precompute_complaint_forecast_task: {e}")
|
||||||
|
self.retry(exc=e)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, ignore_result=True, max_retries=2, default_retry_delay=60)
|
||||||
|
def precompute_sla_breach_prediction_task(self, hospital_id=None, limit=10):
|
||||||
|
"""Pre-compute SLA breach predictions for a single hospital."""
|
||||||
|
from apps.analytics.services.ai_analytics import SLABreachPredictor
|
||||||
|
|
||||||
|
user = _get_system_admin()
|
||||||
|
try:
|
||||||
|
results = SLABreachPredictor.predict(user, hospital_id=hospital_id, limit=limit)
|
||||||
|
logger.info(f"Precomputed SLA breach predictions: hospital={hospital_id} count={len(results)}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Failed precompute_sla_breach_prediction_task: {e}")
|
||||||
|
self.retry(exc=e)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, ignore_result=True, max_retries=2, default_retry_delay=60)
|
||||||
|
def precompute_action_recommendations_task(self, hospital_id=None, limit=5):
|
||||||
|
"""Pre-compute action recommendations for a single hospital."""
|
||||||
|
from apps.analytics.services.ai_analytics import ActionRecommendationEngine
|
||||||
|
|
||||||
|
user = _get_system_admin()
|
||||||
|
try:
|
||||||
|
results = ActionRecommendationEngine.generate_recommendations(user, hospital_id=hospital_id, limit=limit)
|
||||||
|
logger.info(f"Precomputed action recommendations: hospital={hospital_id} count={len(results)}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Failed precompute_action_recommendations_task: {e}")
|
||||||
|
self.retry(exc=e)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, ignore_result=True)
|
||||||
|
def precompute_all_ai_analytics_task(self):
|
||||||
|
"""
|
||||||
|
Master task: pre-compute ALL AI analytics for every active hospital every hour.
|
||||||
|
Dispatches sub-tasks per hospital so failures are isolated.
|
||||||
|
"""
|
||||||
|
hospitals = list(Hospital.objects.filter(status="active").values_list("id", flat=True))
|
||||||
|
|
||||||
|
for hid in hospitals:
|
||||||
|
hospital_id = str(hid)
|
||||||
|
precompute_executive_summary_task.delay(hospital_id=hospital_id, period="30d")
|
||||||
|
precompute_early_warning_task.delay(hospital_id=hospital_id, limit=5)
|
||||||
|
precompute_complaint_forecast_task.delay(hospital_id=hospital_id, forecast_days=30)
|
||||||
|
precompute_sla_breach_prediction_task.delay(hospital_id=hospital_id, limit=10)
|
||||||
|
precompute_action_recommendations_task.delay(hospital_id=hospital_id, limit=5)
|
||||||
|
logger.info(f"Dispatched hourly AI precompute tasks for hospital={hospital_id}")
|
||||||
|
|
||||||
|
# Also run for global (no hospital filter)
|
||||||
|
precompute_executive_summary_task.delay(hospital_id=None, period="30d")
|
||||||
|
precompute_early_warning_task.delay(hospital_id=None, limit=5)
|
||||||
|
precompute_complaint_forecast_task.delay(hospital_id=None, forecast_days=30)
|
||||||
|
precompute_sla_breach_prediction_task.delay(hospital_id=None, limit=10)
|
||||||
|
precompute_action_recommendations_task.delay(hospital_id=None, limit=5)
|
||||||
|
logger.info("Dispatched hourly AI precompute tasks for global scope")
|
||||||
|
|||||||
@ -102,16 +102,21 @@ def analytics_dashboard(request):
|
|||||||
feedback_queryset = feedback_queryset.filter(hospital=hospital)
|
feedback_queryset = feedback_queryset.filter(hospital=hospital)
|
||||||
|
|
||||||
# ============ COMPLAINTS KPIs ============
|
# ============ COMPLAINTS KPIs ============
|
||||||
total_complaints = complaints_queryset.count()
|
# Single query for all status counts
|
||||||
open_complaints = complaints_queryset.filter(status="open").count()
|
status_counts_qs = complaints_queryset.values("status").annotate(count=Count("id"))
|
||||||
in_progress_complaints = complaints_queryset.filter(status="in_progress").count()
|
status_map = {item["status"]: item["count"] for item in status_counts_qs}
|
||||||
resolved_complaints = complaints_queryset.filter(status="resolved").count()
|
total_complaints = sum(status_map.values())
|
||||||
closed_complaints = complaints_queryset.filter(status="closed").count()
|
open_complaints = status_map.get("open", 0)
|
||||||
|
in_progress_complaints = status_map.get("in_progress", 0)
|
||||||
|
resolved_complaints = status_map.get("resolved", 0)
|
||||||
|
closed_complaints = status_map.get("closed", 0)
|
||||||
overdue_complaints = complaints_queryset.filter(is_overdue=True).count()
|
overdue_complaints = complaints_queryset.filter(is_overdue=True).count()
|
||||||
|
|
||||||
# Complaint source types (internal vs external)
|
# Complaint source types (internal vs external) — single query
|
||||||
internal_complaints = complaints_queryset.filter(complaint_source_type="internal").count()
|
source_type_counts = complaints_queryset.values("complaint_source_type").annotate(count=Count("id"))
|
||||||
external_complaints = complaints_queryset.filter(complaint_source_type="external").count()
|
source_type_map = {item["complaint_source_type"]: item["count"] for item in source_type_counts}
|
||||||
|
internal_complaints = source_type_map.get("internal", 0)
|
||||||
|
external_complaints = source_type_map.get("external", 0)
|
||||||
|
|
||||||
# Complaint sources (by PXSource name)
|
# Complaint sources (by PXSource name)
|
||||||
complaint_sources = (
|
complaint_sources = (
|
||||||
@ -137,28 +142,30 @@ def analytics_dashboard(request):
|
|||||||
.order_by("-count")[:5]
|
.order_by("-count")[:5]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Complaint severity - build explicit counts for template
|
# Complaint severity — single query
|
||||||
severity_counts = complaints_queryset.values("severity").annotate(count=Count("id"))
|
severity_counts_qs = complaints_queryset.values("severity").annotate(count=Count("id"))
|
||||||
severity_map = {item["severity"]: item["count"] for item in severity_counts}
|
severity_map = {item["severity"]: item["count"] for item in severity_counts_qs}
|
||||||
critical_complaints = severity_map.get("critical", 0)
|
critical_complaints = severity_map.get("critical", 0)
|
||||||
high_complaints = severity_map.get("high", 0)
|
high_complaints = severity_map.get("high", 0)
|
||||||
medium_complaints = severity_map.get("medium", 0)
|
medium_complaints = severity_map.get("medium", 0)
|
||||||
low_complaints = severity_map.get("low", 0)
|
low_complaints = severity_map.get("low", 0)
|
||||||
|
|
||||||
# Severity breakdown for JSON
|
# Severity breakdown for JSON
|
||||||
severity_breakdown = complaints_queryset.values("severity").annotate(count=Count("id")).order_by("-count")
|
severity_breakdown = severity_counts_qs.order_by("-count")
|
||||||
|
|
||||||
# Status breakdown
|
# Status breakdown
|
||||||
status_breakdown = complaints_queryset.values("status").annotate(count=Count("id")).order_by("-count")
|
status_breakdown = status_counts_qs.order_by("-count")
|
||||||
|
|
||||||
# ============ ACTIONS KPIs ============
|
# ============ ACTIONS KPIs ============
|
||||||
total_actions = actions_queryset.count()
|
action_status_counts = actions_queryset.values("status").annotate(count=Count("id"))
|
||||||
open_actions = actions_queryset.filter(status="open").count()
|
action_status_map = {item["status"]: item["count"] for item in action_status_counts}
|
||||||
in_progress_actions = actions_queryset.filter(status="in_progress").count()
|
total_actions = sum(action_status_map.values())
|
||||||
approved_actions = actions_queryset.filter(status="approved").count()
|
open_actions = action_status_map.get("open", 0)
|
||||||
closed_actions = actions_queryset.filter(status="closed").count()
|
in_progress_actions = action_status_map.get("in_progress", 0)
|
||||||
|
approved_actions = action_status_map.get("approved", 0)
|
||||||
|
closed_actions = action_status_map.get("closed", 0)
|
||||||
|
pending_actions = action_status_map.get("pending_approval", 0)
|
||||||
overdue_actions = actions_queryset.filter(is_overdue=True).count()
|
overdue_actions = actions_queryset.filter(is_overdue=True).count()
|
||||||
pending_actions = actions_queryset.filter(status="pending_approval").count()
|
|
||||||
|
|
||||||
# Action sources
|
# Action sources
|
||||||
action_sources = (
|
action_sources = (
|
||||||
@ -184,21 +191,25 @@ def analytics_dashboard(request):
|
|||||||
avg_survey_score = surveys_queryset.aggregate(avg=Avg("total_score"))["avg"] or 0
|
avg_survey_score = surveys_queryset.aggregate(avg=Avg("total_score"))["avg"] or 0
|
||||||
negative_surveys = surveys_queryset.filter(is_negative=True).count()
|
negative_surveys = surveys_queryset.filter(is_negative=True).count()
|
||||||
|
|
||||||
# Survey completion rate
|
# Survey completion rate — single query
|
||||||
all_surveys = SurveyInstance.objects.all()
|
all_surveys = SurveyInstance.objects.all()
|
||||||
if hospital:
|
if hospital:
|
||||||
all_surveys = all_surveys.filter(survey_template__hospital=hospital)
|
all_surveys = all_surveys.filter(survey_template__hospital=hospital)
|
||||||
total_sent = all_surveys.count()
|
survey_status_counts = all_surveys.values("status").annotate(count=Count("id"))
|
||||||
completed_surveys = all_surveys.filter(status="completed").count()
|
survey_status_map = {item["status"]: item["count"] for item in survey_status_counts}
|
||||||
|
total_sent = sum(survey_status_map.values())
|
||||||
|
completed_surveys = survey_status_map.get("completed", 0)
|
||||||
completion_rate = (completed_surveys / total_sent * 100) if total_sent > 0 else 0
|
completion_rate = (completed_surveys / total_sent * 100) if total_sent > 0 else 0
|
||||||
|
|
||||||
# Survey types
|
# Survey types
|
||||||
survey_types = all_surveys.values("survey_template__survey_type").annotate(count=Count("id")).order_by("-count")[:5]
|
survey_types = all_surveys.values("survey_template__survey_type").annotate(count=Count("id")).order_by("-count")[:5]
|
||||||
|
|
||||||
# ============ FEEDBACK KPIs ============
|
# ============ FEEDBACK KPIs ============
|
||||||
total_feedback = feedback_queryset.count()
|
feedback_type_counts = feedback_queryset.values("feedback_type").annotate(count=Count("id"))
|
||||||
compliments = feedback_queryset.filter(feedback_type="compliment").count()
|
feedback_type_map = {item["feedback_type"]: item["count"] for item in feedback_type_counts}
|
||||||
suggestions = feedback_queryset.filter(feedback_type="suggestion").count()
|
total_feedback = sum(feedback_type_map.values())
|
||||||
|
compliments = feedback_type_map.get("compliment", 0)
|
||||||
|
suggestions = feedback_type_map.get("suggestion", 0)
|
||||||
|
|
||||||
# Sentiment analysis
|
# Sentiment analysis
|
||||||
sentiment_breakdown = feedback_queryset.values("sentiment").annotate(count=Count("id")).order_by("-count")
|
sentiment_breakdown = feedback_queryset.values("sentiment").annotate(count=Count("id")).order_by("-count")
|
||||||
@ -253,48 +264,38 @@ def analytics_dashboard(request):
|
|||||||
survey_trend_values.append(found if found is not None else 0)
|
survey_trend_values.append(found if found is not None else 0)
|
||||||
|
|
||||||
# ============ DEPARTMENT RANKINGS ============
|
# ============ DEPARTMENT RANKINGS ============
|
||||||
|
dept_base_qs = Department.objects.filter(status="active")
|
||||||
|
if hospital:
|
||||||
|
dept_base_qs = dept_base_qs.filter(hospital=hospital)
|
||||||
|
|
||||||
department_rankings = (
|
department_rankings = (
|
||||||
Department.objects.filter(status="active")
|
dept_base_qs.annotate(
|
||||||
.annotate(
|
|
||||||
avg_score=Avg(
|
avg_score=Avg(
|
||||||
"journey_instances__surveys__total_score", filter=Q(journey_instances__surveys__status="completed")
|
"journey_instances__surveys__total_score", filter=Q(journey_instances__surveys__status="completed")
|
||||||
),
|
),
|
||||||
survey_count=Count("journey_instances__surveys", filter=Q(journey_instances__surveys__status="completed")),
|
survey_count=Count("journey_instances__surveys", filter=Q(journey_instances__surveys__status="completed")),
|
||||||
complaint_count=Count("complaints"),
|
complaint_count=Count("complaints"),
|
||||||
|
resolved_count=Count("complaints", filter=Q(complaints__status__in=["resolved", "closed"])),
|
||||||
action_count=Count("px_actions"),
|
action_count=Count("px_actions"),
|
||||||
)
|
)
|
||||||
.filter(survey_count__gt=0)
|
.filter(survey_count__gt=0)
|
||||||
.order_by("-avg_score")[:7]
|
.order_by("-avg_score")[:7]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build department_stats list with resolution rate calculation
|
# Build department_stats list — all data now comes from annotations, zero extra queries
|
||||||
department_stats = []
|
department_stats = []
|
||||||
for dept in department_rankings:
|
for dept in department_rankings:
|
||||||
dept_complaints = (
|
resolution_rate = (
|
||||||
complaints_queryset.filter(department=dept).count()
|
round((dept.resolved_count / dept.complaint_count * 100), 1) if dept.complaint_count > 0 else 0
|
||||||
if hospital
|
|
||||||
else Complaint.objects.filter(department=dept).count()
|
|
||||||
)
|
)
|
||||||
dept_actions = (
|
|
||||||
actions_queryset.filter(department=dept).count()
|
|
||||||
if hospital
|
|
||||||
else PXAction.objects.filter(department=dept).count()
|
|
||||||
)
|
|
||||||
dept_resolved = (
|
|
||||||
complaints_queryset.filter(department=dept, status__in=["resolved", "closed"]).count()
|
|
||||||
if hospital
|
|
||||||
else Complaint.objects.filter(department=dept, status__in=["resolved", "closed"]).count()
|
|
||||||
)
|
|
||||||
resolution_rate = round((dept_resolved / dept_complaints * 100), 1) if dept_complaints > 0 else 0
|
|
||||||
|
|
||||||
department_stats.append(
|
department_stats.append(
|
||||||
{
|
{
|
||||||
"name_en": dept.name_en if hasattr(dept, "name_en") else str(dept),
|
"name_en": dept.name_en if hasattr(dept, "name_en") else str(dept),
|
||||||
"name_ar": dept.name_ar
|
"name_ar": dept.name_ar
|
||||||
if hasattr(dept, "name_ar")
|
if hasattr(dept, "name_ar")
|
||||||
else (dept.name_en if hasattr(dept, "name_en") else str(dept)),
|
else (dept.name_en if hasattr(dept, "name_en") else str(dept)),
|
||||||
"complaints": dept_complaints,
|
"complaints": dept.complaint_count,
|
||||||
"actions": dept_actions,
|
"actions": dept.action_count,
|
||||||
"survey_avg": round(dept.avg_score, 2) if dept.avg_score else 0,
|
"survey_avg": round(dept.avg_score, 2) if dept.avg_score else 0,
|
||||||
"resolution_rate": resolution_rate,
|
"resolution_rate": resolution_rate,
|
||||||
}
|
}
|
||||||
@ -406,20 +407,36 @@ def analytics_dashboard(request):
|
|||||||
generate_executive_summary_task.delay(user_id=str(user.id), hospital_id=hospital_id, period="30d")
|
generate_executive_summary_task.delay(user_id=str(user.id), hospital_id=hospital_id, period="30d")
|
||||||
generate_action_recommendations_task.delay(user_id=str(user.id), hospital_id=hospital_id)
|
generate_action_recommendations_task.delay(user_id=str(user.id), hospital_id=hospital_id)
|
||||||
|
|
||||||
# 1. Executive Summary — read from cache (populated by Celery or fallback)
|
# Read AI analytics from cache ONLY (populated hourly by Celery beat).
|
||||||
exec_summary = ExecutiveSummaryGenerator.generate(user, hospital_id=hospital_id, period="30d")
|
# If cache miss, return lightweight placeholders so page loads instantly.
|
||||||
|
exec_summary = cache.get(ExecutiveSummaryGenerator._cache_key(hospital_id, None, "30d"))
|
||||||
|
if not exec_summary:
|
||||||
|
exec_summary = {
|
||||||
|
"summary_en": "Executive summary is being computed in the background...",
|
||||||
|
"summary_ar": "جاري حساب الملخص التنفيذي في الخلفية...",
|
||||||
|
"key_findings_en": [],
|
||||||
|
"key_findings_ar": [],
|
||||||
|
"recommendations_en": [],
|
||||||
|
"recommendations_ar": [],
|
||||||
|
"risk_level": "medium",
|
||||||
|
"_data": {},
|
||||||
|
}
|
||||||
|
|
||||||
# 2. Early Warning System
|
early_warnings = cache.get(EarlyWarningSystem._cache_key(hospital_id, 5))
|
||||||
early_warnings = EarlyWarningSystem.detect(user, hospital_id=hospital_id, limit=5)
|
if early_warnings is None:
|
||||||
|
early_warnings = []
|
||||||
|
|
||||||
# 3. Complaint Volume Forecast
|
complaint_forecast = cache.get(ComplaintVolumeForecaster._cache_key(hospital_id, 30))
|
||||||
complaint_forecast = ComplaintVolumeForecaster.forecast(user, hospital_id=hospital_id, forecast_days=30)
|
if not complaint_forecast:
|
||||||
|
complaint_forecast = ComplaintVolumeForecaster._insufficient_data_response(30)
|
||||||
|
|
||||||
# 4. SLA Breach Predictions
|
sla_breach_predictions = cache.get(SLABreachPredictor._cache_key(hospital_id, 10))
|
||||||
sla_breach_predictions = SLABreachPredictor.predict(user, hospital_id=hospital_id, limit=10)
|
if sla_breach_predictions is None:
|
||||||
|
sla_breach_predictions = []
|
||||||
|
|
||||||
# 5. Action Recommendations — read from cache
|
action_recommendations = cache.get(ActionRecommendationEngine._cache_key(hospital_id, None, 5))
|
||||||
action_recommendations = ActionRecommendationEngine.generate_recommendations(user, hospital_id=hospital_id, limit=5)
|
if not action_recommendations:
|
||||||
|
action_recommendations = ActionRecommendationEngine._no_data_response()
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"kpis": kpis,
|
"kpis": kpis,
|
||||||
@ -447,8 +464,8 @@ def analytics_dashboard(request):
|
|||||||
"action_recommendations": action_recommendations,
|
"action_recommendations": action_recommendations,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Clear old cache (the new data isn't in the old cache entries)
|
# Cache the full dashboard context for 5 minutes so next load is instant
|
||||||
cache.delete(cache_key)
|
cache.set(cache_key, context, 300)
|
||||||
|
|
||||||
return render(request, "analytics/dashboard.html", context)
|
return render(request, "analytics/dashboard.html", context)
|
||||||
|
|
||||||
@ -477,8 +494,13 @@ def refresh_ai_analytics(request):
|
|||||||
generate_action_recommendations_task.delay(user_id=str(user.id), hospital_id=hospital_id)
|
generate_action_recommendations_task.delay(user_id=str(user.id), hospital_id=hospital_id)
|
||||||
|
|
||||||
# Also clear caches so next page load triggers fresh computation
|
# Also clear caches so next page load triggers fresh computation
|
||||||
cache.delete(f"exec_summary_{user.id}_{hospital_id}_30d")
|
from apps.analytics.services.ai_analytics import (
|
||||||
cache.delete(f"action_recommendations_{user.id}_{hospital_id}_5")
|
ExecutiveSummaryGenerator,
|
||||||
|
ActionRecommendationEngine,
|
||||||
|
)
|
||||||
|
|
||||||
|
cache.delete(ExecutiveSummaryGenerator._cache_key(hospital_id, None, "30d"))
|
||||||
|
cache.delete(ActionRecommendationEngine._cache_key(hospital_id, None, 5))
|
||||||
|
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
{"status": "triggered", "message": "AI analytics refresh queued. Results will be available in ~30 seconds."}
|
{"status": "triggered", "message": "AI analytics refresh queued. Results will be available in ~30 seconds."}
|
||||||
@ -490,8 +512,13 @@ def refresh_ai_analytics(request):
|
|||||||
)
|
)
|
||||||
user = request.user
|
user = request.user
|
||||||
|
|
||||||
summary_cached = cache.get(f"exec_summary_{user.id}_{hospital_id}_30d")
|
from apps.analytics.services.ai_analytics import (
|
||||||
recommendations_cached = cache.get(f"action_recommendations_{user.id}_{hospital_id}_5")
|
ExecutiveSummaryGenerator,
|
||||||
|
ActionRecommendationEngine,
|
||||||
|
)
|
||||||
|
|
||||||
|
summary_cached = cache.get(ExecutiveSummaryGenerator._cache_key(hospital_id, None, "30d"))
|
||||||
|
recommendations_cached = cache.get(ActionRecommendationEngine._cache_key(hospital_id, None, 5))
|
||||||
|
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
{
|
{
|
||||||
@ -517,20 +544,22 @@ def refresh_dashboard_cache(request):
|
|||||||
from .tasks import precompute_dashboard_cache_task
|
from .tasks import precompute_dashboard_cache_task
|
||||||
|
|
||||||
user = request.user
|
user = request.user
|
||||||
|
|
||||||
# Trigger async cache refresh
|
# Trigger async cache refresh
|
||||||
task = precompute_dashboard_cache_task.delay()
|
task = precompute_dashboard_cache_task.delay()
|
||||||
|
|
||||||
# Clear user's dashboard cache so next load gets fresh data
|
# Clear user's dashboard cache so next load gets fresh data
|
||||||
cache.delete(f"analytics_dashboard_{user.id}_all")
|
cache.delete(f"analytics_dashboard_{user.id}_all")
|
||||||
if hasattr(request, "tenant_hospital") and request.tenant_hospital:
|
if hasattr(request, "tenant_hospital") and request.tenant_hospital:
|
||||||
cache.delete(f"analytics_dashboard_{user.id}_{request.tenant_hospital.id}")
|
cache.delete(f"analytics_dashboard_{user.id}_{request.tenant_hospital.id}")
|
||||||
|
|
||||||
return JsonResponse({
|
return JsonResponse(
|
||||||
"status": "triggered",
|
{
|
||||||
"message": "Dashboard cache refresh queued. Please reload the page in a few seconds.",
|
"status": "triggered",
|
||||||
"task_id": str(task.id),
|
"message": "Dashboard cache refresh queued. Please reload the page in a few seconds.",
|
||||||
})
|
"task_id": str(task.id),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@block_source_user
|
@block_source_user
|
||||||
@ -647,19 +676,45 @@ def command_center(request):
|
|||||||
user_id=str(user.id), hospital_id=hospital_id, department_id=department_id
|
user_id=str(user.id), hospital_id=hospital_id, department_id=department_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Read AI analytics from cache ONLY (never block request with LLM calls)
|
||||||
|
exec_summary = cache.get(ExecutiveSummaryGenerator._cache_key(hospital_id, department_id, filters["date_range"]))
|
||||||
|
if not exec_summary:
|
||||||
|
exec_summary = {
|
||||||
|
"summary_en": "Executive summary is being computed in the background...",
|
||||||
|
"summary_ar": "جاري حساب الملخص التنفيذي في الخلفية...",
|
||||||
|
"key_findings_en": [],
|
||||||
|
"key_findings_ar": [],
|
||||||
|
"recommendations_en": [],
|
||||||
|
"recommendations_ar": [],
|
||||||
|
"risk_level": "medium",
|
||||||
|
"_data": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
early_warnings = cache.get(EarlyWarningSystem._cache_key(hospital_id, 5))
|
||||||
|
if early_warnings is None:
|
||||||
|
early_warnings = []
|
||||||
|
|
||||||
|
complaint_forecast = cache.get(ComplaintVolumeForecaster._cache_key(hospital_id, 30))
|
||||||
|
if not complaint_forecast:
|
||||||
|
complaint_forecast = ComplaintVolumeForecaster._insufficient_data_response(30)
|
||||||
|
|
||||||
|
sla_breach_predictions = cache.get(SLABreachPredictor._cache_key(hospital_id, 10))
|
||||||
|
if sla_breach_predictions is None:
|
||||||
|
sla_breach_predictions = []
|
||||||
|
|
||||||
|
action_recommendations = cache.get(ActionRecommendationEngine._cache_key(hospital_id, department_id, 5))
|
||||||
|
if not action_recommendations:
|
||||||
|
action_recommendations = ActionRecommendationEngine._no_data_response()
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"filters": filters,
|
"filters": filters,
|
||||||
"departments": departments,
|
"departments": departments,
|
||||||
"kpis": kpis,
|
"kpis": kpis,
|
||||||
"exec_summary": ExecutiveSummaryGenerator.generate(
|
"exec_summary": exec_summary,
|
||||||
user, hospital_id=hospital_id, department_id=department_id, period=filters["date_range"]
|
"early_warnings": early_warnings,
|
||||||
),
|
"complaint_forecast": complaint_forecast,
|
||||||
"early_warnings": EarlyWarningSystem.detect(user, hospital_id=hospital_id, limit=5),
|
"sla_breach_predictions": sla_breach_predictions,
|
||||||
"complaint_forecast": ComplaintVolumeForecaster.forecast(user, hospital_id=hospital_id, forecast_days=30),
|
"action_recommendations": action_recommendations,
|
||||||
"sla_breach_predictions": SLABreachPredictor.predict(user, hospital_id=hospital_id, limit=10),
|
|
||||||
"action_recommendations": ActionRecommendationEngine.generate_recommendations(
|
|
||||||
user, hospital_id=hospital_id, department_id=department_id, limit=5
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, "analytics/command_center.html", context)
|
return render(request, "analytics/command_center.html", context)
|
||||||
@ -859,17 +914,42 @@ def command_center_api(request):
|
|||||||
user_id=str(user.id), hospital_id=hospital_id, department_id=department_id
|
user_id=str(user.id), hospital_id=hospital_id, department_id=department_id
|
||||||
)
|
)
|
||||||
|
|
||||||
# AI features — read from cache (populated by Celery precompute or on-demand)
|
# AI features — read from cache ONLY (never block request with LLM calls)
|
||||||
|
exec_summary = cache.get(ExecutiveSummaryGenerator._cache_key(hospital_id, department_id, date_range))
|
||||||
|
if not exec_summary:
|
||||||
|
exec_summary = {
|
||||||
|
"summary_en": "Executive summary is being computed in the background...",
|
||||||
|
"summary_ar": "جاري حساب الملخص التنفيذي في الخلفية...",
|
||||||
|
"key_findings_en": [],
|
||||||
|
"key_findings_ar": [],
|
||||||
|
"recommendations_en": [],
|
||||||
|
"recommendations_ar": [],
|
||||||
|
"risk_level": "medium",
|
||||||
|
"_data": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
early_warnings = cache.get(EarlyWarningSystem._cache_key(hospital_id, 5))
|
||||||
|
if early_warnings is None:
|
||||||
|
early_warnings = []
|
||||||
|
|
||||||
|
complaint_forecast = cache.get(ComplaintVolumeForecaster._cache_key(hospital_id, 30))
|
||||||
|
if not complaint_forecast:
|
||||||
|
complaint_forecast = ComplaintVolumeForecaster._insufficient_data_response(30)
|
||||||
|
|
||||||
|
sla_breach_predictions = cache.get(SLABreachPredictor._cache_key(hospital_id, 10))
|
||||||
|
if sla_breach_predictions is None:
|
||||||
|
sla_breach_predictions = []
|
||||||
|
|
||||||
|
action_recommendations = cache.get(ActionRecommendationEngine._cache_key(hospital_id, department_id, 5))
|
||||||
|
if not action_recommendations:
|
||||||
|
action_recommendations = ActionRecommendationEngine._no_data_response()
|
||||||
|
|
||||||
ai_data = {
|
ai_data = {
|
||||||
"executive_summary": ExecutiveSummaryGenerator.generate(
|
"executive_summary": exec_summary,
|
||||||
user, hospital_id=hospital_id, department_id=department_id, period=date_range
|
"early_warnings": early_warnings,
|
||||||
),
|
"complaint_forecast": complaint_forecast,
|
||||||
"early_warnings": EarlyWarningSystem.detect(user, hospital_id=hospital_id, limit=5),
|
"sla_breach_predictions": sla_breach_predictions,
|
||||||
"complaint_forecast": ComplaintVolumeForecaster.forecast(user, hospital_id=hospital_id, forecast_days=30),
|
"action_recommendations": action_recommendations,
|
||||||
"sla_breach_predictions": SLABreachPredictor.predict(user, hospital_id=hospital_id, limit=10),
|
|
||||||
"action_recommendations": ActionRecommendationEngine.generate_recommendations(
|
|
||||||
user, hospital_id=hospital_id, department_id=department_id, limit=5
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return JsonResponse({"kpis": kpis, "charts": charts, "tables": tables, "ai": ai_data})
|
return JsonResponse({"kpis": kpis, "charts": charts, "tables": tables, "ai": ai_data})
|
||||||
|
|||||||
@ -0,0 +1,70 @@
|
|||||||
|
"""
|
||||||
|
Source mapping configuration for historical complaint import.
|
||||||
|
|
||||||
|
Maps Excel 'جهة الشكوى' values to PXSource objects.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from apps.px_sources.models import PXSource
|
||||||
|
|
||||||
|
|
||||||
|
SOURCE_CODE_MAP = {
|
||||||
|
# Arabic sources
|
||||||
|
"وزارة الصحة": "MOH",
|
||||||
|
"مراجع": "PATIENT",
|
||||||
|
"المراجع": "PATIENT",
|
||||||
|
"ذوي المراجع": "FAMILY",
|
||||||
|
"مجلس الضمان الصحي": "CHI",
|
||||||
|
# English / code sources
|
||||||
|
"moh": "MOH",
|
||||||
|
"cchi": "CCHI",
|
||||||
|
"chi": "CHI",
|
||||||
|
"patients": "PATIENT",
|
||||||
|
"patient": "PATIENT",
|
||||||
|
"patient relatives": "FAMILY",
|
||||||
|
"patient's relatives": "FAMILY",
|
||||||
|
"family member": "FAMILY",
|
||||||
|
"call center": "CALL-CENTER",
|
||||||
|
"call-center": "CALL-CENTER",
|
||||||
|
"staff": "STAFF",
|
||||||
|
"survey": "SURVEY",
|
||||||
|
"social media": "SOCIAL-MEDIA",
|
||||||
|
"social-media": "SOCIAL-MEDIA",
|
||||||
|
"public form": "PUBL-FORM",
|
||||||
|
"insurance company": None,
|
||||||
|
"شركة تأمين": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_px_source(source_value: str) -> "PXSource | None":
|
||||||
|
"""
|
||||||
|
Resolve an Excel source string to a PXSource instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_value: Value from the 'جهة الشكوى' column.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PXSource instance or None if unmapped/empty.
|
||||||
|
"""
|
||||||
|
if not source_value:
|
||||||
|
return None
|
||||||
|
|
||||||
|
normalized = str(source_value).strip()
|
||||||
|
if not normalized or normalized.replace(".", "", 1).isdigit():
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Direct lookup (case-insensitive)
|
||||||
|
code = SOURCE_CODE_MAP.get(normalized.lower())
|
||||||
|
if code is None:
|
||||||
|
# Try stripping trailing spaces and retry
|
||||||
|
code = SOURCE_CODE_MAP.get(normalized.lower().strip())
|
||||||
|
|
||||||
|
if code is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if code == "":
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return PXSource.objects.get(code=code)
|
||||||
|
except PXSource.DoesNotExist:
|
||||||
|
return None
|
||||||
@ -1,17 +1,16 @@
|
|||||||
"""
|
"""
|
||||||
Import 2025 complaints from Excel with basic fields (no AI, skip missing columns).
|
Import 2025 complaints from Excel with basic fields (no AI, skip missing columns).
|
||||||
|
|
||||||
2025 has different structure than 2022-2024:
|
Dynamically detects header row and column positions per sheet because 2025
|
||||||
- No 4-level taxonomy (skip)
|
monthly sheets have inconsistent layouts.
|
||||||
- No Staff ID column (use staff_name text only)
|
|
||||||
- No Rightful Side column (skip)
|
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python manage.py import_2025_complaints_basic "Complaints Report - 2025.xlsx" --sheet="JAN"
|
python manage.py import_2025_complaints_basic "Complaints Report - 2025.xlsx" --sheet="JAN"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
@ -22,34 +21,43 @@ from apps.accounts.models import User
|
|||||||
from apps.complaints.models import Complaint
|
from apps.complaints.models import Complaint
|
||||||
from apps.organizations.models import Hospital, Location, MainSection, SubSection
|
from apps.organizations.models import Hospital, Location, MainSection, SubSection
|
||||||
|
|
||||||
|
from .complaint_source_mapping import resolve_px_source
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_HOSPITAL_CODE = "NUZHA-DEV"
|
DEFAULT_HOSPITAL_CODE = "NUZHA"
|
||||||
|
|
||||||
# 2025 Column mapping (different from 2022-2024)
|
# Header aliases: list of possible names in Excel for each field
|
||||||
COLUMN_MAPPING = {
|
HEADER_ALIASES = {
|
||||||
"complaint_num": 3, # رقم الشكوى
|
"complaint_num": ["رقم الشكوى"],
|
||||||
"mrn": 4, # رقم الملف
|
"mrn": ["رقم الملف"],
|
||||||
"source": 5, # جهة الشكوى
|
"source": ["جهة الشكوى"],
|
||||||
"location_name": 6, # الموقع
|
"location_name": ["الموقع"],
|
||||||
"main_dept_name": 7, # القسم الرئيس
|
"main_dept_name": ["القسم الرئيس"],
|
||||||
"sub_dept_name": 8, # القسم الفرعي
|
"sub_dept_name": ["القسم الفرعي"],
|
||||||
"date_received": 9, # تاريخ إستلام الشكوى
|
"date_received": ["تاريخ إستلام الشكوى"],
|
||||||
"data_entry_person": 10, # المدخل
|
"data_entry_person": ["المدخل"],
|
||||||
"response_date": 48, # تاريخ الرد (was Staff ID in 2022-2024)
|
"response_date": ["تاريخ الرد"],
|
||||||
"staff_name": 51, # اسم الشخص المشتكى عليه (was col 49)
|
"staff_name": ["اسم الشخص المشتكى عليه - ان وجد", "اسم الشخص المشتكى عليه"],
|
||||||
# Skip cols 52-53 (Complain Classification, Main Subject)
|
"description_ar": ["الشكوى باختصار (عربي)", "محتوى الشكوى (عربي)"],
|
||||||
"description_ar": 54, # محتوى الشكوى (عربي)
|
"description_en": ["الشكوى باختصار English", "محتوى الشكوى (English)"],
|
||||||
"description_en": 55, # محتوى الشكوى (English)
|
"satisfaction": ["توثيق تذكيرات للقسم المشتكى عليه"],
|
||||||
"satisfaction": 56, # توثيق تذكيرات للقسم المشتكى عليه
|
"reminder_date": ["تاريخ التذكير"],
|
||||||
"reminder_date": 57, # تاريخ التذكير
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Month mapping for 2025 sheet names (3-letter abbreviations)
|
|
||||||
MONTH_MAP = {
|
MONTH_MAP = {
|
||||||
"JAN": "01", "FEB": "02", "MAR": "03", "APR": "04",
|
"JAN": "01",
|
||||||
"MAY": "05", "JUN": "06", "JUL": "07", "AUG": "08",
|
"FEB": "02",
|
||||||
"SEP": "09", "OCT": "10", "NOV": "11", "DEC": "12",
|
"MAR": "03",
|
||||||
|
"APR": "04",
|
||||||
|
"MAY": "05",
|
||||||
|
"JUN": "06",
|
||||||
|
"JUL": "07",
|
||||||
|
"AUG": "08",
|
||||||
|
"SEP": "09",
|
||||||
|
"OCT": "10",
|
||||||
|
"NOV": "11",
|
||||||
|
"DEC": "12",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -60,25 +68,22 @@ class Command(BaseCommand):
|
|||||||
parser.add_argument("excel_file", type=str)
|
parser.add_argument("excel_file", type=str)
|
||||||
parser.add_argument("--sheet", type=str, default="JAN")
|
parser.add_argument("--sheet", type=str, default="JAN")
|
||||||
parser.add_argument("--dry-run", action="store_true")
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
parser.add_argument("--start-row", type=int, default=3)
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
self.excel_file = options["excel_file"]
|
self.excel_file = options["excel_file"]
|
||||||
self.sheet_name = options["sheet"]
|
self.sheet_name = options["sheet"]
|
||||||
self.dry_run = options["dry_run"]
|
self.dry_run = options["dry_run"]
|
||||||
self.start_row = options["start_row"]
|
|
||||||
|
|
||||||
# Load hospital
|
|
||||||
self.hospital = self._load_hospital()
|
self.hospital = self._load_hospital()
|
||||||
if not self.hospital:
|
if not self.hospital:
|
||||||
raise CommandError(f'Hospital "{DEFAULT_HOSPITAL_CODE}" not found')
|
raise CommandError(f'Hospital "{DEFAULT_HOSPITAL_CODE}" not found')
|
||||||
|
|
||||||
self.stdout.write(f"Using hospital: {self.hospital.name}")
|
self.stdout.write(f"Using hospital: {self.hospital.name}")
|
||||||
|
|
||||||
# Load Excel
|
|
||||||
try:
|
try:
|
||||||
import openpyxl
|
import openpyxl
|
||||||
self.wb = openpyxl.load_workbook(self.excel_file)
|
|
||||||
|
self.wb = openpyxl.load_workbook(self.excel_file, read_only=True, data_only=True)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise CommandError("openpyxl required: pip install openpyxl")
|
raise CommandError("openpyxl required: pip install openpyxl")
|
||||||
|
|
||||||
@ -89,11 +94,17 @@ class Command(BaseCommand):
|
|||||||
self.ws = self.wb[self.sheet_name]
|
self.ws = self.wb[self.sheet_name]
|
||||||
self.stdout.write(f"Processing sheet: {self.sheet_name}")
|
self.stdout.write(f"Processing sheet: {self.sheet_name}")
|
||||||
|
|
||||||
# Stats
|
# Detect header row and build column mapping
|
||||||
|
self.column_map = self._detect_columns()
|
||||||
|
if not self.column_map.get("complaint_num"):
|
||||||
|
raise CommandError("Could not detect 'رقم الشكوى' header in sheet")
|
||||||
|
|
||||||
|
self.stdout.write(f"Detected columns: {self.column_map}")
|
||||||
|
|
||||||
self.stats = {"processed": 0, "success": 0, "failed": 0}
|
self.stats = {"processed": 0, "success": 0, "failed": 0}
|
||||||
self.errors = []
|
self.errors = []
|
||||||
|
self.used_refs = set()
|
||||||
|
|
||||||
# Process
|
|
||||||
self._process_sheet()
|
self._process_sheet()
|
||||||
self._print_report()
|
self._print_report()
|
||||||
|
|
||||||
@ -103,12 +114,34 @@ class Command(BaseCommand):
|
|||||||
except Hospital.DoesNotExist:
|
except Hospital.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _process_sheet(self):
|
def _detect_columns(self) -> Dict[str, int]:
|
||||||
row_num = self.start_row
|
"""Scan first 10 rows to find header row and map columns."""
|
||||||
|
mapping = {}
|
||||||
|
for r in range(1, 11):
|
||||||
|
row_values = {}
|
||||||
|
for c in range(1, 80):
|
||||||
|
val = self.ws.cell(r, c).value
|
||||||
|
if val:
|
||||||
|
# Keep first occurrence only
|
||||||
|
if val not in row_values:
|
||||||
|
row_values[val] = c
|
||||||
|
# Check if this row contains the complaint number header
|
||||||
|
if "رقم الشكوى" in row_values:
|
||||||
|
for field, aliases in HEADER_ALIASES.items():
|
||||||
|
for alias in aliases:
|
||||||
|
if alias in row_values:
|
||||||
|
mapping[field] = row_values[alias]
|
||||||
|
break
|
||||||
|
self.header_row = r
|
||||||
|
break
|
||||||
|
return mapping
|
||||||
|
|
||||||
while row_num <= self.ws.max_row:
|
def _process_sheet(self):
|
||||||
|
row_num = self.header_row + 1
|
||||||
|
|
||||||
|
for row in self.ws.iter_rows(min_row=self.header_row + 1, max_row=5000, values_only=True):
|
||||||
try:
|
try:
|
||||||
row_data = self._extract_row_data(row_num)
|
row_data = self._extract_row_data_from_values(row)
|
||||||
|
|
||||||
if not row_data.get("complaint_num"):
|
if not row_data.get("complaint_num"):
|
||||||
row_num += 1
|
row_num += 1
|
||||||
@ -116,29 +149,27 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
self.stats["processed"] += 1
|
self.stats["processed"] += 1
|
||||||
|
|
||||||
# Build reference number
|
try:
|
||||||
ref_num = self._build_reference_number(row_data["complaint_num"])
|
ref_num = self._get_unique_reference_number(row_data["complaint_num"])
|
||||||
|
except (ValueError, TypeError):
|
||||||
# Check for duplicate
|
|
||||||
if Complaint.objects.filter(reference_number=ref_num).exists():
|
|
||||||
row_num += 1
|
row_num += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Parse dates
|
px_source = resolve_px_source(row_data.get("source"))
|
||||||
|
|
||||||
date_received = self._parse_datetime(row_data.get("date_received"))
|
date_received = self._parse_datetime(row_data.get("date_received"))
|
||||||
created_at = date_received or timezone.now()
|
created_at = date_received or timezone.now()
|
||||||
|
if created_at and timezone.is_naive(created_at):
|
||||||
|
created_at = timezone.make_aware(created_at)
|
||||||
response_date = self._parse_datetime(row_data.get("response_date"))
|
response_date = self._parse_datetime(row_data.get("response_date"))
|
||||||
reminder_date = self._parse_datetime(row_data.get("reminder_date"))
|
reminder_date = self._parse_datetime(row_data.get("reminder_date"))
|
||||||
|
|
||||||
# Resolve location/departments
|
|
||||||
location = self._resolve_location(row_data.get("location_name"))
|
location = self._resolve_location(row_data.get("location_name"))
|
||||||
main_section = self._resolve_section(row_data.get("main_dept_name"))
|
main_section = self._resolve_section(row_data.get("main_dept_name"))
|
||||||
subsection = self._resolve_subsection(row_data.get("sub_dept_name"))
|
subsection = self._resolve_subsection(row_data.get("sub_dept_name"))
|
||||||
|
|
||||||
# Get/create data entry user
|
|
||||||
assigned_to_user = self._get_or_create_data_entry_user(row_data.get("data_entry_person"))
|
assigned_to_user = self._get_or_create_data_entry_user(row_data.get("data_entry_person"))
|
||||||
|
|
||||||
# Determine status
|
|
||||||
status = "open"
|
status = "open"
|
||||||
if response_date:
|
if response_date:
|
||||||
status = "resolved"
|
status = "resolved"
|
||||||
@ -156,9 +187,8 @@ class Command(BaseCommand):
|
|||||||
patient_name="Unknown",
|
patient_name="Unknown",
|
||||||
national_id="",
|
national_id="",
|
||||||
relation_to_patient="patient",
|
relation_to_patient="patient",
|
||||||
staff=None, # No staff linking for 2025
|
staff=None,
|
||||||
staff_name=row_data.get("staff_name") or "",
|
staff_name=row_data.get("staff_name") or "",
|
||||||
# No taxonomy fields for 2025
|
|
||||||
domain=None,
|
domain=None,
|
||||||
category=None,
|
category=None,
|
||||||
subcategory_obj=None,
|
subcategory_obj=None,
|
||||||
@ -166,18 +196,19 @@ class Command(BaseCommand):
|
|||||||
status=status,
|
status=status,
|
||||||
assigned_to=assigned_to_user,
|
assigned_to=assigned_to_user,
|
||||||
resolved_by=assigned_to_user if response_date else None,
|
resolved_by=assigned_to_user if response_date else None,
|
||||||
# Timeline
|
due_at=created_at + timedelta(hours=48),
|
||||||
created_at=created_at,
|
|
||||||
explanation_requested=bool(date_received),
|
explanation_requested=bool(date_received),
|
||||||
explanation_requested_at=date_received,
|
explanation_requested_at=date_received,
|
||||||
explanation_received_at=response_date,
|
explanation_received_at=response_date,
|
||||||
reminder_sent_at=reminder_date,
|
reminder_sent_at=reminder_date,
|
||||||
|
source=px_source,
|
||||||
metadata={
|
metadata={
|
||||||
"import_source": "2025_excel_basic",
|
"import_source": "2025_excel_basic",
|
||||||
"original_sheet": self.sheet_name,
|
"original_sheet": self.sheet_name,
|
||||||
"complaint_num": row_data.get("complaint_num"),
|
"complaint_num": row_data.get("complaint_num"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
Complaint.objects.filter(pk=complaint.pk).update(created_at=created_at)
|
||||||
|
|
||||||
self.stats["success"] += 1
|
self.stats["success"] += 1
|
||||||
|
|
||||||
@ -190,11 +221,18 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
def _extract_row_data(self, row_num: int) -> Dict:
|
def _extract_row_data(self, row_num: int) -> Dict:
|
||||||
data = {}
|
data = {}
|
||||||
for field, col in COLUMN_MAPPING.items():
|
for field, col in self.column_map.items():
|
||||||
cell_value = self.ws.cell(row_num, col).value
|
cell_value = self.ws.cell(row_num, col).value
|
||||||
data[field] = cell_value
|
data[field] = cell_value
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
def _extract_row_data_from_values(self, row: tuple) -> Dict:
|
||||||
|
data = {}
|
||||||
|
for field, col in self.column_map.items():
|
||||||
|
cell_value = row[col - 1] if col - 1 < len(row) else None
|
||||||
|
data[field] = cell_value
|
||||||
|
return data
|
||||||
|
|
||||||
def _build_reference_number(self, complaint_num) -> str:
|
def _build_reference_number(self, complaint_num) -> str:
|
||||||
sheet_parts = self.sheet_name.strip().split()
|
sheet_parts = self.sheet_name.strip().split()
|
||||||
year = "2025"
|
year = "2025"
|
||||||
@ -202,6 +240,27 @@ class Command(BaseCommand):
|
|||||||
month_code = MONTH_MAP.get(month_part, "00")
|
month_code = MONTH_MAP.get(month_part, "00")
|
||||||
return f"CMP-{year}-{month_code}-{int(complaint_num):04d}"
|
return f"CMP-{year}-{month_code}-{int(complaint_num):04d}"
|
||||||
|
|
||||||
|
def _get_unique_reference_number(self, complaint_num) -> str:
|
||||||
|
"""Generate unique reference number with suffix if needed."""
|
||||||
|
base_ref = self._build_reference_number(complaint_num)
|
||||||
|
|
||||||
|
if base_ref not in self.used_refs and not Complaint.objects.filter(reference_number=base_ref).exists():
|
||||||
|
self.used_refs.add(base_ref)
|
||||||
|
return base_ref
|
||||||
|
|
||||||
|
suffix = ord("A")
|
||||||
|
while suffix <= ord("Z"):
|
||||||
|
ref_with_suffix = f"{base_ref}-{chr(suffix)}"
|
||||||
|
if (
|
||||||
|
ref_with_suffix not in self.used_refs
|
||||||
|
and not Complaint.objects.filter(reference_number=ref_with_suffix).exists()
|
||||||
|
):
|
||||||
|
self.used_refs.add(ref_with_suffix)
|
||||||
|
return ref_with_suffix
|
||||||
|
suffix += 1
|
||||||
|
|
||||||
|
raise ValueError(f"Cannot generate unique reference for {base_ref}")
|
||||||
|
|
||||||
def _parse_datetime(self, value) -> Optional[datetime]:
|
def _parse_datetime(self, value) -> Optional[datetime]:
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
@ -245,7 +304,7 @@ class Command(BaseCommand):
|
|||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
first, last = parts[0], parts[-1]
|
first, last = parts[0], parts[-1]
|
||||||
else:
|
else:
|
||||||
first, last = arabic_name, "coordinator"
|
first, last = arabic_name, "staff"
|
||||||
|
|
||||||
username_first = re.sub(r"[^a-z0-9]", "", unidecode(first).lower().strip())
|
username_first = re.sub(r"[^a-z0-9]", "", unidecode(first).lower().strip())
|
||||||
username_last = re.sub(r"[^a-z0-9]", "", unidecode(last).lower().strip())
|
username_last = re.sub(r"[^a-z0-9]", "", unidecode(last).lower().strip())
|
||||||
@ -253,7 +312,7 @@ class Command(BaseCommand):
|
|||||||
if not username_first:
|
if not username_first:
|
||||||
username_first = "user"
|
username_first = "user"
|
||||||
if not username_last:
|
if not username_last:
|
||||||
username_last = "coordinator"
|
username_last = "staff"
|
||||||
|
|
||||||
username = f"{username_first}.{username_last}"
|
username = f"{username_first}.{username_last}"
|
||||||
|
|
||||||
|
|||||||
@ -1,23 +1,17 @@
|
|||||||
"""
|
"""
|
||||||
Import historical complaints from Excel (Aug-Dec 2022).
|
Import historical complaints from Excel (2022-2024).
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
# Test import (AUG 2022 only, dry run)
|
# Test import (dry run)
|
||||||
python manage.py import_historical_complaints "Complaints Report - 2022.xlsx" --sheet="AUG 2022 " --dry-run
|
python manage.py import_historical_complaints "Complaints Report - 2024.xlsx" --sheet="January 2024" --dry-run
|
||||||
|
|
||||||
# Actual import (AUG 2022)
|
# Actual import for a single sheet
|
||||||
python manage.py import_historical_complaints "Complaints Report - 2022.xlsx" --sheet="AUG 2022 "
|
python manage.py import_historical_complaints "Complaints Report - 2024.xlsx" --sheet="January 2024"
|
||||||
|
|
||||||
# Import all months
|
|
||||||
python manage.py import_historical_complaints "Complaints Report - 2022.xlsx" --sheet="SEP 2022 "
|
|
||||||
python manage.py import_historical_complaints "Complaints Report - 2022.xlsx" --sheet="OCT 2022"
|
|
||||||
python manage.py import_historical_complaints "Complaints Report - 2022.xlsx" --sheet="NOV 2022"
|
|
||||||
python manage.py import_historical_complaints "Complaints Report - 2022.xlsx" --sheet="DEC 2022"
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
@ -36,11 +30,12 @@ from .complaint_taxonomy_mapping import (
|
|||||||
get_mapped_category,
|
get_mapped_category,
|
||||||
is_taxonomy_mapped,
|
is_taxonomy_mapped,
|
||||||
)
|
)
|
||||||
|
from .complaint_source_mapping import resolve_px_source
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Default hospital code for all imported complaints
|
# Default hospital code for all imported complaints
|
||||||
DEFAULT_HOSPITAL_CODE = "NUZHA-DEV"
|
DEFAULT_HOSPITAL_CODE = "NUZHA"
|
||||||
|
|
||||||
# Column mapping: field_name -> column_number (1-based)
|
# Column mapping: field_name -> column_number (1-based)
|
||||||
COLUMN_MAPPING = {
|
COLUMN_MAPPING = {
|
||||||
@ -97,7 +92,7 @@ MONTH_MAP = {
|
|||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Import historical complaints from Excel (Aug-Dec 2022)"
|
help = "Import historical complaints from Excel (2022-2024)"
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument("excel_file", type=str, help="Path to the Excel file")
|
parser.add_argument("excel_file", type=str, help="Path to the Excel file")
|
||||||
@ -124,7 +119,7 @@ class Command(BaseCommand):
|
|||||||
try:
|
try:
|
||||||
import openpyxl
|
import openpyxl
|
||||||
|
|
||||||
self.wb = openpyxl.load_workbook(self.excel_file)
|
self.wb = openpyxl.load_workbook(self.excel_file, read_only=True, data_only=True)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise CommandError("openpyxl is required. Install with: pip install openpyxl")
|
raise CommandError("openpyxl is required. Install with: pip install openpyxl")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -137,7 +132,6 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
self.ws = self.wb[self.sheet_name]
|
self.ws = self.wb[self.sheet_name]
|
||||||
self.stdout.write(f"Processing sheet: {self.sheet_name}")
|
self.stdout.write(f"Processing sheet: {self.sheet_name}")
|
||||||
self.stdout.write(f"Total rows: {self.ws.max_row}")
|
|
||||||
|
|
||||||
# Statistics tracking
|
# Statistics tracking
|
||||||
self.stats = {
|
self.stats = {
|
||||||
@ -152,6 +146,9 @@ class Command(BaseCommand):
|
|||||||
self.unmatched_locations = set()
|
self.unmatched_locations = set()
|
||||||
self.unmatched_departments = set()
|
self.unmatched_departments = set()
|
||||||
|
|
||||||
|
# Cache for used reference numbers to avoid DB queries
|
||||||
|
self.used_refs = set()
|
||||||
|
|
||||||
# Process rows
|
# Process rows
|
||||||
self._process_sheet()
|
self._process_sheet()
|
||||||
|
|
||||||
@ -166,13 +163,13 @@ class Command(BaseCommand):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _process_sheet(self):
|
def _process_sheet(self):
|
||||||
"""Process all rows in the sheet."""
|
"""Process all rows in the sheet using iter_rows for performance."""
|
||||||
row_num = self.start_row
|
row_num = self.start_row
|
||||||
|
|
||||||
while row_num <= self.ws.max_row:
|
for row in self.ws.iter_rows(min_row=self.start_row, max_row=5000, values_only=True):
|
||||||
try:
|
try:
|
||||||
# Extract row data
|
# Extract row data
|
||||||
row_data = self._extract_row_data(row_num)
|
row_data = self._extract_row_data_from_values(row)
|
||||||
|
|
||||||
# Skip empty rows
|
# Skip empty rows
|
||||||
if not row_data.get("complaint_num"):
|
if not row_data.get("complaint_num"):
|
||||||
@ -181,14 +178,14 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
self.stats["processed"] += 1
|
self.stats["processed"] += 1
|
||||||
|
|
||||||
# Check for duplicate
|
# Validate complaint number and build reference
|
||||||
ref_num = self._build_reference_number(row_data["complaint_num"])
|
try:
|
||||||
if Complaint.objects.filter(reference_number=ref_num).exists():
|
ref_num = self._get_unique_reference_number(row_data["complaint_num"])
|
||||||
self.stats["skipped_duplicate"] += 1
|
except (ValueError, TypeError):
|
||||||
row_num += 1
|
row_num += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Resolve taxonomy - skip if unmapped
|
# Resolve taxonomy - allow unmapped (will be backfilled later via AI)
|
||||||
taxonomy = self._resolve_taxonomy(
|
taxonomy = self._resolve_taxonomy(
|
||||||
row_data.get("domain"),
|
row_data.get("domain"),
|
||||||
row_data.get("category"),
|
row_data.get("category"),
|
||||||
@ -202,10 +199,10 @@ class Command(BaseCommand):
|
|||||||
row_data.get("subcategory"),
|
row_data.get("subcategory"),
|
||||||
row_data.get("classification"),
|
row_data.get("classification"),
|
||||||
):
|
):
|
||||||
self.stats["skipped_unmapped_taxonomy"] += 1
|
|
||||||
self._log_unmapped_taxonomy(row_data)
|
self._log_unmapped_taxonomy(row_data)
|
||||||
row_num += 1
|
|
||||||
continue
|
# Resolve source
|
||||||
|
px_source = resolve_px_source(row_data.get("source"))
|
||||||
|
|
||||||
# Resolve location and departments
|
# Resolve location and departments
|
||||||
location = self._resolve_location(row_data.get("location_name"))
|
location = self._resolve_location(row_data.get("location_name"))
|
||||||
@ -230,6 +227,9 @@ class Command(BaseCommand):
|
|||||||
elif isinstance(date_received, datetime):
|
elif isinstance(date_received, datetime):
|
||||||
created_at = date_received
|
created_at = date_received
|
||||||
|
|
||||||
|
if created_at and timezone.is_naive(created_at):
|
||||||
|
created_at = timezone.make_aware(created_at)
|
||||||
|
|
||||||
# Get or create data entry person user
|
# Get or create data entry person user
|
||||||
data_entry_person = row_data.get("data_entry_person")
|
data_entry_person = row_data.get("data_entry_person")
|
||||||
assigned_to_user = self._get_or_create_data_entry_user(data_entry_person)
|
assigned_to_user = self._get_or_create_data_entry_user(data_entry_person)
|
||||||
@ -253,7 +253,7 @@ class Command(BaseCommand):
|
|||||||
accused_staff = self._resolve_staff_by_id(accused_staff_id)
|
accused_staff = self._resolve_staff_by_id(accused_staff_id)
|
||||||
|
|
||||||
# Map rightful side to resolution outcome
|
# Map rightful side to resolution outcome
|
||||||
rightful_side = row_data.get("rightful_side", "").lower().strip()
|
rightful_side = str(row_data.get("rightful_side") or "").lower().strip()
|
||||||
resolution_outcome = ""
|
resolution_outcome = ""
|
||||||
if rightful_side in ["patient", "hospital", "other"]:
|
if rightful_side in ["patient", "hospital", "other"]:
|
||||||
resolution_outcome = rightful_side
|
resolution_outcome = rightful_side
|
||||||
@ -293,6 +293,8 @@ class Command(BaseCommand):
|
|||||||
explanation_requested=explanation_requested,
|
explanation_requested=explanation_requested,
|
||||||
explanation_requested_at=explanation_requested_at,
|
explanation_requested_at=explanation_requested_at,
|
||||||
explanation_received_at=explanation_received_at,
|
explanation_received_at=explanation_received_at,
|
||||||
|
due_at=created_at + timedelta(hours=48),
|
||||||
|
source=px_source,
|
||||||
metadata=self._build_metadata(row_data, ref_num),
|
metadata=self._build_metadata(row_data, ref_num),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -315,7 +317,7 @@ class Command(BaseCommand):
|
|||||||
row_num += 1
|
row_num += 1
|
||||||
|
|
||||||
def _extract_row_data(self, row_num: int) -> Dict:
|
def _extract_row_data(self, row_num: int) -> Dict:
|
||||||
"""Extract data from Excel row."""
|
"""Extract data from Excel row (kept for compatibility)."""
|
||||||
data = {}
|
data = {}
|
||||||
for field, col in COLUMN_MAPPING.items():
|
for field, col in COLUMN_MAPPING.items():
|
||||||
cell_value = self.ws.cell(row_num, col).value
|
cell_value = self.ws.cell(row_num, col).value
|
||||||
@ -325,6 +327,18 @@ class Command(BaseCommand):
|
|||||||
data[field] = cell_value
|
data[field] = cell_value
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
def _extract_row_data_from_values(self, row: tuple) -> Dict:
|
||||||
|
"""Extract data from Excel row using values tuple (for iter_rows)."""
|
||||||
|
data = {}
|
||||||
|
for field, col in COLUMN_MAPPING.items():
|
||||||
|
# col is 1-based, so subtract 1 for 0-based tuple index
|
||||||
|
cell_value = row[col - 1] if col - 1 < len(row) else None
|
||||||
|
# Clean classification field (remove Excel artifacts like "AX5:BA5")
|
||||||
|
if field == "classification" and cell_value:
|
||||||
|
cell_value = re.sub(r"[A-Z]+\d+:[A-Z]+\d+", "", str(cell_value)).strip()
|
||||||
|
data[field] = cell_value
|
||||||
|
return data
|
||||||
|
|
||||||
def _build_reference_number(self, complaint_num) -> str:
|
def _build_reference_number(self, complaint_num) -> str:
|
||||||
"""Build reference number: CMP-YYYY-MM-NNNN."""
|
"""Build reference number: CMP-YYYY-MM-NNNN."""
|
||||||
# Parse year and month from sheet name (e.g., "January 2023 " -> year=2023, month=January)
|
# Parse year and month from sheet name (e.g., "January 2023 " -> year=2023, month=January)
|
||||||
@ -334,6 +348,31 @@ class Command(BaseCommand):
|
|||||||
month_code = MONTH_MAP.get(month_part, "00")
|
month_code = MONTH_MAP.get(month_part, "00")
|
||||||
return f"CMP-{year}-{month_code}-{int(complaint_num):04d}"
|
return f"CMP-{year}-{month_code}-{int(complaint_num):04d}"
|
||||||
|
|
||||||
|
def _get_unique_reference_number(self, complaint_num) -> str:
|
||||||
|
"""Generate unique reference number with suffix if needed."""
|
||||||
|
base_ref = self._build_reference_number(complaint_num)
|
||||||
|
|
||||||
|
# Check cache first, then DB
|
||||||
|
if base_ref not in self.used_refs and not Complaint.objects.filter(reference_number=base_ref).exists():
|
||||||
|
self.used_refs.add(base_ref)
|
||||||
|
return base_ref
|
||||||
|
|
||||||
|
# Try with suffixes -A, -B, -C, ...
|
||||||
|
suffix = ord("A")
|
||||||
|
while suffix <= ord("Z"):
|
||||||
|
ref_with_suffix = f"{base_ref}-{chr(suffix)}"
|
||||||
|
if (
|
||||||
|
ref_with_suffix not in self.used_refs
|
||||||
|
and not Complaint.objects.filter(reference_number=ref_with_suffix).exists()
|
||||||
|
):
|
||||||
|
self.used_refs.add(ref_with_suffix)
|
||||||
|
self.stats["skipped_duplicate"] += 1 # Actually suffix added
|
||||||
|
return ref_with_suffix
|
||||||
|
suffix += 1
|
||||||
|
|
||||||
|
# If all single letter suffixes exhausted (unlikely), raise error
|
||||||
|
raise ValueError(f"Cannot generate unique reference for {base_ref}")
|
||||||
|
|
||||||
def _resolve_taxonomy(self, domain, category, subcategory, classification) -> Dict:
|
def _resolve_taxonomy(self, domain, category, subcategory, classification) -> Dict:
|
||||||
"""Resolve taxonomy to ComplaintCategory objects."""
|
"""Resolve taxonomy to ComplaintCategory objects."""
|
||||||
return {
|
return {
|
||||||
@ -404,7 +443,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
def _get_or_create_data_entry_user(self, arabic_name: str) -> Optional[User]:
|
def _get_or_create_data_entry_user(self, arabic_name: str) -> Optional[User]:
|
||||||
"""
|
"""
|
||||||
Create or get PX-Coordinator user from Arabic data entry person name.
|
Create or get PX-Staff user from Arabic data entry person name.
|
||||||
|
|
||||||
Transliterates Arabic name to Latin username using first and last name only.
|
Transliterates Arabic name to Latin username using first and last name only.
|
||||||
Stores full Arabic name in first_name field.
|
Stores full Arabic name in first_name field.
|
||||||
@ -431,7 +470,7 @@ class Command(BaseCommand):
|
|||||||
last_name = parts[-1]
|
last_name = parts[-1]
|
||||||
else:
|
else:
|
||||||
first_name = arabic_name
|
first_name = arabic_name
|
||||||
last_name = "coordinator"
|
last_name = "staff"
|
||||||
|
|
||||||
# Transliterate to Latin for username
|
# Transliterate to Latin for username
|
||||||
username_first = unidecode(first_name).lower().strip()
|
username_first = unidecode(first_name).lower().strip()
|
||||||
@ -444,7 +483,7 @@ class Command(BaseCommand):
|
|||||||
if not username_first:
|
if not username_first:
|
||||||
username_first = "user"
|
username_first = "user"
|
||||||
if not username_last:
|
if not username_last:
|
||||||
username_last = "coordinator"
|
username_last = "staff"
|
||||||
|
|
||||||
username = f"{username_first}.{username_last}"
|
username = f"{username_first}.{username_last}"
|
||||||
|
|
||||||
@ -470,7 +509,7 @@ class Command(BaseCommand):
|
|||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
user.save()
|
user.save()
|
||||||
logger.info(f"Created new PX-Coordinator user: {username} ({arabic_name})")
|
logger.info(f"Created new PX-Staff user: {username} ({arabic_name})")
|
||||||
return user
|
return user
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error creating user {username}: {e}")
|
logger.error(f"Error creating user {username}: {e}")
|
||||||
@ -486,7 +525,7 @@ class Command(BaseCommand):
|
|||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
user.save()
|
user.save()
|
||||||
logger.info(f"Created new PX-Coordinator user: {username}{i} ({arabic_name})")
|
logger.info(f"Created new PX-Staff user: {username}{i} ({arabic_name})")
|
||||||
return user
|
return user
|
||||||
except Exception as e2:
|
except Exception as e2:
|
||||||
logger.error(f"Error creating user {username}{i}: {e2}")
|
logger.error(f"Error creating user {username}{i}: {e2}")
|
||||||
@ -576,7 +615,6 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(f"Total rows processed: {self.stats['processed']}")
|
self.stdout.write(f"Total rows processed: {self.stats['processed']}")
|
||||||
self.stdout.write(self.style.SUCCESS(f"Successfully imported: {self.stats['success']}"))
|
self.stdout.write(self.style.SUCCESS(f"Successfully imported: {self.stats['success']}"))
|
||||||
self.stdout.write(self.style.WARNING(f"Skipped (duplicates): {self.stats['skipped_duplicate']}"))
|
self.stdout.write(self.style.WARNING(f"Skipped (duplicates): {self.stats['skipped_duplicate']}"))
|
||||||
self.stdout.write(self.style.WARNING(f"Skipped (unmapped taxonomy): {self.stats['skipped_unmapped_taxonomy']}"))
|
|
||||||
self.stdout.write(self.style.ERROR(f"Failed: {self.stats['failed']}"))
|
self.stdout.write(self.style.ERROR(f"Failed: {self.stats['failed']}"))
|
||||||
|
|
||||||
if self.unmapped_taxonomy:
|
if self.unmapped_taxonomy:
|
||||||
|
|||||||
@ -404,12 +404,10 @@ class Command(BaseCommand):
|
|||||||
if not hospitals:
|
if not hospitals:
|
||||||
return
|
return
|
||||||
|
|
||||||
px_coordinators = User.objects.filter(groups__name="PX Coordinator", is_active=True)
|
px_staff = User.objects.filter(groups__name="PX Staff", is_active=True)
|
||||||
if not px_coordinators.exists():
|
if not px_staff.exists():
|
||||||
self.stdout.write(
|
self.stdout.write(self.style.WARNING("No PX Staff users found. Unassigned items will have no assignee."))
|
||||||
self.style.WARNING("No PX Coordinator users found. Unassigned items will have no assignee.")
|
px_staff = User.objects.filter(groups__name="Hospital Admin", is_active=True)
|
||||||
)
|
|
||||||
px_coordinators = User.objects.filter(groups__name="Hospital Admin", is_active=True)
|
|
||||||
|
|
||||||
all_staff = Staff.objects.filter(status="active")
|
all_staff = Staff.objects.filter(status="active")
|
||||||
complaint_categories = ComplaintCategory.objects.filter(is_active=True)
|
complaint_categories = ComplaintCategory.objects.filter(is_active=True)
|
||||||
@ -437,12 +435,10 @@ class Command(BaseCommand):
|
|||||||
self._clear_sample_data()
|
self._clear_sample_data()
|
||||||
|
|
||||||
complaints_created = self._seed_complaints(
|
complaints_created = self._seed_complaints(
|
||||||
options["complaints"], hospitals, px_coordinators, all_staff, complaint_categories, sources
|
options["complaints"], hospitals, px_staff, all_staff, complaint_categories, sources
|
||||||
)
|
)
|
||||||
observations_created = self._seed_observations(
|
observations_created = self._seed_observations(options["observations"], hospitals, px_staff, obs_categories)
|
||||||
options["observations"], hospitals, px_coordinators, obs_categories
|
inquiries_created = self._seed_inquiries(options["inquiries"], hospitals, px_staff, sources)
|
||||||
)
|
|
||||||
inquiries_created = self._seed_inquiries(options["inquiries"], hospitals, px_coordinators, sources)
|
|
||||||
|
|
||||||
self._print_summary(complaints_created, observations_created, inquiries_created)
|
self._print_summary(complaints_created, observations_created, inquiries_created)
|
||||||
|
|
||||||
@ -493,7 +489,7 @@ class Command(BaseCommand):
|
|||||||
days_ago = random.randint(min_days_ago, max(min_days_ago, self.months_back * 30))
|
days_ago = random.randint(min_days_ago, max(min_days_ago, self.months_back * 30))
|
||||||
return timezone.now() - timedelta(days=days_ago)
|
return timezone.now() - timedelta(days=days_ago)
|
||||||
|
|
||||||
def _seed_complaints(self, count, hospitals, px_coordinators, all_staff, categories, sources):
|
def _seed_complaints(self, count, hospitals, px_staff, all_staff, categories, sources):
|
||||||
self.stdout.write("\n--- Complaints ---")
|
self.stdout.write("\n--- Complaints ---")
|
||||||
|
|
||||||
complaint_statuses = [
|
complaint_statuses = [
|
||||||
@ -569,11 +565,11 @@ class Command(BaseCommand):
|
|||||||
complaint.staff = staff_member
|
complaint.staff = staff_member
|
||||||
if status not in ("open",):
|
if status not in ("open",):
|
||||||
if status in ("open",):
|
if status in ("open",):
|
||||||
coordinator = random.choice(px_coordinators) if px_coordinators.exists() else None
|
coordinator = random.choice(px_staff) if px_staff.exists() else None
|
||||||
complaint.assigned_to = coordinator
|
complaint.assigned_to = coordinator
|
||||||
complaint.assigned_at = created_at + timedelta(minutes=random.randint(5, 60))
|
complaint.assigned_at = created_at + timedelta(minutes=random.randint(5, 60))
|
||||||
else:
|
else:
|
||||||
complaint.assigned_to = random.choice(px_coordinators) if px_coordinators.exists() else None
|
complaint.assigned_to = random.choice(px_staff) if px_staff.exists() else None
|
||||||
complaint.assigned_at = created_at + timedelta(minutes=random.randint(5, 60))
|
complaint.assigned_at = created_at + timedelta(minutes=random.randint(5, 60))
|
||||||
complaint.activated_at = created_at + timedelta(minutes=random.randint(30, 120))
|
complaint.activated_at = created_at + timedelta(minutes=random.randint(30, 120))
|
||||||
|
|
||||||
@ -630,7 +626,7 @@ class Command(BaseCommand):
|
|||||||
ComplaintUpdate.objects.create(
|
ComplaintUpdate.objects.create(
|
||||||
complaint=complaint,
|
complaint=complaint,
|
||||||
update_type="status_change",
|
update_type="status_change",
|
||||||
message=f"Complaint activated and assigned to {complaint.assigned_to.get_full_name() if complaint.assigned_to else 'PX Coordinator'}",
|
message=f"Complaint activated and assigned to {complaint.assigned_to.get_full_name() if complaint.assigned_to else 'PX Staff'}",
|
||||||
old_status="open",
|
old_status="open",
|
||||||
new_status="in_progress",
|
new_status="in_progress",
|
||||||
created_by=complaint.assigned_to,
|
created_by=complaint.assigned_to,
|
||||||
@ -668,7 +664,7 @@ class Command(BaseCommand):
|
|||||||
created_at=created_at + timedelta(days=max(1, days_ago // 2)),
|
created_at=created_at + timedelta(days=max(1, days_ago // 2)),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _seed_observations(self, count, hospitals, px_coordinators, obs_categories):
|
def _seed_observations(self, count, hospitals, px_staff, obs_categories):
|
||||||
self.stdout.write("\n--- Observations ---")
|
self.stdout.write("\n--- Observations ---")
|
||||||
|
|
||||||
obs_statuses = [
|
obs_statuses = [
|
||||||
@ -723,7 +719,7 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if status not in ("new",):
|
if status not in ("new",):
|
||||||
obs.assigned_to = random.choice(px_coordinators) if px_coordinators.exists() else None
|
obs.assigned_to = random.choice(px_staff) if px_staff.exists() else None
|
||||||
|
|
||||||
if status in ("resolved", "closed"):
|
if status in ("resolved", "closed"):
|
||||||
obs.resolved_at = created_at + timedelta(days=max(1, days_ago // 2))
|
obs.resolved_at = created_at + timedelta(days=max(1, days_ago // 2))
|
||||||
@ -801,7 +797,7 @@ class Command(BaseCommand):
|
|||||||
created_at=obs.resolved_at if obs.resolved_at else created_at,
|
created_at=obs.resolved_at if obs.resolved_at else created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _seed_inquiries(self, count, hospitals, px_coordinators, sources):
|
def _seed_inquiries(self, count, hospitals, px_staff, sources):
|
||||||
self.stdout.write("\n--- Inquiries ---")
|
self.stdout.write("\n--- Inquiries ---")
|
||||||
|
|
||||||
inquiry_statuses = [
|
inquiry_statuses = [
|
||||||
@ -852,7 +848,7 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if status not in ("open",):
|
if status not in ("open",):
|
||||||
inquiry.assigned_to = random.choice(px_coordinators) if px_coordinators.exists() else None
|
inquiry.assigned_to = random.choice(px_staff) if px_staff.exists() else None
|
||||||
inquiry.assigned_at = created_at + timedelta(minutes=random.randint(5, 60))
|
inquiry.assigned_at = created_at + timedelta(minutes=random.randint(5, 60))
|
||||||
|
|
||||||
if status in ("resolved", "closed"):
|
if status in ("resolved", "closed"):
|
||||||
@ -894,7 +890,7 @@ class Command(BaseCommand):
|
|||||||
InquiryUpdate.objects.create(
|
InquiryUpdate.objects.create(
|
||||||
inquiry=inquiry,
|
inquiry=inquiry,
|
||||||
update_type="note",
|
update_type="note",
|
||||||
message=f"Inquiry assigned to {inquiry.assigned_to.get_full_name() if inquiry.assigned_to else 'PX Coordinator'} for handling.",
|
message=f"Inquiry assigned to {inquiry.assigned_to.get_full_name() if inquiry.assigned_to else 'PX Staff'} for handling.",
|
||||||
created_by=inquiry.assigned_to,
|
created_by=inquiry.assigned_to,
|
||||||
created_at=inquiry.assigned_at if inquiry.assigned_at else created_at + timedelta(minutes=5),
|
created_at=inquiry.assigned_at if inquiry.assigned_at else created_at + timedelta(minutes=5),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -87,7 +87,7 @@ class Command(BaseCommand):
|
|||||||
if not test_data:
|
if not test_data:
|
||||||
return
|
return
|
||||||
|
|
||||||
admin_user, px_coordinator, department_with_manager, department_without_manager, source = test_data
|
admin_user, px_staff, department_with_manager, department_without_manager, source = test_data
|
||||||
|
|
||||||
# Define scenarios
|
# Define scenarios
|
||||||
scenarios = {
|
scenarios = {
|
||||||
@ -249,11 +249,11 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(" Please create a hospital admin user first")
|
self.stdout.write(" Please create a hospital admin user first")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Get PX Coordinator group
|
# Get PX Staff group
|
||||||
px_coordinator_group = Group.objects.filter(name="PX Coordinator").first()
|
px_staff_group = Group.objects.filter(name="PX Staff").first()
|
||||||
|
|
||||||
# Get PX Coordinator
|
# Get PX Staff
|
||||||
px_coordinator = User.objects.filter(hospital=hospital, groups=px_coordinator_group, is_active=True).first()
|
px_staff = User.objects.filter(hospital=hospital, groups=px_staff_group, is_active=True).first()
|
||||||
|
|
||||||
# Get or create department with manager
|
# Get or create department with manager
|
||||||
dept_with_manager = (
|
dept_with_manager = (
|
||||||
@ -314,8 +314,8 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
self.stdout.write(f"\n👥 Test Data:")
|
self.stdout.write(f"\n👥 Test Data:")
|
||||||
self.stdout.write(f" Admin User: {admin_user.get_full_name()} ({admin_user.email})")
|
self.stdout.write(f" Admin User: {admin_user.get_full_name()} ({admin_user.email})")
|
||||||
if px_coordinator:
|
if px_staff:
|
||||||
self.stdout.write(f" PX Coordinator: {px_coordinator.get_full_name()} ({px_coordinator.email})")
|
self.stdout.write(f" PX Staff: {px_staff.get_full_name()} ({px_staff.email})")
|
||||||
if dept_with_manager.manager:
|
if dept_with_manager.manager:
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
f" Dept with Manager: {dept_with_manager.name} (Manager: {dept_with_manager.manager.get_full_name()})"
|
f" Dept with Manager: {dept_with_manager.name} (Manager: {dept_with_manager.manager.get_full_name()})"
|
||||||
@ -328,7 +328,7 @@ class Command(BaseCommand):
|
|||||||
if source:
|
if source:
|
||||||
self.stdout.write(f" Source: {source.name_en}")
|
self.stdout.write(f" Source: {source.name_en}")
|
||||||
|
|
||||||
return admin_user, px_coordinator, dept_with_manager, dept_without_manager, source
|
return admin_user, px_staff, dept_with_manager, dept_without_manager, source
|
||||||
|
|
||||||
def create_test_complaint(self, hospital, source, department, assigned_to, created_at, index, scenario):
|
def create_test_complaint(self, hospital, source, department, assigned_to, created_at, index, scenario):
|
||||||
"""Create a test complaint with backdated created_at"""
|
"""Create a test complaint with backdated created_at"""
|
||||||
@ -386,12 +386,12 @@ class Command(BaseCommand):
|
|||||||
if department and department.manager:
|
if department and department.manager:
|
||||||
return f"{department.manager.get_full_name()} ({department.manager.email}) - Department Manager"
|
return f"{department.manager.get_full_name()} ({department.manager.email}) - Department Manager"
|
||||||
|
|
||||||
# Fallback to hospital admins and coordinators
|
# Fallback to hospital admins and staff
|
||||||
from apps.complaints.tasks import get_hospital_admins_and_coordinators
|
from apps.complaints.tasks import get_hospital_admins_and_staff
|
||||||
|
|
||||||
recipients = get_hospital_admins_and_coordinators(hospital)
|
recipients = get_hospital_admins_and_staff(hospital)
|
||||||
if recipients:
|
if recipients:
|
||||||
names = [f"{r.get_full_name()} ({r.email})" for r in recipients]
|
names = [f"{r.get_full_name()} ({r.email})" for r in recipients]
|
||||||
return f"Hospital Admins/Coordinators: {', '.join(names)}"
|
return f"Hospital Admins/Staff: {', '.join(names)}"
|
||||||
|
|
||||||
return "NO RECIPIENTS FOUND"
|
return "NO RECIPIENTS FOUND"
|
||||||
|
|||||||
@ -110,7 +110,7 @@ class ComplaintCategory(UUIDModel, TimeStampedModel):
|
|||||||
help_text="Empty list = system-wide category. Add hospitals to share category.",
|
help_text="Empty list = system-wide category. Add hospitals to share category.",
|
||||||
)
|
)
|
||||||
|
|
||||||
code = models.CharField(max_length=50, help_text="Unique code for this category")
|
code = models.CharField(max_length=100, help_text="Unique code for this category")
|
||||||
|
|
||||||
name_en = models.CharField(max_length=200)
|
name_en = models.CharField(max_length=200)
|
||||||
name_ar = models.CharField(max_length=200, blank=True)
|
name_ar = models.CharField(max_length=200, blank=True)
|
||||||
@ -2096,7 +2096,7 @@ class ComplaintInvolvedStaff(UUIDModel, TimeStampedModel):
|
|||||||
RESPONSIBLE = "responsible", _("Responsible for Resolution")
|
RESPONSIBLE = "responsible", _("Responsible for Resolution")
|
||||||
INVESTIGATOR = "investigator", _("Investigator")
|
INVESTIGATOR = "investigator", _("Investigator")
|
||||||
SUPPORT = "support", _("Support Staff")
|
SUPPORT = "support", _("Support Staff")
|
||||||
COORDINATOR = "coordinator", _("Coordinator")
|
PX_STAFF = "px_staff", _("PX Staff")
|
||||||
|
|
||||||
complaint = models.ForeignKey(Complaint, on_delete=models.CASCADE, related_name="involved_staff")
|
complaint = models.ForeignKey(Complaint, on_delete=models.CASCADE, related_name="involved_staff")
|
||||||
|
|
||||||
|
|||||||
@ -22,10 +22,10 @@ class ComplaintService:
|
|||||||
|
|
||||||
For explanation escalation (staff provided):
|
For explanation escalation (staff provided):
|
||||||
staff.report_to -> staff.department.manager ->
|
staff.report_to -> staff.department.manager ->
|
||||||
complaint.department.manager -> hospital admins & PX coordinators
|
complaint.department.manager -> hospital admins & PX staff
|
||||||
|
|
||||||
For complaint-level escalation (no staff):
|
For complaint-level escalation (no staff):
|
||||||
complaint.department.manager -> hospital admins & PX coordinators
|
complaint.department.manager -> hospital admins & PX staff
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
complaint: Complaint instance
|
complaint: Complaint instance
|
||||||
@ -36,7 +36,7 @@ class ComplaintService:
|
|||||||
(or None if no target found) and fallback_path describes
|
(or None if no target found) and fallback_path describes
|
||||||
which step succeeded.
|
which step succeeded.
|
||||||
"""
|
"""
|
||||||
from apps.complaints.tasks import get_hospital_admins_and_coordinators
|
from apps.complaints.tasks import get_hospital_admins_and_staff
|
||||||
|
|
||||||
if staff:
|
if staff:
|
||||||
if staff.report_to and staff.report_to.user and staff.report_to.user.is_active:
|
if staff.report_to and staff.report_to.user and staff.report_to.user.is_active:
|
||||||
@ -52,9 +52,9 @@ class ComplaintService:
|
|||||||
|
|
||||||
hospital = complaint.hospital
|
hospital = complaint.hospital
|
||||||
if hospital:
|
if hospital:
|
||||||
fallback = get_hospital_admins_and_coordinators(hospital).first()
|
fallback = get_hospital_admins_and_staff(hospital).first()
|
||||||
if fallback:
|
if fallback:
|
||||||
return fallback, "hospital_admins_coordinators"
|
return fallback, "hospital_admins_staff"
|
||||||
|
|
||||||
return None, "no_target_found"
|
return None, "no_target_found"
|
||||||
|
|
||||||
@ -64,7 +64,7 @@ class ComplaintService:
|
|||||||
return True
|
return True
|
||||||
if user.is_hospital_admin() and user.hospital == complaint.hospital:
|
if user.is_hospital_admin() and user.hospital == complaint.hospital:
|
||||||
return True
|
return True
|
||||||
if user.is_px_coordinator() and user.hospital == complaint.hospital:
|
if user.is_px_staff() and user.hospital == complaint.hospital:
|
||||||
return True
|
return True
|
||||||
if user.is_department_manager() and user.department == complaint.department:
|
if user.is_department_manager() and user.department == complaint.department:
|
||||||
return True
|
return True
|
||||||
@ -79,7 +79,7 @@ class ComplaintService:
|
|||||||
return (
|
return (
|
||||||
user.is_px_admin()
|
user.is_px_admin()
|
||||||
or user.is_hospital_admin()
|
or user.is_hospital_admin()
|
||||||
or user.is_px_coordinator()
|
or user.is_px_staff()
|
||||||
or (user.is_department_manager() and complaint.department == user.department)
|
or (user.is_department_manager() and complaint.department == user.department)
|
||||||
or complaint.hospital == user.hospital
|
or complaint.hospital == user.hospital
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1727,9 +1727,9 @@ def send_complaint_notification(complaint_id, event_type):
|
|||||||
if complaint.department and complaint.department.manager:
|
if complaint.department and complaint.department.manager:
|
||||||
recipients.append(complaint.department.manager)
|
recipients.append(complaint.department.manager)
|
||||||
|
|
||||||
# Fallback: notify hospital admins/coordinators if no recipients found
|
# Fallback: notify hospital admins/staff if no recipients found
|
||||||
if not recipients and complaint.hospital:
|
if not recipients and complaint.hospital:
|
||||||
fallback = get_hospital_admins_and_coordinators(complaint.hospital)
|
fallback = get_hospital_admins_and_staff(complaint.hospital)
|
||||||
recipients = list(fallback[:3])
|
recipients = list(fallback[:3])
|
||||||
|
|
||||||
elif event_type == "resolved":
|
elif event_type == "resolved":
|
||||||
@ -2217,9 +2217,9 @@ def send_explanation_reminders():
|
|||||||
return {"status": "completed", "reminders_sent": reminder_count, "second_reminders_sent": second_reminder_count}
|
return {"status": "completed", "reminders_sent": reminder_count, "second_reminders_sent": second_reminder_count}
|
||||||
|
|
||||||
|
|
||||||
def get_hospital_admins_and_coordinators(hospital):
|
def get_hospital_admins_and_staff(hospital):
|
||||||
"""
|
"""
|
||||||
Get Hospital Admins and PX Coordinators for a specific hospital.
|
Get Hospital Admins and PX Staff for a specific hospital.
|
||||||
These users receive SLA reminders for unassigned complaints.
|
These users receive SLA reminders for unassigned complaints.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -2232,7 +2232,7 @@ def get_hospital_admins_and_coordinators(hospital):
|
|||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
return User.objects.filter(
|
return User.objects.filter(
|
||||||
Q(groups__name="Hospital Admin") | Q(groups__name="PX Coordinator"), hospital=hospital, is_active=True
|
Q(groups__name="Hospital Admin") | Q(groups__name="PX Staff"), hospital=hospital, is_active=True
|
||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
|
|
||||||
@ -2311,17 +2311,17 @@ def send_sla_reminders():
|
|||||||
if not recipient and complaint.department and complaint.department.manager:
|
if not recipient and complaint.department and complaint.department.manager:
|
||||||
recipient = complaint.department.manager
|
recipient = complaint.department.manager
|
||||||
|
|
||||||
# Fallback to Hospital Admins and PX Coordinators for unassigned complaints
|
# Fallback to Hospital Admins and PX Staff for unassigned complaints
|
||||||
is_unassigned = False
|
is_unassigned = False
|
||||||
fallback_recipients = []
|
fallback_recipients = []
|
||||||
|
|
||||||
if not recipient:
|
if not recipient:
|
||||||
is_unassigned = True
|
is_unassigned = True
|
||||||
fallback_recipients = get_hospital_admins_and_coordinators(complaint.hospital)
|
fallback_recipients = get_hospital_admins_and_staff(complaint.hospital)
|
||||||
|
|
||||||
if not fallback_recipients.exists():
|
if not fallback_recipients.exists():
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"No Hospital Admins or PX Coordinators found for hospital {complaint.hospital.name} "
|
f"No Hospital Admins or PX Staff found for hospital {complaint.hospital.name} "
|
||||||
f"to receive SLA reminder for unassigned complaint {complaint.id}"
|
f"to receive SLA reminder for unassigned complaint {complaint.id}"
|
||||||
)
|
)
|
||||||
skipped_count += 1
|
skipped_count += 1
|
||||||
@ -2405,7 +2405,7 @@ def send_sla_reminders():
|
|||||||
# Create timeline entry
|
# Create timeline entry
|
||||||
if is_unassigned:
|
if is_unassigned:
|
||||||
timeline_message = (
|
timeline_message = (
|
||||||
f"SLA reminder sent to Hospital Admins/Coordinators for UNASSIGNED complaint. "
|
f"SLA reminder sent to Hospital Admins/Staff for UNASSIGNED complaint. "
|
||||||
f"Complaint is due in {int(hours_remaining)} hours. "
|
f"Complaint is due in {int(hours_remaining)} hours. "
|
||||||
f"Recipients: {', '.join(recipient_names)}"
|
f"Recipients: {', '.join(recipient_names)}"
|
||||||
)
|
)
|
||||||
@ -2446,7 +2446,7 @@ def send_sla_reminders():
|
|||||||
if is_unassigned:
|
if is_unassigned:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"SLA reminder sent for UNASSIGNED complaint {complaint.id} "
|
f"SLA reminder sent for UNASSIGNED complaint {complaint.id} "
|
||||||
f"to Hospital Admins/Coordinators: {', '.join(recipient_names)} "
|
f"to Hospital Admins/Staff: {', '.join(recipient_names)} "
|
||||||
f"({int(hours_remaining)} hours remaining)"
|
f"({int(hours_remaining)} hours remaining)"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@ -2479,17 +2479,17 @@ def send_sla_reminders():
|
|||||||
if not recipient and complaint.department and complaint.department.manager:
|
if not recipient and complaint.department and complaint.department.manager:
|
||||||
recipient = complaint.department.manager
|
recipient = complaint.department.manager
|
||||||
|
|
||||||
# Fallback to Hospital Admins and PX Coordinators for unassigned complaints
|
# Fallback to Hospital Admins and PX Staff for unassigned complaints
|
||||||
is_unassigned = False
|
is_unassigned = False
|
||||||
fallback_recipients = []
|
fallback_recipients = []
|
||||||
|
|
||||||
if not recipient:
|
if not recipient:
|
||||||
is_unassigned = True
|
is_unassigned = True
|
||||||
fallback_recipients = get_hospital_admins_and_coordinators(complaint.hospital)
|
fallback_recipients = get_hospital_admins_and_staff(complaint.hospital)
|
||||||
|
|
||||||
if not fallback_recipients.exists():
|
if not fallback_recipients.exists():
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"No Hospital Admins or PX Coordinators found for hospital {complaint.hospital.name} "
|
f"No Hospital Admins or PX Staff found for hospital {complaint.hospital.name} "
|
||||||
f"to receive second SLA reminder for unassigned complaint {complaint.id}"
|
f"to receive second SLA reminder for unassigned complaint {complaint.id}"
|
||||||
)
|
)
|
||||||
skipped_count += 1
|
skipped_count += 1
|
||||||
@ -2573,7 +2573,7 @@ def send_sla_reminders():
|
|||||||
# Create timeline entry
|
# Create timeline entry
|
||||||
if is_unassigned:
|
if is_unassigned:
|
||||||
timeline_message = (
|
timeline_message = (
|
||||||
f"SECOND SLA reminder sent to Hospital Admins/Coordinators for UNASSIGNED complaint. "
|
f"SECOND SLA reminder sent to Hospital Admins/Staff for UNASSIGNED complaint. "
|
||||||
f"Complaint is due in {int(hours_remaining)} hours. "
|
f"Complaint is due in {int(hours_remaining)} hours. "
|
||||||
f"This is the FINAL reminder. Recipients: {', '.join(recipient_names)}"
|
f"This is the FINAL reminder. Recipients: {', '.join(recipient_names)}"
|
||||||
)
|
)
|
||||||
@ -2615,7 +2615,7 @@ def send_sla_reminders():
|
|||||||
if is_unassigned:
|
if is_unassigned:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Second SLA reminder sent for UNASSIGNED complaint {complaint.id} "
|
f"Second SLA reminder sent for UNASSIGNED complaint {complaint.id} "
|
||||||
f"to Hospital Admins/Coordinators: {', '.join(recipient_names)} "
|
f"to Hospital Admins/Staff: {', '.join(recipient_names)} "
|
||||||
f"({int(hours_remaining)} hours remaining)"
|
f"({int(hours_remaining)} hours remaining)"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@ -1062,6 +1062,87 @@ def complaint_export_excel(request):
|
|||||||
return export_complaints_excel(queryset, request.GET.dict())
|
return export_complaints_excel(queryset, request.GET.dict())
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def complaint_export_historical_excel(request):
|
||||||
|
"""Export complaints to historical Excel format with Arabic headers."""
|
||||||
|
from apps.complaints.utils import export_historical_excel
|
||||||
|
|
||||||
|
# Get filtered queryset
|
||||||
|
queryset = Complaint.objects.select_related(
|
||||||
|
"patient",
|
||||||
|
"hospital",
|
||||||
|
"department",
|
||||||
|
"staff",
|
||||||
|
"assigned_to",
|
||||||
|
"resolved_by",
|
||||||
|
"closed_by",
|
||||||
|
"created_by",
|
||||||
|
"source",
|
||||||
|
"domain",
|
||||||
|
"category",
|
||||||
|
"subcategory_obj",
|
||||||
|
"classification_obj",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply RBAC filters
|
||||||
|
user = request.user
|
||||||
|
if user.is_px_admin():
|
||||||
|
pass
|
||||||
|
elif user.is_hospital_admin() and user.hospital:
|
||||||
|
queryset = queryset.filter(hospital=user.hospital)
|
||||||
|
elif user.is_department_manager() and user.department:
|
||||||
|
queryset = queryset.filter(department=user.department)
|
||||||
|
elif user.hospital:
|
||||||
|
queryset = queryset.filter(hospital=user.hospital)
|
||||||
|
else:
|
||||||
|
queryset = queryset.none()
|
||||||
|
|
||||||
|
# Apply date range filter
|
||||||
|
date_start = request.GET.get("date_start")
|
||||||
|
date_end = request.GET.get("date_end")
|
||||||
|
|
||||||
|
if date_start:
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
try:
|
||||||
|
date_start_dt = datetime.strptime(date_start, "%Y-%m-%d")
|
||||||
|
queryset = queryset.filter(created_at__date__gte=date_start_dt)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if date_end:
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
try:
|
||||||
|
date_end_dt = datetime.strptime(date_end, "%Y-%m-%d")
|
||||||
|
queryset = queryset.filter(created_at__date__lte=date_end_dt)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Apply other filters from request
|
||||||
|
status_filter = request.GET.get("status")
|
||||||
|
if status_filter:
|
||||||
|
queryset = queryset.filter(status=status_filter)
|
||||||
|
|
||||||
|
hospital_filter = request.GET.get("hospital")
|
||||||
|
if hospital_filter:
|
||||||
|
queryset = queryset.filter(hospital_id=hospital_filter)
|
||||||
|
|
||||||
|
department_filter = request.GET.get("department")
|
||||||
|
if department_filter:
|
||||||
|
queryset = queryset.filter(department_id=department_filter)
|
||||||
|
|
||||||
|
search_query = request.GET.get("search")
|
||||||
|
if search_query:
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(title__icontains=search_query)
|
||||||
|
| Q(description__icontains=search_query)
|
||||||
|
| Q(patient__mrn__icontains=search_query)
|
||||||
|
)
|
||||||
|
|
||||||
|
return export_historical_excel(queryset, date_start, date_end)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def complaint_export_monthly_calculations(request):
|
def complaint_export_monthly_calculations(request):
|
||||||
"""
|
"""
|
||||||
@ -3357,7 +3438,7 @@ def involved_staff_add(request, complaint_pk):
|
|||||||
- Responsible: Staff responsible for resolution
|
- Responsible: Staff responsible for resolution
|
||||||
- Investigator: Staff investigating the complaint
|
- Investigator: Staff investigating the complaint
|
||||||
- Support: Support staff
|
- Support: Support staff
|
||||||
- Coordinator: Coordination role
|
- PX Staff: Coordination role
|
||||||
"""
|
"""
|
||||||
complaint = get_object_or_404(Complaint, pk=complaint_pk)
|
complaint = get_object_or_404(Complaint, pk=complaint_pk)
|
||||||
|
|
||||||
|
|||||||
@ -286,7 +286,7 @@ def oncall_admin_add(request, schedule_pk):
|
|||||||
existing_admin_ids = schedule.on_call_admins.values_list("admin_user_id", flat=True)
|
existing_admin_ids = schedule.on_call_admins.values_list("admin_user_id", flat=True)
|
||||||
available_admins = (
|
available_admins = (
|
||||||
User.objects.filter(
|
User.objects.filter(
|
||||||
Q(groups__name="PX Admin") | Q(groups__name="PX Coordinator") | Q(groups__name="Hospital Admin"),
|
Q(groups__name="PX Admin") | Q(groups__name="PX Staff") | Q(groups__name="Hospital Admin"),
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
.exclude(id__in=existing_admin_ids)
|
.exclude(id__in=existing_admin_ids)
|
||||||
@ -344,7 +344,7 @@ def oncall_admin_add(request, schedule_pk):
|
|||||||
"schedule": schedule,
|
"schedule": schedule,
|
||||||
"available_admins": available_admins,
|
"available_admins": available_admins,
|
||||||
"available_px_admins": available_admins.filter(groups__name="PX Admin"),
|
"available_px_admins": available_admins.filter(groups__name="PX Admin"),
|
||||||
"available_coordinators": available_admins.filter(groups__name="PX Coordinator"),
|
"available_px_staff": available_admins.filter(groups__name="PX Staff"),
|
||||||
"available_hospital_admins": available_admins.filter(groups__name="Hospital Admin"),
|
"available_hospital_admins": available_admins.filter(groups__name="Hospital Admin"),
|
||||||
"title": _("Add On-Call Admin"),
|
"title": _("Add On-Call Admin"),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,6 +49,11 @@ urlpatterns = [
|
|||||||
# Export Views
|
# Export Views
|
||||||
path("export/csv/", ui_views.complaint_export_csv, name="complaint_export_csv"),
|
path("export/csv/", ui_views.complaint_export_csv, name="complaint_export_csv"),
|
||||||
path("export/excel/", ui_views.complaint_export_excel, name="complaint_export_excel"),
|
path("export/excel/", ui_views.complaint_export_excel, name="complaint_export_excel"),
|
||||||
|
path(
|
||||||
|
"export/historical-excel/",
|
||||||
|
ui_views.complaint_export_historical_excel,
|
||||||
|
name="complaint_export_historical_excel",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"export/monthly-calculations/",
|
"export/monthly-calculations/",
|
||||||
ui_views.complaint_export_monthly_calculations,
|
ui_views.complaint_export_monthly_calculations,
|
||||||
|
|||||||
@ -1839,3 +1839,342 @@ def export_observations_report(queryset, year, month):
|
|||||||
response["Content-Disposition"] = f'attachment; filename="observations_{month_label}.xlsx"'
|
response["Content-Disposition"] = f'attachment; filename="observations_{month_label}.xlsx"'
|
||||||
wb.save(response)
|
wb.save(response)
|
||||||
return response
|
return response
|
||||||
|
def export_historical_excel(queryset, date_start=None, date_end=None):
|
||||||
|
"""
|
||||||
|
Export complaints to historical Excel format with Arabic headers.
|
||||||
|
|
||||||
|
Creates a single sheet with all 105 columns matching the historical
|
||||||
|
complaint report format used in the original Excel files.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
queryset: Complaint queryset to export
|
||||||
|
date_start: Optional start date filter
|
||||||
|
date_end: Optional end date filter
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HttpResponse with Excel file
|
||||||
|
"""
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
|
||||||
|
# Set RTL direction for Arabic
|
||||||
|
ws.sheet_view.rightToLeft = True
|
||||||
|
|
||||||
|
# Define styles
|
||||||
|
header_font = Font(bold=True, color="FFFFFF", size=11)
|
||||||
|
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
||||||
|
header_alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||||
|
thin_border = Border(
|
||||||
|
left=Side(style="thin"), right=Side(style="thin"), top=Side(style="thin"), bottom=Side(style="thin")
|
||||||
|
)
|
||||||
|
|
||||||
|
# All 105 headers in Arabic (matching historical Excel format)
|
||||||
|
headers = [
|
||||||
|
"Week", # 1
|
||||||
|
"No.", # 2
|
||||||
|
"رقم الشكوى", # 3 - Complaint Number
|
||||||
|
"رقم الملف", # 4 - File Number (MRN)
|
||||||
|
"جهة الشكوى", # 5 - Complaint Source
|
||||||
|
"الموقع", # 6 - Location
|
||||||
|
"القسم الرئيس", # 7 - Main Department
|
||||||
|
"القسم الفرعي", # 8 - Sub Department
|
||||||
|
"تاريخ إستلام الشكوى", # 9 - Date Received
|
||||||
|
"المدخل", # 10 - Entered By
|
||||||
|
"Time line", # 11
|
||||||
|
"إرسال نموذج الشكوى", # 12 - Form Sent
|
||||||
|
"Unnamed: 12", # 13
|
||||||
|
"Employee ", # 14
|
||||||
|
"تحرير الشكوى", # 15 - Complaint Filed
|
||||||
|
"Unnamed: 15", # 16
|
||||||
|
"تفعيل الشكوى", # 17 - Activated
|
||||||
|
"Unnamed: 17", # 18
|
||||||
|
"Employee .1", # 19
|
||||||
|
"تم ارسال الشكوى", # 20 - Sent
|
||||||
|
"Unnamed: 20", # 21
|
||||||
|
"Employee .2", # 22
|
||||||
|
"الوقت بين التحرير والارسال", # 23 - Time: Filed to Sent
|
||||||
|
"First Reminder Sent", # 24
|
||||||
|
"Unnamed: 24", # 25
|
||||||
|
"Employee .3", # 26
|
||||||
|
"الوقت بين اول ايميل والارسال", # 27 - Time: 1st Rem to Sent
|
||||||
|
"Second Reminder Sent", # 28
|
||||||
|
"Unnamed: 28", # 29
|
||||||
|
"Employee .4", # 30
|
||||||
|
"الوقت بين ثاني ايميل والاول", # 31 - Time: 2nd Rem to 1st
|
||||||
|
"Escalated", # 32
|
||||||
|
"Unnamed: 32", # 33
|
||||||
|
"Employee .5", # 34
|
||||||
|
"Reason of Escalation ", # 35
|
||||||
|
"الوقت بين التصعيد والارسال", # 36 - Time: Escalation to Sent
|
||||||
|
"Closed", # 37
|
||||||
|
"Unnamed: 37", # 38
|
||||||
|
"Employee .6", # 39
|
||||||
|
"الوقت بين الاغلاق والارسال", # 40 - Time: Close to Sent
|
||||||
|
"تاريخ الرد", # 41 - Response Date
|
||||||
|
"Unnamed: 41", # 42
|
||||||
|
"الوقت بين الرد والارسال", # 43 - Time: Response to Sent
|
||||||
|
"Resolved", # 44
|
||||||
|
"Unnamed: 44", # 45
|
||||||
|
"Employee .7", # 46
|
||||||
|
"الوقت بين المعالجة و الارسال", # 47 - Time: Resolve to Sent
|
||||||
|
"ID", # 48
|
||||||
|
"اسم الشخص المشتكى عليه - ان وجد", # 49 - Accused Staff Name
|
||||||
|
"Domain", # 50
|
||||||
|
"Category", # 51
|
||||||
|
"Sub-Category", # 52
|
||||||
|
"Classification", # 53
|
||||||
|
"محتوى الشكوى (عربي)", # 54 - Content Arabic
|
||||||
|
"محتوى الشكوى (English)", # 55 - Content English
|
||||||
|
"Satisfied/Dissatisfied", # 56
|
||||||
|
"The Rightful Side", # 57
|
||||||
|
"Recommendation/Action plan", # 58
|
||||||
|
"Unnamed: 58", # 59
|
||||||
|
"Unnamed: 59", # 60
|
||||||
|
"Unnamed: 60", # 61
|
||||||
|
"Unnamed: 61", # 62
|
||||||
|
"Unnamed: 62", # 63
|
||||||
|
"Unnamed: 63", # 64
|
||||||
|
"Unnamed: 64", # 65
|
||||||
|
"Unnamed: 65", # 66
|
||||||
|
"رقم الشكوى.1", # 67 - Duplicate columns (for formulas)
|
||||||
|
"رقم الملف.1", # 68
|
||||||
|
"جهة الشكوى.1", # 69
|
||||||
|
"الموقع.1", # 70
|
||||||
|
"القسم الرئيس.1", # 71
|
||||||
|
"القسم الفرعي.1", # 72
|
||||||
|
"تاريخ إستلام الشكوى.1", # 73
|
||||||
|
"المدخل.1", # 74
|
||||||
|
"Time line.1", # 75
|
||||||
|
"Unnamed: 75", # 76
|
||||||
|
"رقم الشكوى.2", # 77
|
||||||
|
"رقم الملف.2", # 78
|
||||||
|
"جهة الشكوى.2", # 79
|
||||||
|
"الموقع.2", # 80
|
||||||
|
"القسم الرئيس.2", # 81
|
||||||
|
"القسم الفرعي.2", # 82
|
||||||
|
"تاريخ إستلام الشكوى.2", # 83
|
||||||
|
"المدخل.2", # 84
|
||||||
|
"Time line.2", # 85
|
||||||
|
"Unnamed: 85", # 86
|
||||||
|
"رقم الشكوى.3", # 87
|
||||||
|
"رقم الملف.3", # 88
|
||||||
|
"جهة الشكوى.3", # 89
|
||||||
|
"الموقع.3", # 90
|
||||||
|
"القسم الرئيس.3", # 91
|
||||||
|
"القسم الفرعي.3", # 92
|
||||||
|
"تاريخ إستلام الشكوى.3", # 93
|
||||||
|
"المدخل.3", # 94
|
||||||
|
"Time line.3", # 95
|
||||||
|
"Unnamed: 95", # 96
|
||||||
|
"رقم الشكوى.4", # 97
|
||||||
|
"رقم الملف.4", # 98
|
||||||
|
"جهة الشكوى.4", # 99
|
||||||
|
"الموقع.4", # 100
|
||||||
|
"القسم الرئيس.4", # 101
|
||||||
|
"القسم الفرعي.4", # 102
|
||||||
|
"تاريخ إستلام الشكوى.4", # 103
|
||||||
|
"المدخل.4", # 104
|
||||||
|
"Time line.4", # 105
|
||||||
|
]
|
||||||
|
|
||||||
|
# Write headers
|
||||||
|
for col_num, header in enumerate(headers, 1):
|
||||||
|
cell = ws.cell(row=1, column=col_num, value=header)
|
||||||
|
cell.font = header_font
|
||||||
|
cell.fill = header_fill
|
||||||
|
cell.alignment = header_alignment
|
||||||
|
cell.border = thin_border
|
||||||
|
|
||||||
|
# Freeze top row
|
||||||
|
ws.freeze_panes = "A2"
|
||||||
|
|
||||||
|
# Prepare queryset with related fields
|
||||||
|
qs = queryset.select_related(
|
||||||
|
"patient",
|
||||||
|
"hospital",
|
||||||
|
"department",
|
||||||
|
"main_section",
|
||||||
|
"subsection",
|
||||||
|
"assigned_to",
|
||||||
|
"resolved_by",
|
||||||
|
"closed_by",
|
||||||
|
"created_by",
|
||||||
|
"source",
|
||||||
|
"domain",
|
||||||
|
"category",
|
||||||
|
"subcategory_obj",
|
||||||
|
"classification_obj",
|
||||||
|
).prefetch_related("updates")
|
||||||
|
|
||||||
|
row_num = 2
|
||||||
|
for idx, c in enumerate(qs, 1):
|
||||||
|
# Get week number from created_at
|
||||||
|
cal = c.created_at
|
||||||
|
week_of_month = (cal.day - 1) // 7 + 1 if cal else ""
|
||||||
|
|
||||||
|
# Helper to get name with fallback
|
||||||
|
def get_name(obj, attr="name"):
|
||||||
|
if not obj:
|
||||||
|
return ""
|
||||||
|
if hasattr(obj, attr):
|
||||||
|
val = getattr(obj, attr)
|
||||||
|
if hasattr(val, "name_ar"):
|
||||||
|
return val.name_ar
|
||||||
|
elif hasattr(val, "name"):
|
||||||
|
return val.name
|
||||||
|
return str(val)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def get_name_ar(obj):
|
||||||
|
if not obj:
|
||||||
|
return ""
|
||||||
|
if hasattr(obj, "name_ar"):
|
||||||
|
return obj.name_ar
|
||||||
|
elif hasattr(obj, "name"):
|
||||||
|
return obj.name
|
||||||
|
return str(obj)
|
||||||
|
|
||||||
|
# Source name
|
||||||
|
source_name = ""
|
||||||
|
if c.source:
|
||||||
|
source_name = c.source.name_ar or c.source.name_en or str(c.source)
|
||||||
|
|
||||||
|
# Location (main_section or location)
|
||||||
|
location_name = ""
|
||||||
|
if c.main_section:
|
||||||
|
location_name = get_name_ar(c.main_section)
|
||||||
|
elif c.location:
|
||||||
|
location_name = get_name_ar(c.location)
|
||||||
|
|
||||||
|
# Departments
|
||||||
|
main_dept = ""
|
||||||
|
if c.main_section:
|
||||||
|
main_dept = get_name_ar(c.main_section)
|
||||||
|
elif c.department:
|
||||||
|
main_dept = get_name_ar(c.department)
|
||||||
|
|
||||||
|
sub_dept = ""
|
||||||
|
if c.subsection:
|
||||||
|
sub_dept = get_name_ar(c.subsection)
|
||||||
|
|
||||||
|
# Entered by (assigned_to or created_by)
|
||||||
|
entered_by = ""
|
||||||
|
if c.assigned_to:
|
||||||
|
entered_by = c.assigned_to.get_full_name() or c.assigned_to.username
|
||||||
|
elif c.created_by:
|
||||||
|
entered_by = c.created_by.get_full_name() or c.created_by.username
|
||||||
|
|
||||||
|
# Taxonomy fields
|
||||||
|
domain_name = ""
|
||||||
|
if c.domain:
|
||||||
|
domain_name = c.domain.name_ar or c.domain.name_en or str(c.domain)
|
||||||
|
|
||||||
|
category_name = ""
|
||||||
|
if c.category:
|
||||||
|
category_name = c.category.name_ar or c.category.name_en or str(c.category)
|
||||||
|
|
||||||
|
subcategory_name = ""
|
||||||
|
if c.subcategory_obj:
|
||||||
|
subcategory_name = c.subcategory_obj.name_ar or c.subcategory_obj.name_en or str(c.subcategory_obj)
|
||||||
|
|
||||||
|
classification_name = ""
|
||||||
|
if c.classification_obj:
|
||||||
|
classification_name = (
|
||||||
|
c.classification_obj.name_ar or c.classification_obj.name_en or str(c.classification_obj)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Description - use as is (not splitting)
|
||||||
|
description = c.description or ""
|
||||||
|
|
||||||
|
# Satisfaction
|
||||||
|
satisfaction = ""
|
||||||
|
if c.satisfaction:
|
||||||
|
satisfaction = "Satisfied" if c.satisfaction == "satisfied" else "Dissatisfied"
|
||||||
|
|
||||||
|
# Rightful side
|
||||||
|
rightful_side = ""
|
||||||
|
if c.resolution_outcome:
|
||||||
|
rightful_side = c.get_resolution_outcome_display() or c.resolution_outcome
|
||||||
|
|
||||||
|
# Staff name
|
||||||
|
staff_name = c.staff_name or ""
|
||||||
|
if not staff_name and c.staff:
|
||||||
|
staff_name = c.staff.get_full_name() or str(c.staff)
|
||||||
|
|
||||||
|
# Reference number components
|
||||||
|
ref_parts = c.reference_number.split("-") if c.reference_number else []
|
||||||
|
complaint_num = ref_parts[-1] if len(ref_parts) >= 4 else ""
|
||||||
|
|
||||||
|
# Build row data (only first ~60 columns have meaningful data)
|
||||||
|
row_data = [""] * 105
|
||||||
|
row_data[0] = week_of_month # Week
|
||||||
|
row_data[1] = idx # No.
|
||||||
|
row_data[2] = complaint_num # رقم الشكوى
|
||||||
|
row_data[3] = c.patient.mrn if c.patient else "" # رقم الملف
|
||||||
|
row_data[4] = source_name # جهة الشكوى
|
||||||
|
row_data[5] = location_name # الموقع
|
||||||
|
row_data[6] = main_dept # القسم الرئيس
|
||||||
|
row_data[7] = sub_dept # القسم الفرعي
|
||||||
|
row_data[8] = cal.strftime("%Y-%m-%d %H:%M:%S") if cal else "" # تاريخ إستلام الشكوى
|
||||||
|
row_data[9] = entered_by # المدخل
|
||||||
|
# Timeline columns (11-47) - mostly empty as we don't have granular data
|
||||||
|
row_data[47] = "" # Time calculations placeholder
|
||||||
|
# Main data columns
|
||||||
|
row_data[48] = c.metadata.get("original_complaint_id", "") if c.metadata else "" # ID
|
||||||
|
row_data[49] = staff_name # اسم الشخص المشتكى عليه
|
||||||
|
row_data[50] = domain_name # Domain
|
||||||
|
row_data[51] = category_name # Category
|
||||||
|
row_data[52] = subcategory_name # Sub-Category
|
||||||
|
row_data[53] = classification_name # Classification
|
||||||
|
row_data[54] = description # محتوى الشكوى (عربي) - full description
|
||||||
|
row_data[55] = "" # محتوى الشكوى (English) - empty, Arabic column has full content
|
||||||
|
row_data[56] = satisfaction # Satisfied/Dissatisfied
|
||||||
|
row_data[57] = rightful_side # The Rightful Side
|
||||||
|
row_data[58] = c.recommendation_action_plan or "" # Recommendation/Action plan
|
||||||
|
# Duplicate columns (67-105) - formulas reference first set, keep empty
|
||||||
|
|
||||||
|
# Write row
|
||||||
|
for col_num, val in enumerate(row_data, 1):
|
||||||
|
cell = ws.cell(row=row_num, column=col_num, value=val)
|
||||||
|
cell.border = thin_border
|
||||||
|
cell.alignment = Alignment(vertical="center", wrap_text=True)
|
||||||
|
|
||||||
|
row_num += 1
|
||||||
|
|
||||||
|
# Auto-adjust column widths for main columns
|
||||||
|
for col_num in range(1, 60):
|
||||||
|
col_letter = get_column_letter(col_num)
|
||||||
|
if col_num in [3, 4, 5, 6, 7, 8]: # Arabic text columns
|
||||||
|
ws.column_dimensions[col_letter].width = 25
|
||||||
|
elif col_num in [54, 55]: # Description columns
|
||||||
|
ws.column_dimensions[col_letter].width = 50
|
||||||
|
elif col_num == 49: # Staff name
|
||||||
|
ws.column_dimensions[col_letter].width = 30
|
||||||
|
else:
|
||||||
|
ws.column_dimensions[col_letter].width = 15
|
||||||
|
|
||||||
|
# Hide the duplicate columns (67-105) as they're for formulas
|
||||||
|
for col_num in range(67, 106):
|
||||||
|
col_letter = get_column_letter(col_num)
|
||||||
|
ws.column_dimensions[col_letter].hidden = True
|
||||||
|
|
||||||
|
# Generate filename
|
||||||
|
if date_start and date_end:
|
||||||
|
sheet_name = f"{date_start}_to_{date_end}"
|
||||||
|
elif date_start:
|
||||||
|
sheet_name = f"from_{date_start}"
|
||||||
|
elif date_end:
|
||||||
|
sheet_name = f"until_{date_end}"
|
||||||
|
else:
|
||||||
|
sheet_name = "all_complaints"
|
||||||
|
|
||||||
|
ws.title = sheet_name[:31] # Excel sheet name limit
|
||||||
|
|
||||||
|
filename = f"historical_complaints_{sheet_name}.xlsx"
|
||||||
|
|
||||||
|
# Save to response
|
||||||
|
response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||||
|
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||||
|
wb.save(response)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|||||||
@ -13,5 +13,6 @@ urlpatterns = [
|
|||||||
path("routing/", config_views.routing_rules_list, name="routing_rules_list"),
|
path("routing/", config_views.routing_rules_list, name="routing_rules_list"),
|
||||||
path("users/", config_views.hospital_users_list, name="hospital_users_list"),
|
path("users/", config_views.hospital_users_list, name="hospital_users_list"),
|
||||||
path("users/<uuid:user_id>/reset-password/", config_views.reset_user_password, name="reset_user_password"),
|
path("users/<uuid:user_id>/reset-password/", config_views.reset_user_password, name="reset_user_password"),
|
||||||
|
path("users/<uuid:user_id>/toggle-active/", config_views.toggle_user_active, name="toggle_user_active"),
|
||||||
path("test/", config_views.test, name="test"),
|
path("test/", config_views.test, name="test"),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -260,6 +260,38 @@ def reset_user_password(request, user_id):
|
|||||||
|
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from rich import print
|
from rich import print
|
||||||
|
from django.contrib.auth.hashers import check_password as verify_password
|
||||||
|
|
||||||
|
|
||||||
|
@admin_required
|
||||||
|
def toggle_user_active(request, user_id):
|
||||||
|
if request.method != "POST":
|
||||||
|
return JsonResponse({"error": "Method not allowed"}, status=405)
|
||||||
|
|
||||||
|
target_user = get_object_or_404(User, pk=user_id, is_superuser=False, is_provisional=False)
|
||||||
|
|
||||||
|
if not request.user.is_px_admin():
|
||||||
|
return JsonResponse({"error": "Only PX Admins can activate/deactivate users."}, status=403)
|
||||||
|
|
||||||
|
data = json.loads(request.body) if request.body else {}
|
||||||
|
password = data.get("password", "")
|
||||||
|
|
||||||
|
if not verify_password(password, request.user.password):
|
||||||
|
return JsonResponse({"error": "Incorrect password. Please try again."}, status=400)
|
||||||
|
|
||||||
|
target_user.is_active = not target_user.is_active
|
||||||
|
target_user.save(update_fields=["is_active"])
|
||||||
|
|
||||||
|
status_label = "activated" if target_user.is_active else "deactivated"
|
||||||
|
|
||||||
|
return JsonResponse(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"is_active": target_user.is_active,
|
||||||
|
"message": f"User {target_user.get_full_name()} has been {status_label}.",
|
||||||
|
"user_name": target_user.get_full_name(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
|
|||||||
@ -98,7 +98,11 @@ def hospital_context(request):
|
|||||||
if request.user.is_px_admin():
|
if request.user.is_px_admin():
|
||||||
from apps.organizations.models import Hospital
|
from apps.organizations.models import Hospital
|
||||||
|
|
||||||
hospitals_list = list(Hospital.objects.filter(status="active").order_by("name").values("id", "name", "code"))
|
hospitals_list = list(
|
||||||
|
Hospital.objects.filter(status="active")
|
||||||
|
.order_by("name")
|
||||||
|
.values("id", "name", "display_name", "display_name_ar", "code")
|
||||||
|
)
|
||||||
|
|
||||||
# Source user context
|
# Source user context
|
||||||
is_source_user = request.user.is_source_user()
|
is_source_user = request.user.is_source_user()
|
||||||
|
|||||||
@ -3,11 +3,12 @@ Role-based access control decorators for PX360.
|
|||||||
|
|
||||||
Provides decorators to restrict views based on user roles:
|
Provides decorators to restrict views based on user roles:
|
||||||
- PX Admin
|
- PX Admin
|
||||||
- Hospital Admin
|
- Hospital Admin
|
||||||
- Department Manager
|
- Department Manager
|
||||||
- PX Coordinator
|
- PX Staff
|
||||||
- Source User
|
- Source User
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
@ -19,165 +20,148 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
def px_admin_required(view_func):
|
def px_admin_required(view_func):
|
||||||
"""
|
"""
|
||||||
Decorator to restrict access to PX Admins only.
|
Decorator to restrict access to PX Admins only.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
@px_admin_required
|
@px_admin_required
|
||||||
def system_settings(request):
|
def system_settings(request):
|
||||||
# Only PX Admins can access this
|
# Only PX Admins can access this
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@wraps(view_func)
|
@wraps(view_func)
|
||||||
@login_required
|
@login_required
|
||||||
def _wrapped_view(request, *args, **kwargs):
|
def _wrapped_view(request, *args, **kwargs):
|
||||||
if not request.user.is_px_admin():
|
if not request.user.is_px_admin():
|
||||||
messages.error(
|
messages.error(request, _("Access denied. PX Admin privileges required."))
|
||||||
request,
|
return redirect("analytics:command_center")
|
||||||
_("Access denied. PX Admin privileges required.")
|
|
||||||
)
|
|
||||||
return redirect('analytics:command_center')
|
|
||||||
|
|
||||||
return view_func(request, *args, **kwargs)
|
return view_func(request, *args, **kwargs)
|
||||||
|
|
||||||
return _wrapped_view
|
return _wrapped_view
|
||||||
|
|
||||||
|
|
||||||
def hospital_admin_required(view_func):
|
def hospital_admin_required(view_func):
|
||||||
"""
|
"""
|
||||||
Decorator to restrict access to Hospital Admins and PX Admins.
|
Decorator to restrict access to Hospital Admins and PX Admins.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
@hospital_admin_required
|
@hospital_admin_required
|
||||||
def hospital_settings(request):
|
def hospital_settings(request):
|
||||||
# Only Hospital Admins and PX Admins can access this
|
# Only Hospital Admins and PX Admins can access this
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@wraps(view_func)
|
@wraps(view_func)
|
||||||
@login_required
|
@login_required
|
||||||
def _wrapped_view(request, *args, **kwargs):
|
def _wrapped_view(request, *args, **kwargs):
|
||||||
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
|
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
|
||||||
messages.error(
|
messages.error(request, _("Access denied. Hospital Admin privileges required."))
|
||||||
request,
|
return redirect("analytics:command_center")
|
||||||
_("Access denied. Hospital Admin privileges required.")
|
|
||||||
)
|
|
||||||
return redirect('analytics:command_center')
|
|
||||||
|
|
||||||
return view_func(request, *args, **kwargs)
|
return view_func(request, *args, **kwargs)
|
||||||
|
|
||||||
return _wrapped_view
|
return _wrapped_view
|
||||||
|
|
||||||
|
|
||||||
def admin_required(view_func):
|
def admin_required(view_func):
|
||||||
"""
|
"""
|
||||||
Decorator to restrict access to any Admin (PX, Hospital, or Dept Manager).
|
Decorator to restrict access to any Admin (PX, Hospital, or Dept Manager).
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
@admin_required
|
@admin_required
|
||||||
def management_view(request):
|
def management_view(request):
|
||||||
# Any admin can access this
|
# Any admin can access this
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@wraps(view_func)
|
@wraps(view_func)
|
||||||
@login_required
|
@login_required
|
||||||
def _wrapped_view(request, *args, **kwargs):
|
def _wrapped_view(request, *args, **kwargs):
|
||||||
if not (request.user.is_px_admin() or
|
if not (request.user.is_px_admin() or request.user.is_hospital_admin() or request.user.is_department_manager()):
|
||||||
request.user.is_hospital_admin() or
|
messages.error(request, _("Access denied. Admin privileges required."))
|
||||||
request.user.is_department_manager()):
|
return redirect("analytics:command_center")
|
||||||
messages.error(
|
|
||||||
request,
|
|
||||||
_("Access denied. Admin privileges required.")
|
|
||||||
)
|
|
||||||
return redirect('analytics:command_center')
|
|
||||||
|
|
||||||
return view_func(request, *args, **kwargs)
|
return view_func(request, *args, **kwargs)
|
||||||
|
|
||||||
return _wrapped_view
|
return _wrapped_view
|
||||||
|
|
||||||
|
|
||||||
def px_coordinator_required(view_func):
|
def px_staff_required(view_func):
|
||||||
"""
|
"""
|
||||||
Decorator to restrict access to PX Coordinators and above.
|
Decorator to restrict access to PX Staff and above.
|
||||||
|
|
||||||
Allows: PX Admin, Hospital Admin, Department Manager, PX Coordinator
|
Allows: PX Admin, Hospital Admin, Department Manager, PX Staff
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
@px_coordinator_required
|
@px_staff_required
|
||||||
def complaint_management(request):
|
def complaint_management(request):
|
||||||
# Coordinators and admins can access this
|
# Staff and admins can access this
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@wraps(view_func)
|
@wraps(view_func)
|
||||||
@login_required
|
@login_required
|
||||||
def _wrapped_view(request, *args, **kwargs):
|
def _wrapped_view(request, *args, **kwargs):
|
||||||
user = request.user
|
user = request.user
|
||||||
if not (user.is_px_admin() or
|
if not (
|
||||||
user.is_hospital_admin() or
|
user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager() or user.has_role("PX Staff")
|
||||||
user.is_department_manager() or
|
):
|
||||||
user.has_role('PX Coordinator')):
|
messages.error(request, _("Access denied. PX Staff privileges required."))
|
||||||
messages.error(
|
return redirect("analytics:command_center")
|
||||||
request,
|
|
||||||
_("Access denied. PX Coordinator privileges required.")
|
|
||||||
)
|
|
||||||
return redirect('analytics:command_center')
|
|
||||||
|
|
||||||
return view_func(request, *args, **kwargs)
|
return view_func(request, *args, **kwargs)
|
||||||
|
|
||||||
return _wrapped_view
|
return _wrapped_view
|
||||||
|
|
||||||
|
|
||||||
def staff_required(view_func):
|
def staff_required(view_func):
|
||||||
"""
|
"""
|
||||||
Decorator to restrict access to Hospital Staff (not Source Users).
|
Decorator to restrict access to Hospital Staff (not Source Users).
|
||||||
|
|
||||||
Allows all authenticated users except Source Users.
|
Allows all authenticated users except Source Users.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
@staff_required
|
@staff_required
|
||||||
def internal_tool(request):
|
def internal_tool(request):
|
||||||
# Any hospital staff can access, but not source users
|
# Any hospital staff can access, but not source users
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@wraps(view_func)
|
@wraps(view_func)
|
||||||
@login_required
|
@login_required
|
||||||
def _wrapped_view(request, *args, **kwargs):
|
def _wrapped_view(request, *args, **kwargs):
|
||||||
if request.user.is_source_user():
|
if request.user.is_source_user():
|
||||||
messages.error(
|
messages.error(request, _("Access denied. This page is not available for source users."))
|
||||||
request,
|
return redirect("px_sources:source_user_dashboard")
|
||||||
_("Access denied. This page is not available for source users.")
|
|
||||||
)
|
|
||||||
return redirect('px_sources:source_user_dashboard')
|
|
||||||
|
|
||||||
return view_func(request, *args, **kwargs)
|
return view_func(request, *args, **kwargs)
|
||||||
|
|
||||||
return _wrapped_view
|
return _wrapped_view
|
||||||
|
|
||||||
|
|
||||||
def source_user_required(view_func):
|
def source_user_required(view_func):
|
||||||
"""
|
"""
|
||||||
Decorator to restrict access to Source Users only.
|
Decorator to restrict access to Source Users only.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
@source_user_required
|
@source_user_required
|
||||||
def source_user_dashboard(request):
|
def source_user_dashboard(request):
|
||||||
# Only source users can access this
|
# Only source users can access this
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@wraps(view_func)
|
@wraps(view_func)
|
||||||
@login_required
|
@login_required
|
||||||
def _wrapped_view(request, *args, **kwargs):
|
def _wrapped_view(request, *args, **kwargs):
|
||||||
if not request.user.is_source_user():
|
if not request.user.is_source_user():
|
||||||
raise PermissionDenied(
|
raise PermissionDenied(_("Access denied. Source user privileges required."))
|
||||||
_("Access denied. Source user privileges required.")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get source user profile
|
# Get source user profile
|
||||||
profile = request.user.get_source_user_profile_active()
|
profile = request.user.get_source_user_profile_active()
|
||||||
if not profile:
|
if not profile:
|
||||||
messages.error(
|
messages.error(request, _("Your source user account is inactive. Please contact your administrator."))
|
||||||
request,
|
return redirect("accounts:login")
|
||||||
_("Your source user account is inactive. Please contact your administrator.")
|
|
||||||
)
|
|
||||||
return redirect('accounts:login')
|
|
||||||
|
|
||||||
# Store in request for easy access
|
# Store in request for easy access
|
||||||
request.source_user = profile
|
request.source_user = profile
|
||||||
request.source = profile.source
|
request.source = profile.source
|
||||||
|
|
||||||
return view_func(request, *args, **kwargs)
|
return view_func(request, *args, **kwargs)
|
||||||
|
|
||||||
return _wrapped_view
|
return _wrapped_view
|
||||||
|
|
||||||
|
|
||||||
@ -185,44 +169,44 @@ def block_source_user(view_func):
|
|||||||
"""
|
"""
|
||||||
Decorator to BLOCK source users from accessing admin/staff pages.
|
Decorator to BLOCK source users from accessing admin/staff pages.
|
||||||
Redirects source users to their dashboard instead.
|
Redirects source users to their dashboard instead.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
@block_source_user
|
@block_source_user
|
||||||
def staff_management(request):
|
def staff_management(request):
|
||||||
# Source users CANNOT access this
|
# Source users CANNOT access this
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@wraps(view_func)
|
@wraps(view_func)
|
||||||
@login_required
|
@login_required
|
||||||
def _wrapped_view(request, *args, **kwargs):
|
def _wrapped_view(request, *args, **kwargs):
|
||||||
if request.user.is_source_user():
|
if request.user.is_source_user():
|
||||||
# Silently redirect to source user dashboard
|
# Silently redirect to source user dashboard
|
||||||
return redirect('px_sources:source_user_dashboard')
|
return redirect("px_sources:source_user_dashboard")
|
||||||
|
|
||||||
return view_func(request, *args, **kwargs)
|
return view_func(request, *args, **kwargs)
|
||||||
|
|
||||||
return _wrapped_view
|
return _wrapped_view
|
||||||
|
|
||||||
|
|
||||||
def source_user_or_admin(view_func):
|
def source_user_or_admin(view_func):
|
||||||
"""
|
"""
|
||||||
Decorator that allows both source users AND admins.
|
Decorator that allows both source users AND admins.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
@source_user_or_admin
|
@source_user_or_admin
|
||||||
def complaint_detail(request, pk):
|
def complaint_detail(request, pk):
|
||||||
# Both source users and admins can view
|
# Both source users and admins can view
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@wraps(view_func)
|
@wraps(view_func)
|
||||||
@login_required
|
@login_required
|
||||||
def _wrapped_view(request, *args, **kwargs):
|
def _wrapped_view(request, *args, **kwargs):
|
||||||
user = request.user
|
user = request.user
|
||||||
|
|
||||||
# Allow admins
|
# Allow admins
|
||||||
if (user.is_px_admin() or
|
if user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager():
|
||||||
user.is_hospital_admin() or
|
|
||||||
user.is_department_manager()):
|
|
||||||
return view_func(request, *args, **kwargs)
|
return view_func(request, *args, **kwargs)
|
||||||
|
|
||||||
# Allow active source users
|
# Allow active source users
|
||||||
if user.is_source_user():
|
if user.is_source_user():
|
||||||
profile = user.get_source_user_profile_active()
|
profile = user.get_source_user_profile_active()
|
||||||
@ -230,7 +214,7 @@ def source_user_or_admin(view_func):
|
|||||||
request.source_user = profile
|
request.source_user = profile
|
||||||
request.source = profile.source
|
request.source = profile.source
|
||||||
return view_func(request, *args, **kwargs)
|
return view_func(request, *args, **kwargs)
|
||||||
|
|
||||||
raise PermissionDenied(_("Access denied."))
|
raise PermissionDenied(_("Access denied."))
|
||||||
|
|
||||||
return _wrapped_view
|
return _wrapped_view
|
||||||
|
|||||||
@ -75,10 +75,10 @@ class Command(BaseCommand):
|
|||||||
"is_staff": False,
|
"is_staff": False,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"email": "e2e-px-coordinator@px360.test",
|
"email": "e2e-px-staff@px360.test",
|
||||||
"role": "PX Coordinator",
|
"role": "PX Staff",
|
||||||
"first_name": "E2E",
|
"first_name": "E2E",
|
||||||
"last_name": "PX Coordinator",
|
"last_name": "PX Staff",
|
||||||
"hospital": hospital,
|
"hospital": hospital,
|
||||||
"is_staff": False,
|
"is_staff": False,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -698,7 +698,7 @@ class Command(BaseCommand):
|
|||||||
{
|
{
|
||||||
"id": "184",
|
"id": "184",
|
||||||
"name_ar": "منسقة مراجعي مستشفى قوى الأمن",
|
"name_ar": "منسقة مراجعي مستشفى قوى الأمن",
|
||||||
"name_en": "SFH Patient Coordinator",
|
"name_en": "SFH Patient Staff",
|
||||||
"location_id": "48",
|
"location_id": "48",
|
||||||
"main_section_id": "4",
|
"main_section_id": "4",
|
||||||
},
|
},
|
||||||
@ -1437,8 +1437,8 @@ class Command(BaseCommand):
|
|||||||
"level": 60,
|
"level": 60,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "px_coordinator",
|
"name": "px_staff",
|
||||||
"display_name": "PX Coordinator",
|
"display_name": "PX Staff",
|
||||||
"description": "Can manage PX actions",
|
"description": "Can manage PX actions",
|
||||||
"level": 50,
|
"level": 50,
|
||||||
},
|
},
|
||||||
@ -2131,10 +2131,10 @@ class Command(BaseCommand):
|
|||||||
"hospital_specific": True,
|
"hospital_specific": True,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role_name": "PX Coordinator",
|
"role_name": "PX Staff",
|
||||||
"suffix": "pxcoord",
|
"suffix": "pxcoord",
|
||||||
"first_name": "PX",
|
"first_name": "PX",
|
||||||
"last_name": "Coordinator",
|
"last_name": "Staff",
|
||||||
"hospital_specific": True,
|
"hospital_specific": True,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,22 +1,35 @@
|
|||||||
"""
|
"""
|
||||||
Template filters for hospital-related functionality
|
Template filters for hospital-related functionality
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django import template
|
from django import template
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def key(dictionary, key_name):
|
||||||
|
"""
|
||||||
|
Get a value from a dictionary by key.
|
||||||
|
|
||||||
|
Usage: {{ mydict|key:"mykey" }}
|
||||||
|
"""
|
||||||
|
if isinstance(dictionary, dict):
|
||||||
|
return dictionary.get(key_name)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
def replace(value, arg):
|
def replace(value, arg):
|
||||||
"""
|
"""
|
||||||
Replace occurrences of a substring with another substring.
|
Replace occurrences of a substring with another substring.
|
||||||
|
|
||||||
Usage: {{ value|replace:"old:new" }}
|
Usage: {{ value|replace:"old:new" }}
|
||||||
Example: {{ "hello_world"|replace:"_":" " }} => "hello world"
|
Example: {{ "hello_world"|replace:"_":" " }} => "hello world"
|
||||||
"""
|
"""
|
||||||
if isinstance(value, str) and isinstance(arg, str):
|
if isinstance(value, str) and isinstance(arg, str):
|
||||||
try:
|
try:
|
||||||
old, new = arg.split(':', 1)
|
old, new = arg.split(":", 1)
|
||||||
return value.replace(old, new)
|
return value.replace(old, new)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return value
|
return value
|
||||||
@ -27,7 +40,7 @@ def replace(value, arg):
|
|||||||
def lookup(dictionary, key):
|
def lookup(dictionary, key):
|
||||||
"""
|
"""
|
||||||
Lookup a value from a dictionary by key.
|
Lookup a value from a dictionary by key.
|
||||||
|
|
||||||
Usage: {{ my_dict|lookup:key }}
|
Usage: {{ my_dict|lookup:key }}
|
||||||
Example: {{ row|lookup:"column_name" }}
|
Example: {{ row|lookup:"column_name" }}
|
||||||
"""
|
"""
|
||||||
@ -40,7 +53,7 @@ def lookup(dictionary, key):
|
|||||||
def div(value, arg):
|
def div(value, arg):
|
||||||
"""
|
"""
|
||||||
Divide value by arg.
|
Divide value by arg.
|
||||||
|
|
||||||
Usage: {{ value|div:arg }}
|
Usage: {{ value|div:arg }}
|
||||||
Example: {{ 10|div:2 }} => 5.0
|
Example: {{ 10|div:2 }} => 5.0
|
||||||
"""
|
"""
|
||||||
@ -54,7 +67,7 @@ def div(value, arg):
|
|||||||
def mul(value, arg):
|
def mul(value, arg):
|
||||||
"""
|
"""
|
||||||
Multiply value by arg.
|
Multiply value by arg.
|
||||||
|
|
||||||
Usage: {{ value|mul:arg }}
|
Usage: {{ value|mul:arg }}
|
||||||
Example: {{ 5|mul:3 }} => 15.0
|
Example: {{ 5|mul:3 }} => 15.0
|
||||||
"""
|
"""
|
||||||
@ -74,4 +87,4 @@ def get_all_hospitals():
|
|||||||
"""
|
"""
|
||||||
from apps.organizations.models import Hospital
|
from apps.organizations.models import Hospital
|
||||||
|
|
||||||
return Hospital.objects.all().order_by('name', 'city')
|
return Hospital.objects.all().order_by("name", "city")
|
||||||
|
|||||||
@ -112,6 +112,8 @@ def switch_hospital(request):
|
|||||||
"hospital": {
|
"hospital": {
|
||||||
"id": str(hospital.id),
|
"id": str(hospital.id),
|
||||||
"name": hospital.name,
|
"name": hospital.name,
|
||||||
|
"display_name": hospital.get_display_name(),
|
||||||
|
"display_name_ar": hospital.get_display_name_ar(),
|
||||||
"code": hospital.code,
|
"code": hospital.code,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
0
apps/executive_summary/__init__.py
Normal file
0
apps/executive_summary/__init__.py
Normal file
116
apps/executive_summary/admin.py
Normal file
116
apps/executive_summary/admin.py
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
"""
|
||||||
|
Executive Summary Admin - Django admin interface for executive summary models
|
||||||
|
"""
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.utils.html import format_html
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .models import ExecutiveMetric, ExecutiveReport, PredictiveInsight, AIRecommendation
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ExecutiveMetric)
|
||||||
|
class ExecutiveMetricAdmin(admin.ModelAdmin):
|
||||||
|
list_display = [
|
||||||
|
'metric_type', 'metric_value', 'variance_display',
|
||||||
|
'metric_date', 'hospital', 'created_at'
|
||||||
|
]
|
||||||
|
list_filter = ['metric_type', 'metric_date', 'hospital']
|
||||||
|
date_hierarchy = 'metric_date'
|
||||||
|
search_fields = ['metric_type', 'hospital__name']
|
||||||
|
ordering = ['-metric_date']
|
||||||
|
|
||||||
|
def variance_display(self, obj):
|
||||||
|
if obj.variance is None:
|
||||||
|
return "-"
|
||||||
|
arrow = "↑" if obj.variance_direction == "up" else "↓" if obj.variance_direction == "down" else "→"
|
||||||
|
color = "#10b981" if obj.variance_direction == "up" else "#ef4444" if obj.variance_direction == "down" else "#6b7280"
|
||||||
|
return format_html(
|
||||||
|
'<span style="color: {}; font-weight: bold;">{} {}%</span>',
|
||||||
|
color, arrow, obj.variance
|
||||||
|
)
|
||||||
|
variance_display.short_description = "Variance"
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ExecutiveReport)
|
||||||
|
class ExecutiveReportAdmin(admin.ModelAdmin):
|
||||||
|
list_display = [
|
||||||
|
'report_type', 'status', 'start_date', 'end_date',
|
||||||
|
'generation_time_display', 'created_at', 'pdf_link'
|
||||||
|
]
|
||||||
|
list_filter = ['report_type', 'status', 'created_at']
|
||||||
|
date_hierarchy = 'created_at'
|
||||||
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def generation_time_display(self, obj):
|
||||||
|
if obj.generation_time_ms:
|
||||||
|
return f"{obj.generation_time_ms / 1000:.2f}s"
|
||||||
|
return "-"
|
||||||
|
generation_time_display.short_description = "Gen Time"
|
||||||
|
|
||||||
|
def pdf_link(self, obj):
|
||||||
|
if obj.pdf_file:
|
||||||
|
return format_html('<a href="{}" target="_blank">Download PDF</a>', obj.pdf_file.url)
|
||||||
|
return "-"
|
||||||
|
pdf_link.short_description = "PDF"
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PredictiveInsight)
|
||||||
|
class PredictiveInsightAdmin(admin.ModelAdmin):
|
||||||
|
list_display = [
|
||||||
|
'title_en', 'severity_badge', 'insight_type',
|
||||||
|
'status', 'hospital', 'created_at', 'acknowledged_by_display'
|
||||||
|
]
|
||||||
|
list_filter = ['severity', 'status', 'insight_type', 'hospital', 'created_at']
|
||||||
|
search_fields = ['title_en', 'description_en', 'hospital__name']
|
||||||
|
date_hierarchy = 'created_at'
|
||||||
|
ordering = ['-severity', '-created_at']
|
||||||
|
readonly_fields = ['created_at', 'updated_at', 'acknowledged_at']
|
||||||
|
|
||||||
|
def severity_badge(self, obj):
|
||||||
|
colors = {
|
||||||
|
'low': '#3b82f6',
|
||||||
|
'medium': '#f59e0b',
|
||||||
|
'high': '#f97316',
|
||||||
|
'critical': '#ef4444',
|
||||||
|
}
|
||||||
|
color = colors.get(obj.severity, '#6b7280')
|
||||||
|
return format_html(
|
||||||
|
'<span style="background-color: {}; color: white; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: bold;">{}</span>',
|
||||||
|
color, obj.get_severity_display()
|
||||||
|
)
|
||||||
|
severity_badge.short_description = "Severity"
|
||||||
|
|
||||||
|
def acknowledged_by_display(self, obj):
|
||||||
|
if obj.acknowledged_by:
|
||||||
|
return obj.acknowledged_by.get_full_name() or obj.acknowledged_by.email
|
||||||
|
return "-"
|
||||||
|
acknowledged_by_display.short_description = "Acknowledged By"
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(AIRecommendation)
|
||||||
|
class AIRecommendationAdmin(admin.ModelAdmin):
|
||||||
|
list_display = [
|
||||||
|
'title_en', 'category', 'priority_badge',
|
||||||
|
'status', 'hospital', 'created_at'
|
||||||
|
]
|
||||||
|
list_filter = ['category', 'priority', 'status', 'hospital', 'created_at']
|
||||||
|
search_fields = ['title_en', 'description_en', 'hospital__name']
|
||||||
|
date_hierarchy = 'created_at'
|
||||||
|
ordering = ['-priority', '-created_at']
|
||||||
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
|
||||||
|
def priority_badge(self, obj):
|
||||||
|
colors = {
|
||||||
|
'low': '#3b82f6',
|
||||||
|
'medium': '#f59e0b',
|
||||||
|
'high': '#f97316',
|
||||||
|
'urgent': '#ef4444',
|
||||||
|
}
|
||||||
|
color = colors.get(obj.priority, '#6b7280')
|
||||||
|
return format_html(
|
||||||
|
'<span style="background-color: {}; color: white; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: bold;">{}</span>',
|
||||||
|
color, obj.get_priority_display()
|
||||||
|
)
|
||||||
|
priority_badge.short_description = "Priority"
|
||||||
7
apps/executive_summary/apps.py
Normal file
7
apps/executive_summary/apps.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutiveSummaryConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'apps.executive_summary'
|
||||||
|
verbose_name = 'Executive Summary'
|
||||||
0
apps/executive_summary/migrations/__init__.py
Normal file
0
apps/executive_summary/migrations/__init__.py
Normal file
350
apps/executive_summary/models.py
Normal file
350
apps/executive_summary/models.py
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
"""
|
||||||
|
Executive Summary models - AI-powered executive dashboards and reports
|
||||||
|
|
||||||
|
This module implements:
|
||||||
|
- Executive metrics (KPI snapshots for trending)
|
||||||
|
- Executive reports (AI-generated weekly/monthly summaries)
|
||||||
|
- Predictive insights (AI-detected patterns and risks)
|
||||||
|
- AI recommendations (suggested actions)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from apps.core.models import TimeStampedModel, UUIDModel
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutiveMetric(UUIDModel, TimeStampedModel):
|
||||||
|
"""
|
||||||
|
Executive Metric - Daily KPI snapshots for trending and analysis.
|
||||||
|
|
||||||
|
Stores aggregated metrics across all hospitals for executive overview.
|
||||||
|
Used for trend analysis and dashboard display.
|
||||||
|
"""
|
||||||
|
|
||||||
|
METRIC_TYPES = [
|
||||||
|
("complaints_total", _("Total Complaints")),
|
||||||
|
("complaints_critical", _("Critical Complaints")),
|
||||||
|
("complaints_overdue", _("Overdue Complaints")),
|
||||||
|
("complaints_resolution_time", _("Avg Resolution Time (hours)")),
|
||||||
|
("surveys_total", _("Total Surveys")),
|
||||||
|
("surveys_satisfaction", _("Satisfaction Rate %")),
|
||||||
|
("surveys_nps", _("NPS Score")),
|
||||||
|
("surveys_response_rate", _("Response Rate %")),
|
||||||
|
("actions_total", _("Total Actions")),
|
||||||
|
("actions_open", _("Open Actions")),
|
||||||
|
("actions_overdue", _("Overdue Actions")),
|
||||||
|
("actions_closed", _("Closed Actions")),
|
||||||
|
("observations_total", _("Total Observations")),
|
||||||
|
("observations_critical", _("Critical Observations")),
|
||||||
|
("inquiries_total", _("Total Inquiries")),
|
||||||
|
("inquiries_resolved", _("Resolved Inquiries")),
|
||||||
|
("call_center_total", _("Call Center Interactions")),
|
||||||
|
("call_center_satisfaction", _("Call Center Satisfaction %")),
|
||||||
|
("physician_avg_rating", _("Avg Physician Rating")),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Date for this metric snapshot
|
||||||
|
metric_date = models.DateField(db_index=True, help_text="Date of this metric snapshot")
|
||||||
|
|
||||||
|
# Metric type
|
||||||
|
metric_type = models.CharField(max_length=50, choices=METRIC_TYPES, db_index=True)
|
||||||
|
|
||||||
|
# Metric value
|
||||||
|
metric_value = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
|
||||||
|
# Variance from previous period
|
||||||
|
variance = models.DecimalField(
|
||||||
|
max_digits=10, decimal_places=2, null=True, blank=True, help_text="Variance from previous period"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Variance direction
|
||||||
|
variance_direction = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=[("up", "Up"), ("down", "Down"), ("neutral", "Neutral")],
|
||||||
|
default="neutral",
|
||||||
|
help_text="Direction of variance",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Hospital (null = system-wide aggregate)
|
||||||
|
hospital = models.ForeignKey(
|
||||||
|
"organizations.Hospital", on_delete=models.CASCADE, null=True, blank=True, related_name="executive_metrics"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-metric_date", "metric_type"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["metric_date", "metric_type"]),
|
||||||
|
models.Index(fields=["hospital", "metric_date"]),
|
||||||
|
]
|
||||||
|
verbose_name = _("Executive Metric")
|
||||||
|
verbose_name_plural = _("Executive Metrics")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
hospital_name = f" - {self.hospital.name}" if self.hospital else " (All)"
|
||||||
|
return f"{self.get_metric_type_display()}: {self.metric_value}{hospital_name} ({self.metric_date})"
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutiveReport(UUIDModel, TimeStampedModel):
|
||||||
|
"""
|
||||||
|
Executive Report - AI-generated weekly/monthly narrative summaries.
|
||||||
|
|
||||||
|
Contains natural language summaries generated by AI analyzing
|
||||||
|
performance data across all hospitals.
|
||||||
|
"""
|
||||||
|
|
||||||
|
REPORT_TYPES = [
|
||||||
|
("weekly", _("Weekly Summary")),
|
||||||
|
("monthly", _("Monthly Summary")),
|
||||||
|
("quarterly", _("Quarterly Summary")),
|
||||||
|
("custom", _("Custom Report")),
|
||||||
|
]
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
("pending", _("Pending")),
|
||||||
|
("generating", _("Generating")),
|
||||||
|
("completed", _("Completed")),
|
||||||
|
("failed", _("Failed")),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Report metadata
|
||||||
|
report_type = models.CharField(max_length=20, choices=REPORT_TYPES, db_index=True)
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending", db_index=True)
|
||||||
|
|
||||||
|
# Date range
|
||||||
|
start_date = models.DateField()
|
||||||
|
end_date = models.DateField()
|
||||||
|
|
||||||
|
# AI-generated narratives (bilingual)
|
||||||
|
narrative_en = models.TextField(blank=True, help_text="English narrative summary")
|
||||||
|
narrative_ar = models.TextField(blank=True, help_text="Arabic narrative summary")
|
||||||
|
|
||||||
|
# Key highlights
|
||||||
|
highlights_en = models.JSONField(default=list, blank=True, help_text="Key highlights (English)")
|
||||||
|
highlights_ar = models.JSONField(default=list, blank=True, help_text="Key highlights (Arabic)")
|
||||||
|
|
||||||
|
# Key concerns
|
||||||
|
concerns_en = models.JSONField(default=list, blank=True, help_text="Key concerns (English)")
|
||||||
|
concerns_ar = models.JSONField(default=list, blank=True, help_text="Key concerns (Arabic)")
|
||||||
|
|
||||||
|
# Summary metrics snapshot
|
||||||
|
metrics_snapshot = models.JSONField(default=dict, blank=True, help_text="Key metrics at report time")
|
||||||
|
|
||||||
|
# AI metadata
|
||||||
|
ai_model = models.CharField(max_length=100, blank=True, help_text="AI model used for generation")
|
||||||
|
generation_time_ms = models.IntegerField(null=True, blank=True, help_text="Generation time in milliseconds")
|
||||||
|
|
||||||
|
# PDF report file (if generated)
|
||||||
|
pdf_file = models.FileField(
|
||||||
|
upload_to="executive_reports/pdfs/%Y/%m/", null=True, blank=True, help_text="Generated PDF report"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Error message if failed
|
||||||
|
error_message = models.TextField(blank=True, help_text="Error message if generation failed")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["report_type", "-created_at"]),
|
||||||
|
models.Index(fields=["status", "-created_at"]),
|
||||||
|
]
|
||||||
|
verbose_name = _("Executive Report")
|
||||||
|
verbose_name_plural = _("Executive Reports")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.get_report_type_display()} ({self.start_date} to {self.end_date}) - {self.get_status_display()}"
|
||||||
|
|
||||||
|
|
||||||
|
class PredictiveInsight(UUIDModel, TimeStampedModel):
|
||||||
|
"""
|
||||||
|
Predictive Insight - AI-detected patterns, anomalies, and early warnings.
|
||||||
|
|
||||||
|
Automatically generated insights identifying:
|
||||||
|
- Emerging complaint patterns
|
||||||
|
- Declining satisfaction trends
|
||||||
|
- SLA breach risks
|
||||||
|
- Performance anomalies
|
||||||
|
- Resource bottlenecks
|
||||||
|
"""
|
||||||
|
|
||||||
|
INSIGHT_TYPES = [
|
||||||
|
("trend_change", _("Trend Change")),
|
||||||
|
("anomaly", _("Anomaly Detected")),
|
||||||
|
("risk_warning", _("Risk Warning")),
|
||||||
|
("sla_breach_risk", _("SLA Breach Risk")),
|
||||||
|
("performance_drop", _("Performance Drop")),
|
||||||
|
("volume_spike", _("Volume Spike")),
|
||||||
|
("satisfaction_decline", _("Satisfaction Decline")),
|
||||||
|
("positive_trend", _("Positive Trend")),
|
||||||
|
]
|
||||||
|
|
||||||
|
SEVERITY_LEVELS = [
|
||||||
|
("low", _("Low")),
|
||||||
|
("medium", _("Medium")),
|
||||||
|
("high", _("High")),
|
||||||
|
("critical", _("Critical")),
|
||||||
|
]
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
("new", _("New")),
|
||||||
|
("acknowledged", _("Acknowledged")),
|
||||||
|
("resolved", _("Resolved")),
|
||||||
|
("dismissed", _("Dismissed")),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Insight details
|
||||||
|
insight_type = models.CharField(max_length=30, choices=INSIGHT_TYPES, db_index=True)
|
||||||
|
severity = models.CharField(max_length=10, choices=SEVERITY_LEVELS, default="medium", db_index=True)
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="new", db_index=True)
|
||||||
|
|
||||||
|
# Title and description (bilingual)
|
||||||
|
title_en = models.CharField(max_length=300)
|
||||||
|
title_ar = models.CharField(max_length=300, blank=True)
|
||||||
|
description_en = models.TextField()
|
||||||
|
description_ar = models.TextField(blank=True)
|
||||||
|
|
||||||
|
# AI-generated recommendation
|
||||||
|
recommendation_en = models.TextField(blank=True)
|
||||||
|
recommendation_ar = models.TextField(blank=True)
|
||||||
|
|
||||||
|
# Affected entities
|
||||||
|
hospital = models.ForeignKey(
|
||||||
|
"organizations.Hospital", on_delete=models.SET_NULL, null=True, blank=True, related_name="predictive_insights"
|
||||||
|
)
|
||||||
|
department = models.ForeignKey(
|
||||||
|
"organizations.Department", on_delete=models.SET_NULL, null=True, blank=True, related_name="predictive_insights"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Metric affected
|
||||||
|
metric_type = models.CharField(max_length=50, blank=True, help_text="Related metric type")
|
||||||
|
|
||||||
|
# Related entity (for SLA breach predictions, etc.)
|
||||||
|
entity_type = models.CharField(
|
||||||
|
max_length=50, blank=True, help_text="Type of related entity (e.g., complaint, px_action)"
|
||||||
|
)
|
||||||
|
entity_id = models.CharField(max_length=100, blank=True, help_text="ID of related entity")
|
||||||
|
|
||||||
|
# Predicted values
|
||||||
|
current_value = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
|
||||||
|
predicted_value = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
|
||||||
|
confidence_score = models.DecimalField(
|
||||||
|
max_digits=5, decimal_places=2, null=True, blank=True, help_text="AI confidence score (0-100)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Time-related
|
||||||
|
predicted_date = models.DateField(null=True, blank=True, help_text="When this is expected to occur")
|
||||||
|
|
||||||
|
# Acknowledgement
|
||||||
|
acknowledged_by = models.ForeignKey(
|
||||||
|
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="acknowledged_insights"
|
||||||
|
)
|
||||||
|
acknowledged_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
# AI metadata
|
||||||
|
ai_model = models.CharField(max_length=100, blank=True)
|
||||||
|
detection_metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-severity", "-created_at"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["status", "severity", "-created_at"]),
|
||||||
|
models.Index(fields=["hospital", "status"]),
|
||||||
|
]
|
||||||
|
verbose_name = _("Predictive Insight")
|
||||||
|
verbose_name_plural = _("Predictive Insights")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"[{self.get_severity_display()}] {self.title_en}"
|
||||||
|
|
||||||
|
|
||||||
|
class AIRecommendation(UUIDModel, TimeStampedModel):
|
||||||
|
"""
|
||||||
|
AI Recommendation - Suggested actions based on data analysis.
|
||||||
|
|
||||||
|
AI-generated recommendations for:
|
||||||
|
- Process improvements
|
||||||
|
- Resource allocation
|
||||||
|
- Training needs
|
||||||
|
- Policy changes
|
||||||
|
- Preventive measures
|
||||||
|
"""
|
||||||
|
|
||||||
|
CATEGORY_CHOICES = [
|
||||||
|
("process_improvement", _("Process Improvement")),
|
||||||
|
("resource_allocation", _("Resource Allocation")),
|
||||||
|
("training", _("Training")),
|
||||||
|
("policy_change", _("Policy Change")),
|
||||||
|
("preventive_action", _("Preventive Action")),
|
||||||
|
("performance_optimization", _("Performance Optimization")),
|
||||||
|
("communication", _("Communication")),
|
||||||
|
("quality_assurance", _("Quality Assurance")),
|
||||||
|
]
|
||||||
|
|
||||||
|
PRIORITY_LEVELS = [
|
||||||
|
("low", _("Low")),
|
||||||
|
("medium", _("Medium")),
|
||||||
|
("high", _("High")),
|
||||||
|
("urgent", _("Urgent")),
|
||||||
|
]
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
("new", _("New")),
|
||||||
|
("under_review", _("Under Review")),
|
||||||
|
("approved", _("Approved")),
|
||||||
|
("implemented", _("Implemented")),
|
||||||
|
("rejected", _("Rejected")),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Recommendation details
|
||||||
|
category = models.CharField(max_length=30, choices=CATEGORY_CHOICES, db_index=True)
|
||||||
|
priority = models.CharField(max_length=10, choices=PRIORITY_LEVELS, default="medium", db_index=True)
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="new", db_index=True)
|
||||||
|
|
||||||
|
# Title and description (bilingual)
|
||||||
|
title_en = models.CharField(max_length=300)
|
||||||
|
title_ar = models.CharField(max_length=300, blank=True)
|
||||||
|
description_en = models.TextField()
|
||||||
|
description_ar = models.TextField(blank=True)
|
||||||
|
|
||||||
|
# Expected impact
|
||||||
|
expected_impact_en = models.TextField(blank=True, help_text="Expected impact if implemented (English)")
|
||||||
|
expected_impact_ar = models.TextField(blank=True, help_text="Expected impact if implemented (Arabic)")
|
||||||
|
|
||||||
|
# Related data
|
||||||
|
hospital = models.ForeignKey(
|
||||||
|
"organizations.Hospital", on_delete=models.SET_NULL, null=True, blank=True, related_name="ai_recommend"
|
||||||
|
)
|
||||||
|
department = models.ForeignKey(
|
||||||
|
"organizations.Department", on_delete=models.SET_NULL, null=True, blank=True, related_name="ai_recommend"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Related insight (if generated from one)
|
||||||
|
related_insight = models.ForeignKey(
|
||||||
|
PredictiveInsight, on_delete=models.SET_NULL, null=True, blank=True, related_name="recommendations"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Implementation tracking
|
||||||
|
implemented_by = models.ForeignKey(
|
||||||
|
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="implemented_recommendations"
|
||||||
|
)
|
||||||
|
implemented_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
# AI metadata
|
||||||
|
confidence_score = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
|
||||||
|
ai_model = models.CharField(max_length=100, blank=True)
|
||||||
|
generation_metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-priority", "-created_at"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["status", "priority", "-created_at"]),
|
||||||
|
models.Index(fields=["hospital", "status"]),
|
||||||
|
]
|
||||||
|
verbose_name = _("AI Recommendation")
|
||||||
|
verbose_name_plural = _("AI Recommendations")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"[{self.get_priority_display()}] {self.title_en}"
|
||||||
32
apps/executive_summary/pdf_service.py
Normal file
32
apps/executive_summary/pdf_service.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"""
|
||||||
|
PDF generation service for executive reports.
|
||||||
|
|
||||||
|
Renders the executive PDF template without requiring an HttpRequest.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.utils import timezone
|
||||||
|
from weasyprint import HTML
|
||||||
|
|
||||||
|
|
||||||
|
def generate_executive_pdf(report) -> bytes:
|
||||||
|
"""
|
||||||
|
Generate a PDF byte stream for an ExecutiveReport.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
report: ExecutiveReport instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PDF file contents as bytes
|
||||||
|
"""
|
||||||
|
context = {
|
||||||
|
"report": report,
|
||||||
|
"narrative": report.narrative_en,
|
||||||
|
"highlights": report.highlights_en,
|
||||||
|
"concerns": report.concerns_en,
|
||||||
|
"metrics": report.metrics_snapshot,
|
||||||
|
"generated_at": timezone.now().strftime("%Y-%m-%d %H:%M"),
|
||||||
|
}
|
||||||
|
|
||||||
|
html_string = render_to_string("executive/pdf_report.html", context)
|
||||||
|
return HTML(string=html_string).write_pdf()
|
||||||
2198
apps/executive_summary/services.py
Normal file
2198
apps/executive_summary/services.py
Normal file
File diff suppressed because it is too large
Load Diff
420
apps/executive_summary/tasks.py
Normal file
420
apps/executive_summary/tasks.py
Normal file
@ -0,0 +1,420 @@
|
|||||||
|
"""
|
||||||
|
Executive Summary Celery Tasks
|
||||||
|
|
||||||
|
Scheduled tasks for:
|
||||||
|
- Generating weekly/monthly executive reports
|
||||||
|
- Calculating daily metrics
|
||||||
|
- Generating predictive insights
|
||||||
|
- Creating AI recommendations
|
||||||
|
- Sending PDF reports via email
|
||||||
|
"""
|
||||||
|
|
||||||
|
from celery import shared_task
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
from celery import shared_task
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def generate_weekly_executive_summary():
|
||||||
|
"""
|
||||||
|
Generate weekly executive summary report.
|
||||||
|
|
||||||
|
Runs every Monday at 6 AM.
|
||||||
|
Creates an ExecutiveReport with AI-generated narrative for the past week.
|
||||||
|
"""
|
||||||
|
from .models import ExecutiveReport
|
||||||
|
from .services import AINarrativeService
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("Starting weekly executive summary generation")
|
||||||
|
|
||||||
|
end_date = timezone.now().date()
|
||||||
|
start_date = end_date - timedelta(days=7)
|
||||||
|
|
||||||
|
# Check if report already exists
|
||||||
|
existing = ExecutiveReport.objects.filter(
|
||||||
|
report_type="weekly",
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
logger.info(f"Weekly report already exists for {start_date} to {end_date}")
|
||||||
|
return {"status": "skipped", "report_id": str(existing.id)}
|
||||||
|
|
||||||
|
# Generate narratives using AI
|
||||||
|
narrative_service = AINarrativeService()
|
||||||
|
|
||||||
|
# Generate English narrative
|
||||||
|
logger.info("Generating English narrative...")
|
||||||
|
en_result = narrative_service.generate_weekly_narrative(
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate Arabic narrative
|
||||||
|
logger.info("Generating Arabic narrative...")
|
||||||
|
ar_result = narrative_service.generate_arabic_narrative(
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
report_type="weekly",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create report
|
||||||
|
report = ExecutiveReport.objects.create(
|
||||||
|
report_type="weekly",
|
||||||
|
status=en_result.get("status", "completed"),
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
narrative_en=en_result.get("narrative_en", ""),
|
||||||
|
narrative_ar=ar_result.get("narrative_ar", ""),
|
||||||
|
highlights_en=en_result.get("highlights_en", []),
|
||||||
|
highlights_ar=ar_result.get("highlights_ar", []),
|
||||||
|
concerns_en=en_result.get("concerns_en", []),
|
||||||
|
concerns_ar=ar_result.get("concerns_ar", []),
|
||||||
|
ai_model=en_result.get("ai_model", ""),
|
||||||
|
generation_time_ms=en_result.get("generation_time_ms"),
|
||||||
|
error_message=en_result.get("error", "") or ar_result.get("error", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate and save PDF
|
||||||
|
try:
|
||||||
|
from .pdf_service import generate_executive_pdf
|
||||||
|
|
||||||
|
pdf_bytes = generate_executive_pdf(report)
|
||||||
|
filename = f"executive_report_{report.id}_{report.start_date}.pdf"
|
||||||
|
report.pdf_file.save(filename, ContentFile(pdf_bytes), save=True)
|
||||||
|
logger.info(f"PDF saved for report {report.id}")
|
||||||
|
except Exception as pdf_err:
|
||||||
|
logger.error(f"Failed to generate PDF for report {report.id}: {pdf_err}", exc_info=True)
|
||||||
|
|
||||||
|
logger.info(f"Weekly executive summary generated: {report.id}")
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"report_id": str(report.id),
|
||||||
|
"report_type": "weekly",
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to generate weekly executive summary: {str(e)}", exc_info=True)
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def generate_monthly_executive_summary():
|
||||||
|
"""
|
||||||
|
Generate monthly executive summary report.
|
||||||
|
|
||||||
|
Runs on 1st of each month at 7 AM.
|
||||||
|
Creates an ExecutiveReport with AI-generated narrative for the past month.
|
||||||
|
"""
|
||||||
|
from .models import ExecutiveReport
|
||||||
|
from .services import AINarrativeService
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("Starting monthly executive summary generation")
|
||||||
|
|
||||||
|
end_date = timezone.now().date()
|
||||||
|
start_date = end_date - timedelta(days=30)
|
||||||
|
|
||||||
|
# Check if report already exists
|
||||||
|
existing = ExecutiveReport.objects.filter(
|
||||||
|
report_type="monthly",
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
logger.info(f"Monthly report already exists for {start_date} to {end_date}")
|
||||||
|
return {"status": "skipped", "report_id": str(existing.id)}
|
||||||
|
|
||||||
|
# Generate narratives using AI
|
||||||
|
narrative_service = AINarrativeService()
|
||||||
|
|
||||||
|
# Generate English narrative
|
||||||
|
logger.info("Generating English narrative...")
|
||||||
|
en_result = narrative_service.generate_monthly_narrative(
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate Arabic narrative
|
||||||
|
logger.info("Generating Arabic narrative...")
|
||||||
|
ar_result = narrative_service.generate_arabic_narrative(
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
report_type="monthly",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create report
|
||||||
|
report = ExecutiveReport.objects.create(
|
||||||
|
report_type="monthly",
|
||||||
|
status=en_result.get("status", "completed"),
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
narrative_en=en_result.get("narrative_en", ""),
|
||||||
|
narrative_ar=ar_result.get("narrative_ar", ""),
|
||||||
|
highlights_en=en_result.get("highlights_en", []),
|
||||||
|
highlights_ar=ar_result.get("highlights_ar", []),
|
||||||
|
concerns_en=en_result.get("concerns_en", []),
|
||||||
|
concerns_ar=ar_result.get("concerns_ar", []),
|
||||||
|
ai_model=en_result.get("ai_model", ""),
|
||||||
|
generation_time_ms=en_result.get("generation_time_ms"),
|
||||||
|
error_message=en_result.get("error", "") or ar_result.get("error", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate and save PDF
|
||||||
|
try:
|
||||||
|
from .pdf_service import generate_executive_pdf
|
||||||
|
|
||||||
|
pdf_bytes = generate_executive_pdf(report)
|
||||||
|
filename = f"executive_report_{report.id}_{report.start_date}.pdf"
|
||||||
|
report.pdf_file.save(filename, ContentFile(pdf_bytes), save=True)
|
||||||
|
logger.info(f"PDF saved for report {report.id}")
|
||||||
|
except Exception as pdf_err:
|
||||||
|
logger.error(f"Failed to generate PDF for report {report.id}: {pdf_err}", exc_info=True)
|
||||||
|
|
||||||
|
logger.info(f"Monthly executive summary generated: {report.id}")
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"report_id": str(report.id),
|
||||||
|
"report_type": "monthly",
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to generate monthly executive summary: {str(e)}", exc_info=True)
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def calculate_daily_metrics():
|
||||||
|
"""
|
||||||
|
Calculate and store daily metrics for all hospitals.
|
||||||
|
|
||||||
|
Runs daily at 1 AM.
|
||||||
|
Aggregates metrics from complaints, surveys, actions, observations, etc.
|
||||||
|
"""
|
||||||
|
from decimal import Decimal
|
||||||
|
from .models import ExecutiveMetric
|
||||||
|
from .services import ExecutiveSummaryService
|
||||||
|
from apps.organizations.models import Hospital
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("Starting daily metrics calculation")
|
||||||
|
|
||||||
|
target_date = (timezone.now() - timedelta(days=1)).date()
|
||||||
|
summary_service = ExecutiveSummaryService()
|
||||||
|
|
||||||
|
def _save_metrics(metrics, date, hospital=None):
|
||||||
|
saved = 0
|
||||||
|
for metric_type, metric_value in metrics.items():
|
||||||
|
try:
|
||||||
|
value = Decimal(str(metric_value))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Calculate simple variance vs previous day
|
||||||
|
previous = (
|
||||||
|
ExecutiveMetric.objects.filter(
|
||||||
|
metric_type=metric_type,
|
||||||
|
hospital=hospital,
|
||||||
|
metric_date__lt=date,
|
||||||
|
)
|
||||||
|
.order_by("-metric_date")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
variance = None
|
||||||
|
variance_direction = "neutral"
|
||||||
|
if previous and previous.metric_value is not None:
|
||||||
|
prev_val = float(previous.metric_value)
|
||||||
|
curr_val = float(value)
|
||||||
|
if prev_val != 0:
|
||||||
|
variance = Decimal(str(((curr_val - prev_val) / prev_val) * 100))
|
||||||
|
else:
|
||||||
|
variance = Decimal("0")
|
||||||
|
if variance > 0:
|
||||||
|
variance_direction = "up"
|
||||||
|
elif variance < 0:
|
||||||
|
variance_direction = "down"
|
||||||
|
|
||||||
|
ExecutiveMetric.objects.update_or_create(
|
||||||
|
metric_date=date,
|
||||||
|
metric_type=metric_type,
|
||||||
|
hospital=hospital,
|
||||||
|
defaults={
|
||||||
|
"metric_value": value,
|
||||||
|
"variance": variance,
|
||||||
|
"variance_direction": variance_direction,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
saved += 1
|
||||||
|
return saved
|
||||||
|
|
||||||
|
# Calculate system-wide metrics
|
||||||
|
logger.info(f"Calculating system-wide metrics for {target_date}")
|
||||||
|
metrics = summary_service.aggregate_daily_metrics(target_date, hospital=None)
|
||||||
|
system_saved = _save_metrics(metrics, target_date, hospital=None)
|
||||||
|
|
||||||
|
# Calculate per-hospital metrics
|
||||||
|
hospitals = Hospital.objects.filter(status="active")
|
||||||
|
total_hospital_saved = 0
|
||||||
|
for hospital in hospitals:
|
||||||
|
logger.info(f"Calculating metrics for {hospital.name}")
|
||||||
|
metrics = summary_service.aggregate_daily_metrics(target_date, hospital=hospital)
|
||||||
|
total_hospital_saved += _save_metrics(metrics, target_date, hospital=hospital)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Daily metrics calculated for {target_date}: {system_saved} system-wide, {total_hospital_saved} hospital-level"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"date": target_date.isoformat(),
|
||||||
|
"hospitals_processed": hospitals.count(),
|
||||||
|
"system_metrics_saved": system_saved,
|
||||||
|
"hospital_metrics_saved": total_hospital_saved,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to calculate daily metrics: {str(e)}", exc_info=True)
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def generate_predictive_insights():
|
||||||
|
"""
|
||||||
|
Generate predictive insights from data analysis.
|
||||||
|
|
||||||
|
Runs every 6 hours.
|
||||||
|
Analyzes trends, anomalies, SLA breach risks, and creates PredictiveInsight objects.
|
||||||
|
"""
|
||||||
|
from .services import PredictiveAnalyticsService
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("Starting predictive insights generation")
|
||||||
|
|
||||||
|
predictive_service = PredictiveAnalyticsService()
|
||||||
|
result = predictive_service.generate_predictive_insights()
|
||||||
|
|
||||||
|
logger.info(f"Predictive insights generated: {result.get('insights_created', 0)} insights created")
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"insights_created": result.get("insights_created", 0),
|
||||||
|
"insights_updated": result.get("insights_updated", 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to generate predictive insights: {str(e)}", exc_info=True)
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def generate_ai_recommendations():
|
||||||
|
"""
|
||||||
|
Generate AI recommendations from predictive insights.
|
||||||
|
|
||||||
|
Runs daily at 3 AM.
|
||||||
|
Creates AIRecommendation objects based on unhandled PredictiveInsights.
|
||||||
|
"""
|
||||||
|
from .services import RecommendationService
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("Starting AI recommendations generation")
|
||||||
|
|
||||||
|
recommendation_service = RecommendationService()
|
||||||
|
result = recommendation_service.generate_recommendations_from_insights()
|
||||||
|
|
||||||
|
logger.info(f"AI recommendations generated: {result.get('recommendations_created', 0)}")
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"recommendations_created": result.get("recommendations_created", 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to generate AI recommendations: {str(e)}", exc_info=True)
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def send_executive_pdf_report():
|
||||||
|
"""
|
||||||
|
Send PDF reports to executives via email.
|
||||||
|
|
||||||
|
Runs weekly and monthly after report generation.
|
||||||
|
Emails the latest executive PDF report to all executive users.
|
||||||
|
"""
|
||||||
|
from django.core.mail import EmailMessage
|
||||||
|
from django.conf import settings
|
||||||
|
from .models import ExecutiveReport
|
||||||
|
from apps.accounts.models import User
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("Starting executive PDF report email delivery")
|
||||||
|
|
||||||
|
# Get latest completed report
|
||||||
|
latest_report = (
|
||||||
|
ExecutiveReport.objects.filter(status="completed", pdf_file__isnull=False).order_by("-created_at").first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not latest_report:
|
||||||
|
logger.info("No completed PDF report found to send")
|
||||||
|
return {"status": "skipped", "reason": "no_pdf_report"}
|
||||||
|
|
||||||
|
# Get executive users
|
||||||
|
executives = User.objects.filter(
|
||||||
|
groups__name="Executive",
|
||||||
|
is_active=True,
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
if not executives.exists():
|
||||||
|
logger.info("No executive users found to send report to")
|
||||||
|
return {"status": "skipped", "reason": "no_executives"}
|
||||||
|
|
||||||
|
# Prepare email
|
||||||
|
subject = f"Executive Summary Report - {latest_report.get_report_type_display()}"
|
||||||
|
body = f"""Dear Executive,
|
||||||
|
|
||||||
|
Please find attached the {latest_report.get_report_type_display().lower()} summary report for the period {latest_report.start_date} to {latest_report.end_date}.
|
||||||
|
|
||||||
|
Key Highlights:
|
||||||
|
{chr(10).join(f"• {highlight}" for highlight in latest_report.highlights_en[:5])}
|
||||||
|
|
||||||
|
Key Concerns:
|
||||||
|
{chr(10).join(f"• {concern}" for concern in latest_report.concerns_en[:5])}
|
||||||
|
|
||||||
|
This report was generated automatically by the PX360 AI system.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
PX360 Team
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Send emails
|
||||||
|
emails_sent = 0
|
||||||
|
for executive in executives:
|
||||||
|
if executive.email:
|
||||||
|
try:
|
||||||
|
email = EmailMessage(
|
||||||
|
subject=subject,
|
||||||
|
body=body,
|
||||||
|
to=[executive.email],
|
||||||
|
)
|
||||||
|
email.attach_file(latest_report.pdf_file.path)
|
||||||
|
email.send()
|
||||||
|
emails_sent += 1
|
||||||
|
logger.info(f"Report sent to {executive.email}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send report to {executive.email}: {str(e)}")
|
||||||
|
|
||||||
|
logger.info(f"PDF reports sent to {emails_sent} executives")
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"emails_sent": emails_sent,
|
||||||
|
"report_id": str(latest_report.id),
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send executive PDF reports: {str(e)}", exc_info=True)
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
554
apps/executive_summary/templates/executive/dashboard.html
Normal file
554
apps/executive_summary/templates/executive/dashboard.html
Normal file
@ -0,0 +1,554 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Executive Summary" %} — PX360{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="p-4 lg:p-6 max-w-[1600px] mx-auto">
|
||||||
|
|
||||||
|
{# Hidden fields for HTMX analysis requests #}
|
||||||
|
<input type="hidden" name="date_range" value="{{ date_range }}" id="htmx-date-range">
|
||||||
|
{% if selected_hospital %}<input type="hidden" name="hospital" value="{{ selected_hospital.id }}" id="htmx-hospital">{% endif %}
|
||||||
|
|
||||||
|
{# ── Header & Filters ── #}
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">{% trans "Executive Summary" %}</h1>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">
|
||||||
|
{% if selected_hospital %}{{ selected_hospital.name }}{% else %}{% trans "All Hospitals" %}{% endif %}
|
||||||
|
<span class="text-gray-300 mx-1">·</span>
|
||||||
|
<span class="text-gray-400">{% trans "Updated" %} {{ last_updated }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<form method="get" class="flex items-center gap-2">
|
||||||
|
{% if is_px_admin %}
|
||||||
|
<select name="hospital" onchange="this.form.submit()" class="text-sm border-gray-300 rounded-lg py-2 px-3 focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
|
<option value="">{% trans "All Hospitals" %}</option>
|
||||||
|
{% for h in available_hospitals %}
|
||||||
|
<option value="{{ h.id }}" {% if selected_hospital and selected_hospital.id == h.id %}selected{% endif %}>{{ h.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% endif %}
|
||||||
|
<select name="date_range" onchange="this.form.submit()" class="text-sm border-gray-300 rounded-lg py-2 px-3 focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
|
{% for val, label in date_range_options %}
|
||||||
|
<option value="{{ val }}" {% if date_range == val %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
<a href="{% url 'analytics:dashboard' %}" class="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-indigo-700 bg-indigo-50 rounded-lg hover:bg-indigo-100 transition">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
|
||||||
|
{% trans "Analytics" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Tabs ── #}
|
||||||
|
<div class="border-b border-gray-200 mb-6">
|
||||||
|
<nav class="flex gap-1 -mb-px" id="tabNav">
|
||||||
|
<button data-tab="overview" class="tab-btn active px-4 py-3 text-sm font-semibold border-b-2 border-indigo-600 text-indigo-600">{% trans "Overview" %}</button>
|
||||||
|
<button data-tab="trends" class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 transition">{% trans "Trends" %}</button>
|
||||||
|
<button data-tab="insights" class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 transition">
|
||||||
|
{% trans "Insights" %}
|
||||||
|
{% if insights_critical_count %}
|
||||||
|
<span class="ml-1 px-1.5 py-0.5 text-[10px] font-bold bg-red-100 text-red-700 rounded-full">{{ insights_critical_count }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
<button data-tab="reports" class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 transition">{% trans "Reports" %}</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ═══════════════ OVERVIEW ═══════════════ #}
|
||||||
|
<section id="tab-overview" class="tab-content">
|
||||||
|
|
||||||
|
{# KPI Cards #}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||||
|
{% for key, card in kpi_cards.items %}
|
||||||
|
<div class="bg-white rounded-xl p-5 shadow-sm border border-gray-200 hover:shadow-md transition">
|
||||||
|
<div class="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||||
|
{% if key == "complaints" %}{% trans "Complaints" %}{% elif key == "surveys" %}{% trans "Satisfaction" %}{% elif key == "actions" %}{% trans "PX Actions" %}{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900 mt-0.5">
|
||||||
|
{% if key == "surveys" %}{{ card.satisfaction|floatformat:1 }}<span class="text-base font-normal text-gray-400">/5</span>
|
||||||
|
{% else %}{{ card.total }}{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% if card.variance.percentage %}
|
||||||
|
<span class="inline-flex items-center gap-0.5 px-2 py-0.5 rounded-full text-xs font-semibold
|
||||||
|
{% if card.variance.direction == 'up' %}
|
||||||
|
{% if card.variance.is_positive %}bg-green-100 text-green-800{% else %}bg-red-100 text-red-800{% endif %}
|
||||||
|
{% elif card.variance.direction == 'down' %}
|
||||||
|
{% if card.variance.is_positive %}bg-red-100 text-red-800{% else %}bg-green-100 text-green-800{% endif %}
|
||||||
|
{% else %}bg-gray-100 text-gray-600{% endif %}">
|
||||||
|
{% if card.variance.direction == 'up' %}↑{% elif card.variance.direction == 'down' %}↓{% else %}→{% endif %}
|
||||||
|
{{ card.variance.percentage|floatformat:1 }}%
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 text-xs text-gray-500 mb-3">
|
||||||
|
{% if key == "complaints" %}
|
||||||
|
<span>{% trans "Critical" %}: <b class="text-gray-700">{{ card.critical }}</b></span>
|
||||||
|
<span>{% trans "Overdue" %}: <b class="text-gray-700">{{ card.overdue }}</b></span>
|
||||||
|
<span>{% trans "Avg" %}: <b class="text-gray-700">{{ card.resolution_time|floatformat:0 }}h</b></span>
|
||||||
|
{% elif key == "surveys" %}
|
||||||
|
<span>NPS: <b class="text-gray-700">{{ card.nps|floatformat:0 }}</b></span>
|
||||||
|
<span>{% trans "Total" %}: <b class="text-gray-700">{{ card.total }}</b></span>
|
||||||
|
{% elif key == "actions" %}
|
||||||
|
<span>{% trans "Open" %}: <b class="text-gray-700">{{ card.open }}</b></span>
|
||||||
|
<span>{% trans "Overdue" %}: <b class="text-gray-700">{{ card.overdue }}</b></span>
|
||||||
|
<span>{% trans "Closed" %}: <b class="text-gray-700">{{ card.closed }}</b></span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div id="sparkline-{{ key }}" class="h-8"></div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{# Risk Alert Summary #}
|
||||||
|
<div class="bg-white rounded-xl p-5 shadow-sm border border-gray-200">
|
||||||
|
<h3 class="text-sm font-bold text-gray-900 mb-3 flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
|
||||||
|
{% trans "Risk Alerts" %}
|
||||||
|
{% if risk_alerts_count %}<span class="px-1.5 py-0.5 text-[10px] font-bold bg-red-100 text-red-700 rounded-full">{{ risk_alerts_count }}</span>{% endif %}
|
||||||
|
</h3>
|
||||||
|
{% if has_risk_alerts %}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{% for alert in risk_alerts|slice:":3" %}
|
||||||
|
<div class="flex items-start gap-2 p-2 rounded-lg {% if alert.severity == 'critical' %}bg-red-50{% else %}bg-amber-50{% endif %}">
|
||||||
|
<span class="mt-1 w-2 h-2 rounded-full flex-shrink-0 {% if alert.severity == 'critical' %}bg-red-500{% else %}bg-amber-500{% endif %}"></span>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-xs font-medium text-gray-900 truncate">{{ alert.title_en }}</p>
|
||||||
|
<p class="text-[11px] text-gray-500">{% if alert.hospital %}{{ alert.hospital.name }} · {% endif %}{{ alert.created_at|timesince }} {% trans "ago" %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<button onclick="switchTab('insights')" class="text-xs text-indigo-600 hover:text-indigo-700 font-medium">{% trans "View all insights" %} →</button>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm text-gray-400 py-4 text-center">{% trans "No active risk alerts" %}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Latest AI Report #}
|
||||||
|
<div class="lg:col-span-2 bg-white rounded-xl p-5 shadow-sm border border-gray-200">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h3 class="text-sm font-bold text-gray-900">{% trans "Latest AI Report" %}</h3>
|
||||||
|
{% if report_type %}<span class="text-xs text-gray-400">{{ report_type }} · {{ report_period }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if ai_narrative %}
|
||||||
|
<p class="text-sm text-gray-700 leading-relaxed mb-3">{{ ai_narrative|truncatewords:80 }}</p>
|
||||||
|
<div class="flex gap-6 flex-wrap">
|
||||||
|
{% if ai_highlights %}
|
||||||
|
<div class="flex-1 min-w-[180px]">
|
||||||
|
<p class="text-xs font-semibold text-green-700 mb-1">{% trans "Highlights" %}</p>
|
||||||
|
<ul class="space-y-0.5">
|
||||||
|
{% for h in ai_highlights|slice:":2" %}
|
||||||
|
<li class="text-xs text-gray-600"><span class="text-green-500">✓</span> {{ h|truncatewords:10 }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if ai_concerns %}
|
||||||
|
<div class="flex-1 min-w-[180px]">
|
||||||
|
<p class="text-xs font-semibold text-red-700 mb-1">{% trans "Concerns" %}</p>
|
||||||
|
<ul class="space-y-0.5">
|
||||||
|
{% for c in ai_concerns|slice:":2" %}
|
||||||
|
<li class="text-xs text-gray-600"><span class="text-red-500">!</span> {{ c|truncatewords:10 }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if latest_report and latest_report.pdf_file %}
|
||||||
|
<a href="{% url 'executive:report_pdf' latest_report.id %}" class="inline-flex items-center gap-1 mt-3 text-xs font-medium text-indigo-600 hover:text-indigo-700">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||||
|
{% trans "Download PDF" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-6">
|
||||||
|
<svg class="w-10 h-10 text-gray-300 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||||
|
<p class="text-sm text-gray-400">{% trans "No AI report generated yet" %}</p>
|
||||||
|
<button onclick="switchTab('reports')" class="text-sm text-indigo-600 hover:text-indigo-700 font-medium mt-1">{% trans "Generate your first report" %} →</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# AI Overview Analysis #}
|
||||||
|
<div class="mt-6">
|
||||||
|
<div id="ai-overview-result">
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<button hx-post="{% url 'executive:analyze_overview' %}"
|
||||||
|
hx-include="[name='date_range'],[name='hospital']"
|
||||||
|
hx-target="#ai-overview-result"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
|
||||||
|
{% trans "Analyze Overview with AI" %}
|
||||||
|
</button>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">{% trans "Get AI-powered analysis of your current KPIs" %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section id="tab-trends" class="tab-content hidden">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||||
|
<div class="lg:col-span-2 bg-white rounded-xl p-6 shadow-sm border border-gray-200">
|
||||||
|
<h3 class="text-sm font-bold text-gray-900 mb-4">{% trans "Complaints Trend" %}</h3>
|
||||||
|
<div id="complaintsChart"></div>
|
||||||
|
<div id="complaintsChartEmpty" class="hidden text-center py-10">
|
||||||
|
<p class="text-sm text-gray-400">{% trans "No data for selected period" %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl p-6 shadow-sm border border-gray-200">
|
||||||
|
<h3 class="text-sm font-bold text-gray-900 mb-4 flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-yellow-500" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/></svg>
|
||||||
|
{% trans "Hospital Leaderboard" %}
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{% for h in hospital_leaderboard %}
|
||||||
|
<div class="flex items-center justify-between p-2.5 rounded-lg
|
||||||
|
{% if forloop.counter == 1 %}bg-yellow-50 border border-yellow-200
|
||||||
|
{% elif forloop.counter == 2 %}bg-gray-50 border border-gray-200
|
||||||
|
{% elif forloop.counter == 3 %}bg-orange-50 border border-orange-200
|
||||||
|
{% else %}bg-gray-50{% endif %}">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-6 h-6 flex items-center justify-center rounded-full text-xs font-bold
|
||||||
|
{% if forloop.counter == 1 %}bg-yellow-500 text-white
|
||||||
|
{% elif forloop.counter == 2 %}bg-gray-400 text-white
|
||||||
|
{% elif forloop.counter == 3 %}bg-orange-400 text-white
|
||||||
|
{% else %}bg-gray-200 text-gray-600{% endif %}">{{ forloop.counter }}</span>
|
||||||
|
<span class="text-sm font-medium text-gray-900">{{ h.hospital_name }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-bold
|
||||||
|
{% if h.satisfaction_rate >= 80 %}text-green-600
|
||||||
|
{% elif h.satisfaction_rate >= 60 %}text-amber-600
|
||||||
|
{% else %}text-red-600{% endif %}">
|
||||||
|
{{ h.satisfaction_rate|floatformat:0 }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="text-sm text-gray-400 text-center py-4">{% trans "No hospital data" %}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl p-6 shadow-sm border border-gray-200">
|
||||||
|
<h3 class="text-sm font-bold text-gray-900 mb-4">{% trans "Satisfaction Trend" %}</h3>
|
||||||
|
<div id="satisfactionChart"></div>
|
||||||
|
<div id="satisfactionChartEmpty" class="hidden text-center py-10">
|
||||||
|
<p class="text-sm text-gray-400">{% trans "No data for selected period" %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# AI Trend Analysis #}
|
||||||
|
<div class="mt-6">
|
||||||
|
<div id="ai-trends-result">
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<button hx-post="{% url 'executive:analyze_trends' %}"
|
||||||
|
hx-include="[name='date_range'],[name='hospital']"
|
||||||
|
hx-target="#ai-trends-result"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
|
||||||
|
{% trans "Analyze Trends with AI" %}
|
||||||
|
</button>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">{% trans "Get AI-powered trend analysis and predictions" %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section id="tab-insights" class="tab-content hidden">
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
{% if insights_critical_count %}
|
||||||
|
<span class="px-3 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800">{% trans "Critical" %}: {{ insights_critical_count }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if insights_high_count %}
|
||||||
|
<span class="px-3 py-1 text-xs font-semibold rounded-full bg-amber-100 text-amber-800">{% trans "High" %}: {{ insights_high_count }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{% url 'executive:predictive_insights' %}" class="px-3 py-1 text-xs font-semibold rounded-full bg-gray-100 text-gray-700 hover:bg-gray-200 transition">{% trans "View All Insights" %} →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Risk Alerts #}
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 mb-6">
|
||||||
|
<div class="px-5 py-4 border-b border-gray-100">
|
||||||
|
<h3 class="text-sm font-bold text-gray-900">{% trans "Active Risk Alerts" %}</h3>
|
||||||
|
</div>
|
||||||
|
{% if has_risk_alerts %}
|
||||||
|
<div class="divide-y divide-gray-50">
|
||||||
|
{% for alert in risk_alerts %}
|
||||||
|
<div id="alert-{{ alert.id }}" class="flex items-center justify-between px-5 py-3 hover:bg-gray-50 transition">
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
<span class="w-2.5 h-2.5 rounded-full flex-shrink-0 {% if alert.severity == 'critical' %}bg-red-500{% else %}bg-amber-500{% endif %}"></span>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-medium text-gray-900 truncate">{{ alert.title_en }}</p>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
{{ alert.get_insight_type_display }}
|
||||||
|
{% if alert.hospital %} · {{ alert.hospital.name }}{% endif %}
|
||||||
|
· {{ alert.created_at|timesince }} {% trans "ago" %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 flex-shrink-0 ml-4">
|
||||||
|
{% if alert.status == "new" %}
|
||||||
|
<button hx-post="{% url 'executive:acknowledge_insight' alert.id %}"
|
||||||
|
hx-target="#alert-{{ alert.id }} .ack-area"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="text-xs px-3 py-1.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition font-medium">
|
||||||
|
{% trans "Acknowledge" %}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<span class="ack-area">
|
||||||
|
{% if alert.status == "acknowledged" %}
|
||||||
|
<span class="text-xs text-green-600 font-medium">{% trans "Acknowledged" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="py-8 text-center"><p class="text-sm text-gray-400">{% trans "No active risk alerts" %}</p></div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# AI Recommendations #}
|
||||||
|
{% if ai_recommendations %}
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||||
|
<div class="px-5 py-4 border-b border-gray-100">
|
||||||
|
<h3 class="text-sm font-bold text-gray-900 flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
|
||||||
|
{% trans "AI Recommended Actions" %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-gray-50">
|
||||||
|
{% for rec in ai_recommendations %}
|
||||||
|
<div class="px-5 py-4">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-medium text-gray-900">{{ rec.title_en }}</p>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">{{ rec.description_en|truncatewords:25 }}</p>
|
||||||
|
{% if rec.hospital %}<span class="inline-block mt-1.5 px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">{{ rec.hospital.name }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
<span class="flex-shrink-0 px-2 py-0.5 text-xs font-semibold rounded-full
|
||||||
|
{% if rec.priority == 'urgent' %}bg-red-100 text-red-800
|
||||||
|
{% elif rec.priority == 'high' %}bg-amber-100 text-amber-800
|
||||||
|
{% else %}bg-blue-100 text-blue-800{% endif %}">
|
||||||
|
{{ rec.get_priority_display }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# AI Insights Analysis #}
|
||||||
|
<div class="mt-6">
|
||||||
|
<div id="ai-insights-result">
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<button hx-post="{% url 'executive:analyze_insights' %}"
|
||||||
|
hx-include="[name='hospital']"
|
||||||
|
hx-target="#ai-insights-result"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
|
||||||
|
{% trans "Analyze Risks with AI" %}
|
||||||
|
</button>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">{% trans "Get AI-powered risk assessment and recommendations" %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section id="tab-reports" class="tab-content hidden">
|
||||||
|
<div class="bg-white rounded-xl p-5 shadow-sm border border-gray-200 mb-6">
|
||||||
|
<h3 class="text-sm font-bold text-gray-900 mb-3">{% trans "Generate New Report" %}</h3>
|
||||||
|
<form method="post" action="{% url 'executive:generate_report' %}" class="flex flex-wrap items-end gap-3">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">{% trans "Type" %}</label>
|
||||||
|
<select name="report_type" class="text-sm border-gray-300 rounded-lg py-2 px-3">
|
||||||
|
<option value="weekly">{% trans "Weekly" %}</option>
|
||||||
|
<option value="monthly">{% trans "Monthly" %}</option>
|
||||||
|
<option value="quarterly">{% trans "Quarterly" %}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">{% trans "Start" %}</label>
|
||||||
|
<input type="date" name="start_date" class="text-sm border-gray-300 rounded-lg py-2 px-3">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">{% trans "End" %}</label>
|
||||||
|
<input type="date" name="end_date" class="text-sm border-gray-300 rounded-lg py-2 px-3">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||||
|
{% trans "Generate" %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||||
|
<div class="px-5 py-4 border-b border-gray-100">
|
||||||
|
<h3 class="text-sm font-bold text-gray-900">{% trans "Recent Reports" %}</h3>
|
||||||
|
</div>
|
||||||
|
{% if recent_reports %}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50 text-xs text-gray-500 uppercase">
|
||||||
|
<tr>
|
||||||
|
<th class="px-5 py-3 text-left">{% trans "Type" %}</th>
|
||||||
|
<th class="px-5 py-3 text-left">{% trans "Period" %}</th>
|
||||||
|
<th class="px-5 py-3 text-left">{% trans "Generated" %}</th>
|
||||||
|
<th class="px-5 py-3 text-left">{% trans "Status" %}</th>
|
||||||
|
<th class="px-5 py-3 text-right">{% trans "Actions" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-50">
|
||||||
|
{% for report in recent_reports %}
|
||||||
|
<tr class="hover:bg-gray-50 transition">
|
||||||
|
<td class="px-5 py-3 font-medium text-gray-900">{{ report.get_report_type_display }}</td>
|
||||||
|
<td class="px-5 py-3 text-gray-600">{{ report.start_date }} — {{ report.end_date }}</td>
|
||||||
|
<td class="px-5 py-3 text-gray-500">{{ report.created_at|timesince }} {% trans "ago" %}</td>
|
||||||
|
<td class="px-5 py-3">
|
||||||
|
<span class="px-2 py-0.5 text-xs font-semibold rounded-full
|
||||||
|
{% if report.status == 'completed' %}bg-green-100 text-green-800
|
||||||
|
{% elif report.status == 'generating' %}bg-blue-100 text-blue-800
|
||||||
|
{% elif report.status == 'failed' %}bg-red-100 text-red-800
|
||||||
|
{% else %}bg-gray-100 text-gray-800{% endif %}">
|
||||||
|
{{ report.get_status_display }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-5 py-3 text-right">
|
||||||
|
{% if report.status == 'completed' %}
|
||||||
|
<a href="{% url 'executive:report_pdf' report.id %}" class="inline-flex items-center gap-1 text-indigo-600 hover:text-indigo-700 font-medium">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||||
|
{% trans "PDF" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="py-10 text-center">
|
||||||
|
<svg class="w-12 h-12 text-gray-300 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||||
|
<p class="text-sm text-gray-400">{% trans "No reports yet" %}</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">{% trans "Use the form above to generate your first report" %}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var tabs = document.querySelectorAll('.tab-btn');
|
||||||
|
var sections = document.querySelectorAll('.tab-content');
|
||||||
|
|
||||||
|
function switchTab(id) {
|
||||||
|
tabs.forEach(function(b) {
|
||||||
|
var on = b.dataset.tab === id;
|
||||||
|
b.classList.toggle('border-indigo-600', on);
|
||||||
|
b.classList.toggle('text-indigo-600', on);
|
||||||
|
b.classList.toggle('font-semibold', on);
|
||||||
|
b.classList.toggle('border-transparent', !on);
|
||||||
|
b.classList.toggle('text-gray-500', !on);
|
||||||
|
b.classList.toggle('font-medium', !on);
|
||||||
|
});
|
||||||
|
sections.forEach(function(s) { s.classList.toggle('hidden', s.id !== 'tab-' + id); });
|
||||||
|
history.replaceState(null, '', '#' + id);
|
||||||
|
if (id === 'trends') initCharts();
|
||||||
|
if (id === 'overview') initSparklines();
|
||||||
|
}
|
||||||
|
|
||||||
|
tabs.forEach(function(b) { b.addEventListener('click', function() { switchTab(this.dataset.tab); }); });
|
||||||
|
var h = location.hash.replace('#','');
|
||||||
|
if (h && document.getElementById('tab-' + h)) switchTab(h);
|
||||||
|
window.switchTab = switchTab;
|
||||||
|
|
||||||
|
// ── Sparklines ──
|
||||||
|
var sparksDone = false;
|
||||||
|
function initSparklines() {
|
||||||
|
if (sparksDone || typeof ApexCharts === 'undefined') return;
|
||||||
|
sparksDone = true;
|
||||||
|
var cfg = {
|
||||||
|
complaints: { d: {{ kpi_cards.complaints.sparkline|safe }}, c: '#ef4444' },
|
||||||
|
surveys: { d: {{ kpi_cards.surveys.sparkline|safe }}, c: '#10b981' },
|
||||||
|
actions: { d: {{ kpi_cards.actions.sparkline|safe }}, c: '#3b82f6' },
|
||||||
|
};
|
||||||
|
Object.keys(cfg).forEach(function(k) {
|
||||||
|
var el = document.getElementById('sparkline-' + k);
|
||||||
|
if (!el || !cfg[k].d || !cfg[k].d.length) return;
|
||||||
|
new ApexCharts(el, {
|
||||||
|
series: [{ data: cfg[k].d }],
|
||||||
|
chart: { type: 'area', height: 32, sparkline: { enabled: true }, animations: { enabled: false } },
|
||||||
|
stroke: { curve: 'smooth', width: 1.5 },
|
||||||
|
fill: { type: 'gradient', gradient: { shadeIntensity: 1, opacityFrom: 0.3, opacityTo: 0.05, stops: [0, 100] } },
|
||||||
|
colors: [cfg[k].c],
|
||||||
|
tooltip: { enabled: false },
|
||||||
|
grid: { show: false },
|
||||||
|
xaxis: { labels: { show: false }, axisBorder: { show: false }, axisTicks: { show: false } },
|
||||||
|
yaxis: { show: false },
|
||||||
|
}).render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Charts ──
|
||||||
|
var chartsDone = false;
|
||||||
|
function initCharts() {
|
||||||
|
if (chartsDone || typeof ApexCharts === 'undefined') return;
|
||||||
|
chartsDone = true;
|
||||||
|
|
||||||
|
var cd = {{ chart_data.complaints_trend|safe }};
|
||||||
|
var ce = document.getElementById('complaintsChart');
|
||||||
|
var cempty = document.getElementById('complaintsChartEmpty');
|
||||||
|
if (ce && cd && cd.length > 0) {
|
||||||
|
ce.classList.remove('hidden');
|
||||||
|
if (cempty) cempty.classList.add('hidden');
|
||||||
|
new ApexCharts(ce, {
|
||||||
|
series: [{ name: '{% trans "Complaints" %}', data: cd.map(function(d){return d.value;}) }],
|
||||||
|
chart: { type: 'area', height: 320, toolbar: { show: false }, fontFamily: 'Inter, sans-serif' },
|
||||||
|
colors: ['#ef4444'], stroke: { curve: 'smooth', width: 2 },
|
||||||
|
fill: { type: 'gradient', gradient: { shadeIntensity: 1, opacityFrom: 0.4, opacityTo: 0.1 } },
|
||||||
|
dataLabels: { enabled: false },
|
||||||
|
xaxis: { categories: cd.map(function(d){return d.date;}), labels: { rotate: -45, style: { fontSize: '10px' } } },
|
||||||
|
yaxis: { labels: { formatter: function(v){return Math.round(v);} } },
|
||||||
|
grid: { borderColor: '#f1f5f9', strokeDashArray: 5 },
|
||||||
|
tooltip: { theme: 'light' },
|
||||||
|
}).render();
|
||||||
|
} else if (ce) { ce.classList.add('hidden'); if(cempty) cempty.classList.remove('hidden'); }
|
||||||
|
|
||||||
|
var sd = {{ chart_data.surveys_satisfaction_trend|safe }};
|
||||||
|
var se = document.getElementById('satisfactionChart');
|
||||||
|
var sempty = document.getElementById('satisfactionChartEmpty');
|
||||||
|
if (se && sd && sd.length > 0) {
|
||||||
|
se.classList.remove('hidden');
|
||||||
|
if (sempty) sempty.classList.add('hidden');
|
||||||
|
new ApexCharts(se, {
|
||||||
|
series: [{ name: '{% trans "Satisfaction" %}', data: sd.map(function(d){return d.value;}) }],
|
||||||
|
chart: { type: 'line', height: 300, toolbar: { show: false }, fontFamily: 'Inter, sans-serif' },
|
||||||
|
colors: ['#10b981'], stroke: { curve: 'smooth', width: 2 },
|
||||||
|
dataLabels: { enabled: false },
|
||||||
|
xaxis: { categories: sd.map(function(d){return d.date;}), labels: { rotate: -45, style: { fontSize: '10px' } } },
|
||||||
|
yaxis: { min: 0, max: 5, labels: { formatter: function(v){return v.toFixed(1);} } },
|
||||||
|
grid: { borderColor: '#f1f5f9', strokeDashArray: 5 },
|
||||||
|
tooltip: { theme: 'light' },
|
||||||
|
}).render();
|
||||||
|
} else if (se) { se.classList.add('hidden'); if(sempty) sempty.classList.remove('hidden'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
initSparklines();
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
192
apps/executive_summary/templates/executive/insights.html
Normal file
192
apps/executive_summary/templates/executive/insights.html
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Predictive Insights" %} — PX360{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="p-4 lg:p-6 max-w-[1600px] mx-auto">
|
||||||
|
|
||||||
|
{# ── Header ── #}
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<a href="{% url 'executive:executive_dashboard' %}#insights" class="text-gray-400 hover:text-gray-600 transition">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
|
||||||
|
</a>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">{% trans "Predictive Insights" %}</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500">{% trans "AI-detected patterns, anomalies, and early warnings" %}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="px-3 py-1.5 text-xs font-semibold rounded-lg bg-gray-100 text-gray-700">{% trans "Total" %}: {{ total_insights }}</span>
|
||||||
|
{% if critical_insights %}
|
||||||
|
<span class="px-3 py-1.5 text-xs font-semibold rounded-lg bg-red-100 text-red-800">{% trans "Critical" %}: {{ critical_insights }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if high_insights %}
|
||||||
|
<span class="px-3 py-1.5 text-xs font-semibold rounded-lg bg-amber-100 text-amber-800">{% trans "High" %}: {{ high_insights }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="px-3 py-1.5 text-xs font-semibold rounded-lg bg-blue-100 text-blue-800">{% trans "New" %}: {{ new_insights }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Collapsible Filters ── #}
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 mb-6">
|
||||||
|
<button id="filterToggle" onclick="document.getElementById('filterBody').classList.toggle('hidden'); this.querySelector('.chevron').classList.toggle('rotate-180');"
|
||||||
|
class="w-full flex items-center justify-between px-5 py-3 text-sm font-medium text-gray-700 hover:bg-gray-50 rounded-xl transition">
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/></svg>
|
||||||
|
{% trans "Filters" %}
|
||||||
|
{% if current_filters.severity or current_filters.status or current_filters.insight_type or current_filters.hospital %}
|
||||||
|
<span class="px-1.5 py-0.5 text-[10px] font-bold bg-indigo-100 text-indigo-700 rounded-full">
|
||||||
|
{% widthratio 1 1 0 %}
|
||||||
|
{% if current_filters.severity %}1{% endif %}
|
||||||
|
{% if current_filters.status %}1{% endif %}
|
||||||
|
{% if current_filters.insight_type %}1{% endif %}
|
||||||
|
{% if current_filters.hospital %}1{% endif %}
|
||||||
|
active
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<svg class="w-4 h-4 chevron transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
||||||
|
</button>
|
||||||
|
<div id="filterBody" class="{% if not current_filters.severity and not current_filters.status and not current_filters.insight_type and not current_filters.hospital %}hidden{% endif %} border-t border-gray-100 px-5 py-4">
|
||||||
|
<form method="get" id="filterForm" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">{% trans "Severity" %}</label>
|
||||||
|
<select name="severity" onchange="this.form.submit()" class="w-full text-sm border-gray-300 rounded-lg py-2 px-3">
|
||||||
|
<option value="">{% trans "All Severities" %}</option>
|
||||||
|
{% for value, label in severity_choices %}
|
||||||
|
<option value="{{ value }}" {% if current_filters.severity == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">{% trans "Status" %}</label>
|
||||||
|
<select name="status" onchange="this.form.submit()" class="w-full text-sm border-gray-300 rounded-lg py-2 px-3">
|
||||||
|
<option value="">{% trans "All Statuses" %}</option>
|
||||||
|
{% for value, label in status_choices %}
|
||||||
|
<option value="{{ value }}" {% if current_filters.status == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">{% trans "Type" %}</label>
|
||||||
|
<select name="insight_type" onchange="this.form.submit()" class="w-full text-sm border-gray-300 rounded-lg py-2 px-3">
|
||||||
|
<option value="">{% trans "All Types" %}</option>
|
||||||
|
{% for value, label in insight_type_choices %}
|
||||||
|
<option value="{{ value }}" {% if current_filters.insight_type == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{% if available_hospitals %}
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">{% trans "Hospital" %}</label>
|
||||||
|
<select name="hospital" onchange="this.form.submit()" class="w-full text-sm border-gray-300 rounded-lg py-2 px-3">
|
||||||
|
<option value="">{% trans "All Hospitals" %}</option>
|
||||||
|
{% for hospital in available_hospitals %}
|
||||||
|
<option value="{{ hospital.id }}" {% if current_filters.hospital == hospital.id|stringformat:"s" %}selected{% endif %}>{{ hospital.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
{% if current_filters.severity or current_filters.status or current_filters.insight_type or current_filters.hospital %}
|
||||||
|
<div class="mt-3 pt-3 border-t border-gray-100">
|
||||||
|
<a href="{% url 'executive:predictive_insights' %}" class="text-xs text-indigo-600 hover:text-indigo-700 font-medium">{% trans "Clear all filters" %}</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Insights List ── #}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% for insight in insights %}
|
||||||
|
<div id="insight-{{ insight.id }}" class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden border-l-4
|
||||||
|
{% if insight.severity == 'critical' %}border-l-red-500
|
||||||
|
{% elif insight.severity == 'high' %}border-l-amber-500
|
||||||
|
{% elif insight.severity == 'medium' %}border-l-yellow-500
|
||||||
|
{% else %}border-l-blue-500{% endif %}">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
|
<span class="px-2 py-0.5 text-xs font-semibold rounded-full
|
||||||
|
{% if insight.severity == 'critical' %}bg-red-100 text-red-800
|
||||||
|
{% elif insight.severity == 'high' %}bg-amber-100 text-amber-800
|
||||||
|
{% elif insight.severity == 'medium' %}bg-yellow-100 text-yellow-800
|
||||||
|
{% else %}bg-blue-100 text-blue-800{% endif %}">
|
||||||
|
{{ insight.get_severity_display }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-400">{{ insight.get_insight_type_display }}</span>
|
||||||
|
<span class="text-xs text-gray-400">·</span>
|
||||||
|
<span class="text-xs text-gray-400">{{ insight.created_at|timesince }} {% trans "ago" %}</span>
|
||||||
|
{% if insight.status == 'acknowledged' %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 rounded-full">{% trans "Acknowledged" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 mb-1">{{ insight.title_en }}</h3>
|
||||||
|
<p class="text-sm text-gray-600 mb-2">{{ insight.description_en|truncatewords:40 }}</p>
|
||||||
|
|
||||||
|
{% if insight.recommendation_en %}
|
||||||
|
<div class="bg-indigo-50 rounded-lg p-3 mb-2">
|
||||||
|
<p class="text-xs font-semibold text-indigo-900 mb-0.5">{% trans "AI Recommendation" %}</p>
|
||||||
|
<p class="text-xs text-indigo-800">{{ insight.recommendation_en }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4 text-xs text-gray-500">
|
||||||
|
{% if insight.hospital %}<span>{{ insight.hospital.name }}</span>{% endif %}
|
||||||
|
{% if insight.department %}<span>{{ insight.department.name }}</span>{% endif %}
|
||||||
|
{% if insight.confidence_score %}<span>{% trans "Confidence" %}: {{ insight.confidence_score|floatformat:0 }}%</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if insight.status == 'new' %}
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<button hx-post="{% url 'executive:acknowledge_insight' insight.id %}"
|
||||||
|
hx-target="#insight-{{ insight.id }} .ack-btn-area"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="px-3 py-1.5 bg-indigo-600 text-white text-xs font-medium rounded-lg hover:bg-indigo-700 transition">
|
||||||
|
{% trans "Acknowledge" %}
|
||||||
|
</button>
|
||||||
|
<span class="ack-btn-area"></span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="bg-white rounded-xl p-12 text-center shadow-sm border border-gray-200">
|
||||||
|
<svg class="w-12 h-12 mx-auto text-gray-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-1">{% trans "No Insights Match" %}</h3>
|
||||||
|
<p class="text-sm text-gray-500">{% trans "Try adjusting your filters" %}</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Pagination ── #}
|
||||||
|
{% if page_obj.has_other_pages %}
|
||||||
|
<div class="mt-6 flex items-center justify-center gap-2">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<a href="?page=1{% if current_filters.severity %}&severity={{ current_filters.severity }}{% endif %}{% if current_filters.status %}&status={{ current_filters.status }}{% endif %}{% if current_filters.insight_type %}&insight_type={{ current_filters.insight_type }}{% endif %}{% if current_filters.hospital %}&hospital={{ current_filters.hospital }}{% endif %}"
|
||||||
|
class="px-3 py-2 text-sm bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition">{% trans "First" %}</a>
|
||||||
|
<a href="?page={{ page_obj.previous_page_number }}{% if current_filters.severity %}&severity={{ current_filters.severity }}{% endif %}{% if current_filters.status %}&status={{ current_filters.status }}{% endif %}{% if current_filters.insight_type %}&insight_type={{ current_filters.insight_type }}{% endif %}{% if current_filters.hospital %}&hospital={{ current_filters.hospital }}{% endif %}"
|
||||||
|
class="px-3 py-2 text-sm bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition">{% trans "Previous" %}</a>
|
||||||
|
{% endif %}
|
||||||
|
<span class="px-4 py-2 text-sm text-gray-600">{% trans "Page" %} {{ page_obj.number }} / {{ page_obj.paginator.num_pages }}</span>
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<a href="?page={{ page_obj.next_page_number }}{% if current_filters.severity %}&severity={{ current_filters.severity }}{% endif %}{% if current_filters.status %}&status={{ current_filters.status }}{% endif %}{% if current_filters.insight_type %}&insight_type={{ current_filters.insight_type }}{% endif %}{% if current_filters.hospital %}&hospital={{ current_filters.hospital }}{% endif %}"
|
||||||
|
class="px-3 py-2 text-sm bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition">{% trans "Next" %}</a>
|
||||||
|
<a href="?page={{ page_obj.paginator.num_pages }}{% if current_filters.severity %}&severity={{ current_filters.severity }}{% endif %}{% if current_filters.status %}&status={{ current_filters.status }}{% endif %}{% if current_filters.insight_type %}&insight_type={{ current_filters.insight_type }}{% endif %}{% if current_filters.hospital %}&hospital={{ current_filters.hospital }}{% endif %}"
|
||||||
|
class="px-3 py-2 text-sm bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition">{% trans "Last" %}</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<div class="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-bold text-gray-900 flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
|
||||||
|
{% trans "AI Risk Assessment" %}
|
||||||
|
</h3>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if analysis.status == "completed" %}
|
||||||
|
<span class="text-[11px] text-gray-400">{{ analysis.generation_time_ms|default:"" }}ms</span>
|
||||||
|
{% endif %}
|
||||||
|
<button hx-post="{% url 'executive:analyze_insights' %}"
|
||||||
|
hx-include="[name='hospital']"
|
||||||
|
hx-target="#ai-insights-result"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-indicator="#ai-insights-spinner"
|
||||||
|
class="text-xs px-2.5 py-1 bg-indigo-50 text-indigo-700 rounded-lg hover:bg-indigo-100 transition font-medium">
|
||||||
|
{% trans "Regenerate" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-5">
|
||||||
|
{% if analysis.status == "failed" %}
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<svg class="w-8 h-8 text-red-400 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
<p class="text-sm text-red-600">{% trans "Analysis failed. Try regenerating." %}</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm text-gray-700 leading-relaxed mb-4">{{ analysis.narrative }}</p>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{% if analysis.highlights %}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-green-700 mb-1.5">{% trans "Highlights" %}</p>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
{% for h in analysis.highlights %}
|
||||||
|
<li class="text-xs text-gray-600 flex gap-1"><span class="text-green-500 flex-shrink-0">+</span> {{ h|truncatewords:12 }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if analysis.concerns %}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-red-700 mb-1.5">{% trans "Concerns" %}</p>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
{% for c in analysis.concerns %}
|
||||||
|
<li class="text-xs text-gray-600 flex gap-1"><span class="text-red-500 flex-shrink-0">!</span> {{ c|truncatewords:12 }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if analysis.recommendations %}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-indigo-700 mb-1.5">{% trans "Recommendations" %}</p>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
{% for r in analysis.recommendations %}
|
||||||
|
<li class="text-xs text-gray-600 flex gap-1"><span class="text-indigo-500 flex-shrink-0">→</span> {{ r|truncatewords:12 }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<p class="text-[11px] text-gray-400 mt-3">{% trans "Generated just now" %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<div class="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-bold text-gray-900 flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
|
||||||
|
{% trans "AI Overview Analysis" %}
|
||||||
|
</h3>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if analysis.status == "completed" %}
|
||||||
|
<span class="text-[11px] text-gray-400">{{ analysis.generation_time_ms|default:"" }}ms</span>
|
||||||
|
{% endif %}
|
||||||
|
<button hx-post="{% url 'executive:analyze_overview' %}"
|
||||||
|
hx-include="[name='date_range'],[name='hospital']"
|
||||||
|
hx-target="#ai-overview-result"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-indicator="#ai-overview-spinner"
|
||||||
|
class="text-xs px-2.5 py-1 bg-indigo-50 text-indigo-700 rounded-lg hover:bg-indigo-100 transition font-medium">
|
||||||
|
{% trans "Regenerate" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-5">
|
||||||
|
{% if analysis.status == "failed" %}
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<svg class="w-8 h-8 text-red-400 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
<p class="text-sm text-red-600">{% trans "Analysis failed. Try regenerating." %}</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm text-gray-700 leading-relaxed mb-4">{{ analysis.narrative }}</p>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{% if analysis.highlights %}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-green-700 mb-1.5">{% trans "Highlights" %}</p>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
{% for h in analysis.highlights %}
|
||||||
|
<li class="text-xs text-gray-600 flex gap-1"><span class="text-green-500 flex-shrink-0">+</span> {{ h|truncatewords:12 }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if analysis.concerns %}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-red-700 mb-1.5">{% trans "Concerns" %}</p>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
{% for c in analysis.concerns %}
|
||||||
|
<li class="text-xs text-gray-600 flex gap-1"><span class="text-red-500 flex-shrink-0">!</span> {{ c|truncatewords:12 }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if analysis.recommendations %}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-indigo-700 mb-1.5">{% trans "Recommendations" %}</p>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
{% for r in analysis.recommendations %}
|
||||||
|
<li class="text-xs text-gray-600 flex gap-1"><span class="text-indigo-500 flex-shrink-0">→</span> {{ r|truncatewords:12 }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<p class="text-[11px] text-gray-400 mt-3">{% trans "Generated just now" %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<div class="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-bold text-gray-900 flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
|
||||||
|
{% trans "AI Trend Analysis" %}
|
||||||
|
</h3>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if analysis.status == "completed" %}
|
||||||
|
<span class="text-[11px] text-gray-400">{{ analysis.generation_time_ms|default:"" }}ms</span>
|
||||||
|
{% endif %}
|
||||||
|
<button hx-post="{% url 'executive:analyze_trends' %}"
|
||||||
|
hx-include="[name='date_range'],[name='hospital']"
|
||||||
|
hx-target="#ai-trends-result"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-indicator="#ai-trends-spinner"
|
||||||
|
class="text-xs px-2.5 py-1 bg-indigo-50 text-indigo-700 rounded-lg hover:bg-indigo-100 transition font-medium">
|
||||||
|
{% trans "Regenerate" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-5">
|
||||||
|
{% if analysis.status == "failed" %}
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<svg class="w-8 h-8 text-red-400 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
<p class="text-sm text-red-600">{% trans "Analysis failed. Try regenerating." %}</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm text-gray-700 leading-relaxed mb-4">{{ analysis.narrative }}</p>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{% if analysis.highlights %}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-green-700 mb-1.5">{% trans "Highlights" %}</p>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
{% for h in analysis.highlights %}
|
||||||
|
<li class="text-xs text-gray-600 flex gap-1"><span class="text-green-500 flex-shrink-0">+</span> {{ h|truncatewords:12 }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if analysis.concerns %}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-red-700 mb-1.5">{% trans "Concerns" %}</p>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
{% for c in analysis.concerns %}
|
||||||
|
<li class="text-xs text-gray-600 flex gap-1"><span class="text-red-500 flex-shrink-0">!</span> {{ c|truncatewords:12 }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if analysis.recommendations %}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-indigo-700 mb-1.5">{% trans "Recommendations" %}</p>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
{% for r in analysis.recommendations %}
|
||||||
|
<li class="text-xs text-gray-600 flex gap-1"><span class="text-indigo-500 flex-shrink-0">→</span> {{ r|truncatewords:12 }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<p class="text-[11px] text-gray-400 mt-3">{% trans "Generated just now" %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
{% if variance %}
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold
|
||||||
|
{% if variance.direction == 'up' %}
|
||||||
|
{% if variance.is_positive %}bg-green-100 text-green-800{% else %}bg-red-100 text-red-800{% endif %}
|
||||||
|
{% elif variance.direction == 'down' %}
|
||||||
|
{% if variance.is_positive %}bg-red-100 text-red-800{% else %}bg-green-100 text-green-800{% endif %}
|
||||||
|
{% else %}
|
||||||
|
bg-gray-100 text-gray-800
|
||||||
|
{% endif %}">
|
||||||
|
{% if variance.direction == 'up' %}
|
||||||
|
↑
|
||||||
|
{% elif variance.direction == 'down' %}
|
||||||
|
↓
|
||||||
|
{% else %}
|
||||||
|
→
|
||||||
|
{% endif %}
|
||||||
|
{{ variance.percentage|floatformat:1 }}%
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
174
apps/executive_summary/templates/executive/pdf_report.html
Normal file
174
apps/executive_summary/templates/executive/pdf_report.html
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{% trans "Executive Report" %} - PX360</title>
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 2cm;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 11pt;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
border-bottom: 3px solid #4f46e5;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.header h1 {
|
||||||
|
font-size: 24pt;
|
||||||
|
color: #4f46e5;
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
font-size: 10pt;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.section h2 {
|
||||||
|
font-size: 14pt;
|
||||||
|
color: #111827;
|
||||||
|
border-left: 4px solid #4f46e5;
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.narrative {
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
.item-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.item-list li {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.item-list li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 9pt;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.badge-green { background: #10b981; }
|
||||||
|
.badge-red { background: #ef4444; }
|
||||||
|
.badge-amber { background: #f59e0b; }
|
||||||
|
.metrics-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.metric-box {
|
||||||
|
flex: 1 1 30%;
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.metric-value {
|
||||||
|
font-size: 16pt;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
.metric-label {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #9ca3af;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>{% trans "Executive Summary Report" %}</h1>
|
||||||
|
<div class="meta">
|
||||||
|
<p><strong>{% trans "Report Type" %}:</strong> {{ report.get_report_type_display }}</p>
|
||||||
|
<p><strong>{% trans "Period" %}:</strong> {{ report.start_date }} – {{ report.end_date }}</p>
|
||||||
|
<p><strong>{% trans "Generated" %}:</strong> {{ generated_at }} | <strong>{% trans "By" %}:</strong> {{ user.get_full_name|default:user.email }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if narrative %}
|
||||||
|
<div class="section">
|
||||||
|
<h2>{% trans "AI Executive Narrative" %}</h2>
|
||||||
|
<div class="narrative">{{ narrative }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if highlights %}
|
||||||
|
<div class="section">
|
||||||
|
<h2>{% trans "Key Highlights" %}</h2>
|
||||||
|
<ul class="item-list">
|
||||||
|
{% for item in highlights %}
|
||||||
|
<li>
|
||||||
|
<span class="badge badge-green">✓</span>
|
||||||
|
<span>{{ item }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if concerns %}
|
||||||
|
<div class="section">
|
||||||
|
<h2>{% trans "Key Concerns" %}</h2>
|
||||||
|
<ul class="item-list">
|
||||||
|
{% for item in concerns %}
|
||||||
|
<li>
|
||||||
|
<span class="badge badge-red">!</span>
|
||||||
|
<span>{{ item }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if metrics %}
|
||||||
|
<div class="section">
|
||||||
|
<h2>{% trans "Snapshot Metrics" %}</h2>
|
||||||
|
<div class="metrics-grid">
|
||||||
|
{% for key, value in metrics.items %}
|
||||||
|
<div class="metric-box">
|
||||||
|
<div class="metric-value">{{ value|floatformat:1 }}</div>
|
||||||
|
<div class="metric-label">{{ key|title }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>{% trans "This report was automatically generated by the PX360 AI system." %}</p>
|
||||||
|
<p>© {% now "Y" %} PX360 - Al Hammadi Group</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3
apps/executive_summary/tests.py
Normal file
3
apps/executive_summary/tests.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
30
apps/executive_summary/urls.py
Normal file
30
apps/executive_summary/urls.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from django.views.generic import RedirectView
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = "executive"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", views.ExecutiveDashboardView.as_view(), name="executive_dashboard"),
|
||||||
|
path("api/", views.executive_dashboard_api, name="executive_api"),
|
||||||
|
path("api/analyze/overview/", views.analyze_overview, name="analyze_overview"),
|
||||||
|
path("api/analyze/trends/", views.analyze_trends, name="analyze_trends"),
|
||||||
|
path("api/analyze/insights/", views.analyze_insights, name="analyze_insights"),
|
||||||
|
path("insights/", views.PredictiveInsightsView.as_view(), name="predictive_insights"),
|
||||||
|
path(
|
||||||
|
"insights/<uuid:insight_id>/acknowledge/",
|
||||||
|
views.acknowledge_insight,
|
||||||
|
name="acknowledge_insight",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"qa/",
|
||||||
|
RedirectView.as_view(url="/executive/", permanent=False),
|
||||||
|
name="executive_qa",
|
||||||
|
),
|
||||||
|
path("reports/generate/", views.generate_executive_report, name="generate_report"),
|
||||||
|
path(
|
||||||
|
"reports/<uuid:report_id>/pdf/",
|
||||||
|
views.ExecutivePDFReportView.as_view(),
|
||||||
|
name="report_pdf",
|
||||||
|
),
|
||||||
|
]
|
||||||
677
apps/executive_summary/views.py
Normal file
677
apps/executive_summary/views.py
Normal file
@ -0,0 +1,677 @@
|
|||||||
|
"""
|
||||||
|
Executive Summary Views - AI-powered executive dashboard and analytics
|
||||||
|
|
||||||
|
This module provides views for:
|
||||||
|
- Executive dashboard (tabbed: Overview, Trends, Insights, Reports)
|
||||||
|
- Predictive insights management with HTMX acknowledge
|
||||||
|
- Executive report generation and PDF export
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta, datetime
|
||||||
|
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.db.models import Avg, Count, Q
|
||||||
|
from django.http import JsonResponse, HttpResponse
|
||||||
|
from django.shortcuts import redirect, get_object_or_404
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.views import View
|
||||||
|
from django.views.generic import TemplateView
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.contrib import messages
|
||||||
|
|
||||||
|
from .models import ExecutiveReport, PredictiveInsight, AIRecommendation, ExecutiveMetric
|
||||||
|
from .services import (
|
||||||
|
ExecutiveSummaryService,
|
||||||
|
AINarrativeService,
|
||||||
|
PredictiveAnalyticsService,
|
||||||
|
RecommendationService,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutiveAccessMixin:
|
||||||
|
"""Mixin to restrict access to executive and px_admin users only."""
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return redirect("accounts:login")
|
||||||
|
if not (request.user.is_executive() or request.user.is_px_admin()):
|
||||||
|
messages.error(request, _("You do not have permission to access the executive dashboard."))
|
||||||
|
return redirect("core:home")
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Executive Dashboard
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutiveDashboardView(ExecutiveAccessMixin, LoginRequiredMixin, TemplateView):
|
||||||
|
"""
|
||||||
|
Executive Dashboard - Tabbed AI-powered overview for top management.
|
||||||
|
|
||||||
|
Tabs: Overview | Trends | Insights | Reports
|
||||||
|
"""
|
||||||
|
|
||||||
|
template_name = "executive/dashboard.html"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
user = self.request.user
|
||||||
|
|
||||||
|
date_range, hospital = self._parse_filters(user)
|
||||||
|
summary_service = ExecutiveSummaryService()
|
||||||
|
dashboard_data = summary_service.get_dashboard_data(
|
||||||
|
hospital=hospital,
|
||||||
|
date_range=date_range,
|
||||||
|
)
|
||||||
|
kpis = dashboard_data.get("kpis", {})
|
||||||
|
variances = dashboard_data.get("variances", {})
|
||||||
|
trends = dashboard_data.get("trends", {})
|
||||||
|
|
||||||
|
context.update(self._get_kpi_cards(kpis, variances, hospital))
|
||||||
|
context.update(self._get_chart_data(trends))
|
||||||
|
context.update(self._get_risk_alerts())
|
||||||
|
context.update(self._get_insights_and_recommendations())
|
||||||
|
context.update(self._get_reports_context())
|
||||||
|
context.update(self._get_filters(user, hospital, date_range))
|
||||||
|
context.update(self._get_latest_report())
|
||||||
|
|
||||||
|
context["last_updated"] = timezone.now().strftime("%Y-%m-%d %H:%M")
|
||||||
|
context["is_px_admin"] = user.is_px_admin()
|
||||||
|
context["hospital_leaderboard"] = dashboard_data.get("hospital_leaderboard", [])[:7]
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _parse_filters(self, user):
|
||||||
|
date_range = int(self.request.GET.get("date_range", 30))
|
||||||
|
hospital_id = self.request.GET.get("hospital")
|
||||||
|
hospital = None
|
||||||
|
if hospital_id:
|
||||||
|
from apps.organizations.models import Hospital
|
||||||
|
|
||||||
|
hospital = get_object_or_404(Hospital, id=hospital_id)
|
||||||
|
elif user.is_hospital_admin() and user.hospital:
|
||||||
|
hospital = user.hospital
|
||||||
|
return date_range, hospital
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _get_kpi_cards(self, kpis, variances, hospital):
|
||||||
|
c_var = variances.get("complaints_total", {})
|
||||||
|
c_var["is_positive"] = False
|
||||||
|
s_var = variances.get("surveys_satisfaction", {})
|
||||||
|
s_var["is_positive"] = True
|
||||||
|
a_var = variances.get("actions_total", {})
|
||||||
|
a_var["is_positive"] = False
|
||||||
|
|
||||||
|
sparklines = self._get_sparkline_data(hospital, days=7)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"kpi_cards": {
|
||||||
|
"complaints": {
|
||||||
|
"total": int(kpis.get("complaints_total", 0)),
|
||||||
|
"critical": int(kpis.get("complaints_critical", 0)),
|
||||||
|
"overdue": int(kpis.get("complaints_overdue", 0)),
|
||||||
|
"variance": c_var,
|
||||||
|
"resolution_time": kpis.get("complaints_resolution_time", 0),
|
||||||
|
"sparkline": sparklines.get("complaints_total", []),
|
||||||
|
},
|
||||||
|
"surveys": {
|
||||||
|
"total": int(kpis.get("surveys_total", 0)),
|
||||||
|
"satisfaction": float(kpis.get("surveys_satisfaction", 0)),
|
||||||
|
"nps": float(kpis.get("surveys_nps", 0)),
|
||||||
|
"variance": s_var,
|
||||||
|
"sparkline": sparklines.get("surveys_satisfaction", []),
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"total": int(kpis.get("actions_total", 0)),
|
||||||
|
"open": int(kpis.get("actions_open", 0)),
|
||||||
|
"overdue": int(kpis.get("actions_overdue", 0)),
|
||||||
|
"closed": int(kpis.get("actions_closed", 0)),
|
||||||
|
"variance": a_var,
|
||||||
|
"sparkline": sparklines.get("actions_total", []),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _get_sparkline_data(self, hospital, days=7):
|
||||||
|
svc = ExecutiveSummaryService()
|
||||||
|
result = {}
|
||||||
|
for mt in ["complaints_total", "surveys_satisfaction", "actions_total"]:
|
||||||
|
trend = svc.get_trend_data(mt, days=days, hospital=hospital)
|
||||||
|
result[mt] = json.dumps([round(p["value"], 1) for p in trend])
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _get_chart_data(self, trends):
|
||||||
|
return {
|
||||||
|
"chart_data": {
|
||||||
|
"complaints_trend": json.dumps(trends.get("complaints_total", [])),
|
||||||
|
"surveys_satisfaction_trend": json.dumps(trends.get("surveys_satisfaction", [])),
|
||||||
|
"actions_trend": json.dumps(trends.get("actions_total", [])),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _get_risk_alerts(self):
|
||||||
|
alerts = (
|
||||||
|
PredictiveInsight.objects.filter(
|
||||||
|
severity__in=["high", "critical"],
|
||||||
|
status__in=["new", "acknowledged"],
|
||||||
|
)
|
||||||
|
.select_related("hospital", "department")
|
||||||
|
.order_by("-severity", "-created_at")[:10]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"risk_alerts": alerts,
|
||||||
|
"has_risk_alerts": alerts.exists(),
|
||||||
|
"risk_alerts_count": alerts.count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _get_insights_and_recommendations(self):
|
||||||
|
recs = (
|
||||||
|
AIRecommendation.objects.filter(
|
||||||
|
status__in=["new", "under_review"],
|
||||||
|
)
|
||||||
|
.select_related("hospital", "department")
|
||||||
|
.order_by("-priority", "-created_at")[:5]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ai_recommendations": recs,
|
||||||
|
"insights_critical_count": PredictiveInsight.objects.filter(severity="critical", status="new").count(),
|
||||||
|
"insights_high_count": PredictiveInsight.objects.filter(severity="high", status="new").count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _get_reports_context(self):
|
||||||
|
return {"recent_reports": ExecutiveReport.objects.all().order_by("-created_at")[:20]}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _get_filters(self, user, hospital, date_range):
|
||||||
|
from apps.organizations.models import Hospital
|
||||||
|
|
||||||
|
if user.is_px_admin():
|
||||||
|
hospitals = Hospital.objects.filter(status="active")
|
||||||
|
elif user.is_hospital_admin() and user.hospital:
|
||||||
|
hospitals = [user.hospital]
|
||||||
|
else:
|
||||||
|
hospitals = Hospital.objects.filter(status="active")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"available_hospitals": hospitals,
|
||||||
|
"selected_hospital": hospital,
|
||||||
|
"date_range": date_range,
|
||||||
|
"date_range_options": [
|
||||||
|
(7, _("Last 7 days")),
|
||||||
|
(14, _("Last 14 days")),
|
||||||
|
(30, _("Last 30 days")),
|
||||||
|
(60, _("Last 60 days")),
|
||||||
|
(90, _("Last 90 days")),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _get_latest_report(self):
|
||||||
|
rpt = ExecutiveReport.objects.filter(status="completed").order_by("-created_at").first()
|
||||||
|
ctx = {"latest_report": rpt}
|
||||||
|
if rpt:
|
||||||
|
ctx.update(
|
||||||
|
{
|
||||||
|
"ai_narrative": rpt.narrative_en,
|
||||||
|
"ai_highlights": rpt.highlights_en,
|
||||||
|
"ai_concerns": rpt.concerns_en,
|
||||||
|
"report_period": f"{rpt.start_date} to {rpt.end_date}",
|
||||||
|
"report_type": rpt.get_report_type_display(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ctx.update(
|
||||||
|
{
|
||||||
|
"ai_narrative": None,
|
||||||
|
"ai_highlights": [],
|
||||||
|
"ai_concerns": [],
|
||||||
|
"report_period": None,
|
||||||
|
"report_type": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Dashboard API
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def executive_dashboard_api(request):
|
||||||
|
"""API endpoint for executive dashboard HTMX updates."""
|
||||||
|
if not (request.user.is_executive() or request.user.is_px_admin()):
|
||||||
|
return JsonResponse({"error": _("Permission denied")}, status=403)
|
||||||
|
|
||||||
|
date_range = int(request.GET.get("date_range", 30))
|
||||||
|
hospital_id = request.GET.get("hospital")
|
||||||
|
hospital = None
|
||||||
|
if hospital_id:
|
||||||
|
from apps.organizations.models import Hospital
|
||||||
|
|
||||||
|
try:
|
||||||
|
hospital = Hospital.objects.get(id=hospital_id)
|
||||||
|
except Hospital.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
dashboard_data = ExecutiveSummaryService().get_dashboard_data(hospital=hospital, date_range=date_range)
|
||||||
|
|
||||||
|
risk_alerts = PredictiveInsight.objects.filter(
|
||||||
|
severity__in=["high", "critical"],
|
||||||
|
status__in=["new", "acknowledged"],
|
||||||
|
).order_by("-severity", "-created_at")[:5]
|
||||||
|
|
||||||
|
latest_report = ExecutiveReport.objects.filter(status="completed").order_by("-created_at").first()
|
||||||
|
|
||||||
|
return JsonResponse(
|
||||||
|
{
|
||||||
|
"kpis": dashboard_data.get("kpis", {}),
|
||||||
|
"variances": dashboard_data.get("variances", {}),
|
||||||
|
"risk_alerts": [
|
||||||
|
{
|
||||||
|
"id": str(i.id),
|
||||||
|
"title": i.title_en,
|
||||||
|
"severity": i.severity,
|
||||||
|
"insight_type": i.insight_type,
|
||||||
|
"hospital": i.hospital.name if i.hospital else None,
|
||||||
|
"created_at": i.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
for i in risk_alerts
|
||||||
|
],
|
||||||
|
"hospital_leaderboard": dashboard_data.get("hospital_leaderboard", [])[:5],
|
||||||
|
"ai_narrative": latest_report.narrative_en if latest_report else None,
|
||||||
|
"last_updated": timezone.now().isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Predictive Insights
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class PredictiveInsightsView(ExecutiveAccessMixin, LoginRequiredMixin, TemplateView):
|
||||||
|
"""Predictive Insights - View and manage AI-detected patterns and risks."""
|
||||||
|
|
||||||
|
template_name = "executive/insights.html"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
user = self.request.user
|
||||||
|
|
||||||
|
severity_filter = self.request.GET.get("severity", "")
|
||||||
|
status_filter = self.request.GET.get("status", "")
|
||||||
|
insight_type_filter = self.request.GET.get("insight_type", "")
|
||||||
|
hospital_id = self.request.GET.get("hospital")
|
||||||
|
|
||||||
|
qs = PredictiveInsight.objects.select_related("hospital", "department", "acknowledged_by").order_by(
|
||||||
|
"-severity", "-created_at"
|
||||||
|
)
|
||||||
|
|
||||||
|
if severity_filter:
|
||||||
|
qs = qs.filter(severity=severity_filter)
|
||||||
|
if status_filter:
|
||||||
|
qs = qs.filter(status=status_filter)
|
||||||
|
if insight_type_filter:
|
||||||
|
qs = qs.filter(insight_type=insight_type_filter)
|
||||||
|
if hospital_id:
|
||||||
|
from apps.organizations.models import Hospital
|
||||||
|
|
||||||
|
try:
|
||||||
|
qs = qs.filter(hospital=Hospital.objects.get(id=hospital_id))
|
||||||
|
except Hospital.DoesNotExist:
|
||||||
|
pass
|
||||||
|
elif user.is_hospital_admin() and user.hospital:
|
||||||
|
qs = qs.filter(hospital=user.hospital)
|
||||||
|
|
||||||
|
context["total_insights"] = qs.count()
|
||||||
|
context["new_insights"] = qs.filter(status="new").count()
|
||||||
|
context["critical_insights"] = qs.filter(severity="critical").count()
|
||||||
|
context["high_insights"] = qs.filter(severity="high").count()
|
||||||
|
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
|
||||||
|
paginator = Paginator(qs, 25)
|
||||||
|
context["insights"] = paginator.get_page(self.request.GET.get("page"))
|
||||||
|
|
||||||
|
context["severity_choices"] = PredictiveInsight.SEVERITY_LEVELS
|
||||||
|
context["status_choices"] = PredictiveInsight.STATUS_CHOICES
|
||||||
|
context["insight_type_choices"] = PredictiveInsight.INSIGHT_TYPES
|
||||||
|
|
||||||
|
if user.is_px_admin():
|
||||||
|
from apps.organizations.models import Hospital
|
||||||
|
|
||||||
|
context["available_hospitals"] = Hospital.objects.filter(predictive_insights__isnull=False).distinct()
|
||||||
|
else:
|
||||||
|
context["available_hospitals"] = []
|
||||||
|
|
||||||
|
context["current_filters"] = {
|
||||||
|
"severity": severity_filter,
|
||||||
|
"status": status_filter,
|
||||||
|
"insight_type": insight_type_filter,
|
||||||
|
"hospital": hospital_id,
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def acknowledge_insight(request, insight_id):
|
||||||
|
"""Acknowledge a predictive insight via HTMX or JSON."""
|
||||||
|
if not (request.user.is_executive() or request.user.is_px_admin()):
|
||||||
|
return JsonResponse({"error": _("Permission denied")}, status=403)
|
||||||
|
|
||||||
|
insight = get_object_or_404(PredictiveInsight, id=insight_id)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
try:
|
||||||
|
insight.status = "acknowledged"
|
||||||
|
insight.acknowledged_by = request.user
|
||||||
|
insight.acknowledged_at = timezone.now()
|
||||||
|
insight.save(update_fields=["status", "acknowledged_by", "acknowledged_at"])
|
||||||
|
|
||||||
|
if request.headers.get("HX-Request"):
|
||||||
|
return HttpResponse(
|
||||||
|
'<span class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs '
|
||||||
|
'font-medium bg-green-100 text-green-800">'
|
||||||
|
'<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">'
|
||||||
|
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>'
|
||||||
|
"</svg>" + str(_("Acknowledged")) + "</span>"
|
||||||
|
)
|
||||||
|
return JsonResponse(
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": _("Insight acknowledged successfully"),
|
||||||
|
"insight_id": str(insight.id),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error acknowledging insight {insight_id}: {e}", exc_info=True)
|
||||||
|
if request.headers.get("HX-Request"):
|
||||||
|
return HttpResponse(
|
||||||
|
'<span class="text-red-600">' + str(_("Error acknowledging insight")) + "</span>",
|
||||||
|
status=500,
|
||||||
|
)
|
||||||
|
return JsonResponse({"error": _("Error acknowledging insight"), "details": str(e)}, status=500)
|
||||||
|
|
||||||
|
return JsonResponse({"error": _("POST required")}, status=405)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Per-Tab AI Analysis (HTMX)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _get_analysis_filters(request):
|
||||||
|
"""Extract date_range and hospital from request (POST or GET params)."""
|
||||||
|
date_range = int(request.POST.get("date_range") or request.GET.get("date_range", 30))
|
||||||
|
hospital_id = request.POST.get("hospital") or request.GET.get("hospital")
|
||||||
|
hospital = None
|
||||||
|
if hospital_id:
|
||||||
|
from apps.organizations.models import Hospital
|
||||||
|
|
||||||
|
try:
|
||||||
|
hospital = Hospital.objects.get(id=hospital_id)
|
||||||
|
except Hospital.DoesNotExist:
|
||||||
|
pass
|
||||||
|
elif request.user.is_hospital_admin() and request.user.hospital:
|
||||||
|
hospital = request.user.hospital
|
||||||
|
return date_range, hospital
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def analyze_overview(request):
|
||||||
|
"""On-demand AI analysis for the Overview tab (HTMX POST)."""
|
||||||
|
if not (request.user.is_executive() or request.user.is_px_admin()):
|
||||||
|
return JsonResponse({"error": _("Permission denied")}, status=403)
|
||||||
|
|
||||||
|
date_range, hospital = _get_analysis_filters(request)
|
||||||
|
now = timezone.now()
|
||||||
|
start_date = now - timedelta(days=date_range)
|
||||||
|
|
||||||
|
svc = ExecutiveSummaryService()
|
||||||
|
dashboard_data = svc.get_dashboard_data(hospital=hospital, date_range=date_range)
|
||||||
|
trends = dashboard_data.get("trends", {})
|
||||||
|
|
||||||
|
ai_svc = AINarrativeService()
|
||||||
|
analysis = ai_svc.generate_overview_analysis(
|
||||||
|
start_date=start_date.date(),
|
||||||
|
end_date=now.date(),
|
||||||
|
hospital=hospital,
|
||||||
|
)
|
||||||
|
|
||||||
|
html = render_to_string(
|
||||||
|
"executive/partials/ai_overview_card.html",
|
||||||
|
{"analysis": analysis},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
return HttpResponse(html)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def analyze_trends(request):
|
||||||
|
"""On-demand AI analysis for the Trends tab (HTMX POST)."""
|
||||||
|
if not (request.user.is_executive() or request.user.is_px_admin()):
|
||||||
|
return JsonResponse({"error": _("Permission denied")}, status=403)
|
||||||
|
|
||||||
|
date_range, hospital = _get_analysis_filters(request)
|
||||||
|
now = timezone.now()
|
||||||
|
start_date = now - timedelta(days=date_range)
|
||||||
|
|
||||||
|
svc = ExecutiveSummaryService()
|
||||||
|
dashboard_data = svc.get_dashboard_data(hospital=hospital, date_range=date_range)
|
||||||
|
trends = dashboard_data.get("trends", {})
|
||||||
|
leaderboard = dashboard_data.get("hospital_leaderboard", [])
|
||||||
|
|
||||||
|
ai_svc = AINarrativeService()
|
||||||
|
analysis = ai_svc.generate_trends_analysis(
|
||||||
|
start_date=start_date.date(),
|
||||||
|
end_date=now.date(),
|
||||||
|
hospital=hospital,
|
||||||
|
trends_data=trends,
|
||||||
|
leaderboard=leaderboard,
|
||||||
|
)
|
||||||
|
|
||||||
|
html = render_to_string(
|
||||||
|
"executive/partials/ai_trends_card.html",
|
||||||
|
{"analysis": analysis},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
return HttpResponse(html)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def analyze_insights(request):
|
||||||
|
"""On-demand AI analysis for the Insights tab (HTMX POST)."""
|
||||||
|
if not (request.user.is_executive() or request.user.is_px_admin()):
|
||||||
|
return JsonResponse({"error": _("Permission denied")}, status=403)
|
||||||
|
|
||||||
|
date_range, hospital = _get_analysis_filters(request)
|
||||||
|
|
||||||
|
risk_alerts = list(
|
||||||
|
PredictiveInsight.objects.filter(
|
||||||
|
severity__in=["high", "critical"],
|
||||||
|
status__in=["new", "acknowledged"],
|
||||||
|
)
|
||||||
|
.select_related("hospital", "department")
|
||||||
|
.order_by("-severity", "-created_at")[:10]
|
||||||
|
)
|
||||||
|
|
||||||
|
ai_recs = list(
|
||||||
|
AIRecommendation.objects.filter(status__in=["new", "under_review"])
|
||||||
|
.select_related("hospital", "department")
|
||||||
|
.order_by("-priority", "-created_at")[:5]
|
||||||
|
)
|
||||||
|
|
||||||
|
ai_svc = AINarrativeService()
|
||||||
|
analysis = ai_svc.generate_insights_analysis(
|
||||||
|
hospital=hospital,
|
||||||
|
risk_alerts=risk_alerts,
|
||||||
|
ai_recommendations=ai_recs,
|
||||||
|
)
|
||||||
|
|
||||||
|
html = render_to_string(
|
||||||
|
"executive/partials/ai_insights_card.html",
|
||||||
|
{"analysis": analysis},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
return HttpResponse(html)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Report Generation
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def generate_executive_report(request):
|
||||||
|
"""Trigger AI report generation for executive summary."""
|
||||||
|
if not (request.user.is_executive() or request.user.is_px_admin()):
|
||||||
|
messages.error(request, _("You do not have permission to generate reports."))
|
||||||
|
return redirect("executive:executive_dashboard")
|
||||||
|
|
||||||
|
if request.method != "POST":
|
||||||
|
return redirect("executive:executive_dashboard")
|
||||||
|
|
||||||
|
report_type = request.POST.get("report_type", "weekly")
|
||||||
|
start_date_str = request.POST.get("start_date")
|
||||||
|
end_date_str = request.POST.get("end_date")
|
||||||
|
now = timezone.now()
|
||||||
|
|
||||||
|
if start_date_str and end_date_str:
|
||||||
|
try:
|
||||||
|
start_date = datetime.strptime(start_date_str, "%Y-%m-%d").date()
|
||||||
|
end_date = datetime.strptime(end_date_str, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
messages.error(request, _("Invalid date format. Use YYYY-MM-DD."))
|
||||||
|
return redirect("executive:executive_dashboard")
|
||||||
|
else:
|
||||||
|
end_date = now.date()
|
||||||
|
deltas = {"weekly": 7, "monthly": 30, "quarterly": 90}
|
||||||
|
start_date = end_date - timedelta(days=deltas.get(report_type, 7))
|
||||||
|
|
||||||
|
try:
|
||||||
|
report = ExecutiveReport.objects.create(
|
||||||
|
report_type=report_type,
|
||||||
|
status="generating",
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
ai_model="openrouter/google/gemma-3-27b-it:free",
|
||||||
|
)
|
||||||
|
|
||||||
|
narrative_service = AINarrativeService()
|
||||||
|
method_map = {
|
||||||
|
"monthly": ("generate_monthly_narrative", "monthly"),
|
||||||
|
"quarterly": ("generate_quarterly_narrative", "quarterly"),
|
||||||
|
}
|
||||||
|
method_name, ar_type = method_map.get(report_type, ("generate_weekly_narrative", "weekly"))
|
||||||
|
|
||||||
|
en_result = getattr(narrative_service, method_name)(start_date=start_date, end_date=end_date)
|
||||||
|
ar_result = narrative_service.generate_arabic_narrative(
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
report_type=ar_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
report.status = en_result.get("status", "completed")
|
||||||
|
report.narrative_en = en_result.get("narrative_en", "")
|
||||||
|
report.narrative_ar = ar_result.get("narrative_ar", "")
|
||||||
|
report.highlights_en = en_result.get("highlights_en", [])
|
||||||
|
report.highlights_ar = ar_result.get("highlights_ar", [])
|
||||||
|
report.concerns_en = en_result.get("concerns_en", [])
|
||||||
|
report.concerns_ar = ar_result.get("concerns_ar", [])
|
||||||
|
report.ai_model = en_result.get("ai_model", "")
|
||||||
|
report.error_message = en_result.get("error_message", "") or ar_result.get("error_message", "")
|
||||||
|
|
||||||
|
metrics = ExecutiveSummaryService().aggregate_daily_metrics(target_date=end_date)
|
||||||
|
report.metrics_snapshot = {k: float(v) for k, v in metrics.items()}
|
||||||
|
report.save()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .pdf_service import generate_executive_pdf
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
|
||||||
|
pdf_bytes = generate_executive_pdf(report)
|
||||||
|
report.pdf_file.save(
|
||||||
|
f"executive_report_{report.id}_{report.start_date}.pdf",
|
||||||
|
ContentFile(pdf_bytes),
|
||||||
|
save=True,
|
||||||
|
)
|
||||||
|
except Exception as pdf_err:
|
||||||
|
logger.error(f"Error saving PDF for report {report.id}: {pdf_err}", exc_info=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
PredictiveAnalyticsService().generate_predictive_insights()
|
||||||
|
RecommendationService().generate_recommendations_from_insights()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating insights/recommendations: {e}", exc_info=True)
|
||||||
|
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
_("%(report_type)s report generated successfully.") % {"report_type": report.get_report_type_display()},
|
||||||
|
)
|
||||||
|
return redirect("executive:executive_dashboard")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating executive report: {e}", exc_info=True)
|
||||||
|
messages.error(request, _("Failed to generate report. Please try again."))
|
||||||
|
if "report" in locals():
|
||||||
|
report.status = "failed"
|
||||||
|
report.error_message = str(e)
|
||||||
|
report.save()
|
||||||
|
return redirect("executive:executive_dashboard")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# PDF Export
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutivePDFReportView(ExecutiveAccessMixin, LoginRequiredMixin, View):
|
||||||
|
"""Generate and download PDF version of an executive report."""
|
||||||
|
|
||||||
|
def get(self, request, report_id, *args, **kwargs):
|
||||||
|
report = get_object_or_404(ExecutiveReport, id=report_id)
|
||||||
|
if not (request.user.is_executive() or request.user.is_px_admin()):
|
||||||
|
messages.error(request, _("You do not have permission to access this report."))
|
||||||
|
return redirect("executive:executive_dashboard")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if report.pdf_file:
|
||||||
|
resp = HttpResponse(report.pdf_file.read(), content_type="application/pdf")
|
||||||
|
resp["Content-Disposition"] = (
|
||||||
|
f'attachment; filename="executive_report_{report.id}_{report.start_date}.pdf"'
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
from .pdf_service import generate_executive_pdf
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
|
||||||
|
pdf_bytes = generate_executive_pdf(report)
|
||||||
|
report.pdf_file.save(
|
||||||
|
f"executive_report_{report.id}_{report.start_date}.pdf",
|
||||||
|
ContentFile(pdf_bytes),
|
||||||
|
save=True,
|
||||||
|
)
|
||||||
|
resp = HttpResponse(pdf_bytes, content_type="application/pdf")
|
||||||
|
resp["Content-Disposition"] = f'attachment; filename="executive_report_{report.id}_{report.start_date}.pdf"'
|
||||||
|
return resp
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating PDF for report {report_id}: {e}", exc_info=True)
|
||||||
|
messages.error(request, _("Failed to generate PDF report."))
|
||||||
|
return redirect("executive:executive_dashboard")
|
||||||
@ -182,6 +182,7 @@ class NotificationService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
response = requests.get(url, params=params, timeout=30)
|
response = requests.get(url, params=params, timeout=30)
|
||||||
|
logger.info(f"Mshastra API response for {phone}: {response.text}")
|
||||||
response_text = response.text.strip()
|
response_text = response.text.strip()
|
||||||
|
|
||||||
log.provider_response = {"status_code": response.status_code, "response": response_text}
|
log.provider_response = {"status_code": response.status_code, "response": response_text}
|
||||||
|
|||||||
@ -441,7 +441,9 @@ def notification_settings_api(request, hospital_id=None):
|
|||||||
if field.name not in ["id", "uuid", "created_at", "updated_at", "hospital"]:
|
if field.name not in ["id", "uuid", "created_at", "updated_at", "hospital"]:
|
||||||
settings_dict[field.name] = getattr(settings, field.name)
|
settings_dict[field.name] = getattr(settings, field.name)
|
||||||
|
|
||||||
return JsonResponse({"hospital_id": str(hospital.id), "hospital_name": hospital.name, "settings": settings_dict})
|
return JsonResponse(
|
||||||
|
{"hospital_id": str(hospital.id), "hospital_name": hospital.get_display_name(), "settings": settings_dict}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
@ -30,13 +30,14 @@ class OrganizationAdmin(admin.ModelAdmin):
|
|||||||
class HospitalAdmin(admin.ModelAdmin):
|
class HospitalAdmin(admin.ModelAdmin):
|
||||||
"""Hospital admin"""
|
"""Hospital admin"""
|
||||||
|
|
||||||
list_display = ["name", "code", "city", "ceo", "status", "capacity", "created_at"]
|
list_display = ["name", "get_display_name", "code", "city", "ceo", "status", "capacity", "created_at"]
|
||||||
list_filter = ["status", "city"]
|
list_filter = ["status", "city"]
|
||||||
search_fields = ["name", "name_ar", "code", "license_number"]
|
search_fields = ["name", "name_ar", "display_name", "display_name_ar", "code", "license_number"]
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {"fields": ("organization", "name", "name_ar", "code")}),
|
(None, {"fields": ("organization", "name", "name_ar", "code")}),
|
||||||
|
("Display", {"fields": ("display_name", "display_name_ar")}),
|
||||||
("Contact Information", {"fields": ("address", "city", "phone", "email")}),
|
("Contact Information", {"fields": ("address", "city", "phone", "email")}),
|
||||||
("Executive Leadership", {"fields": ("ceo", "medical_director", "coo", "cfo")}),
|
("Executive Leadership", {"fields": ("ceo", "medical_director", "coo", "cfo")}),
|
||||||
("Details", {"fields": ("license_number", "capacity", "status")}),
|
("Details", {"fields": ("license_number", "capacity", "status")}),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -62,6 +62,17 @@ class Hospital(UUIDModel, TimeStampedModel):
|
|||||||
)
|
)
|
||||||
name = models.CharField(max_length=200)
|
name = models.CharField(max_length=200)
|
||||||
name_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)")
|
name_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)")
|
||||||
|
display_name = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
blank=True,
|
||||||
|
help_text="Display name (English). Falls back to 'name' if empty.",
|
||||||
|
)
|
||||||
|
display_name_ar = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Display Name (Arabic)",
|
||||||
|
help_text="Display name (Arabic). Falls back to 'name_ar' if empty.",
|
||||||
|
)
|
||||||
code = models.CharField(max_length=50, unique=True)
|
code = models.CharField(max_length=50, unique=True)
|
||||||
|
|
||||||
# Contact information
|
# Contact information
|
||||||
@ -121,14 +132,20 @@ class Hospital(UUIDModel, TimeStampedModel):
|
|||||||
verbose_name_plural = "Hospitals"
|
verbose_name_plural = "Hospitals"
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.get_display_name()
|
||||||
|
|
||||||
def get_localized_name(self):
|
def get_localized_name(self):
|
||||||
from django.utils.translation import get_language
|
from django.utils.translation import get_language
|
||||||
|
|
||||||
if get_language() == "ar" and self.name_ar:
|
if get_language() == "ar":
|
||||||
return self.name_ar
|
return self.get_display_name_ar()
|
||||||
return self.name
|
return self.get_display_name()
|
||||||
|
|
||||||
|
def get_display_name(self):
|
||||||
|
return self.display_name or self.name
|
||||||
|
|
||||||
|
def get_display_name_ar(self):
|
||||||
|
return self.display_name_ar or self.name_ar or self.get_display_name()
|
||||||
|
|
||||||
|
|
||||||
class Department(UUIDModel, TimeStampedModel):
|
class Department(UUIDModel, TimeStampedModel):
|
||||||
@ -413,7 +430,7 @@ class Patient(UUIDModel, TimeStampedModel):
|
|||||||
|
|
||||||
# Basic information
|
# Basic information
|
||||||
mrn = models.CharField(max_length=50, unique=True, verbose_name="Medical Record Number")
|
mrn = models.CharField(max_length=50, unique=True, verbose_name="Medical Record Number")
|
||||||
national_id = EncryptedCharField(max_length=50, blank=True, default="")
|
national_id = EncryptedCharField(max_length=256, blank=True, default="")
|
||||||
national_id_hash = models.CharField(max_length=64, blank=True, db_index=True, default="")
|
national_id_hash = models.CharField(max_length=64, blank=True, db_index=True, default="")
|
||||||
|
|
||||||
first_name = models.CharField(max_length=100)
|
first_name = models.CharField(max_length=100)
|
||||||
|
|||||||
@ -52,6 +52,8 @@ class HospitalSerializer(serializers.ModelSerializer):
|
|||||||
"organization_name",
|
"organization_name",
|
||||||
"name",
|
"name",
|
||||||
"name_ar",
|
"name_ar",
|
||||||
|
"display_name",
|
||||||
|
"display_name_ar",
|
||||||
"code",
|
"code",
|
||||||
"address",
|
"address",
|
||||||
"city",
|
"city",
|
||||||
|
|||||||
@ -576,7 +576,7 @@ class DoctorRatingAdapter:
|
|||||||
"""
|
"""
|
||||||
from django.db.models import Avg, Count, Q
|
from django.db.models import Avg, Count, Q
|
||||||
|
|
||||||
results = {"aggregated": 0, "errors": []}
|
results = {"aggregated": 0, "skipped_unlinked": 0, "errors": []}
|
||||||
|
|
||||||
# Get unaggregated ratings for the period
|
# Get unaggregated ratings for the period
|
||||||
queryset = PhysicianIndividualRating.objects.filter(
|
queryset = PhysicianIndividualRating.objects.filter(
|
||||||
@ -586,6 +586,14 @@ class DoctorRatingAdapter:
|
|||||||
if hospital:
|
if hospital:
|
||||||
queryset = queryset.filter(hospital=hospital)
|
queryset = queryset.filter(hospital=hospital)
|
||||||
|
|
||||||
|
# Count unlinked ratings before grouping
|
||||||
|
unlinked_count = queryset.filter(staff__isnull=True).count()
|
||||||
|
if unlinked_count:
|
||||||
|
results["skipped_unlinked"] = unlinked_count
|
||||||
|
logger.warning(
|
||||||
|
f"Skipping {unlinked_count} unlinked individual ratings for {year}-{month:02d} (no staff match)"
|
||||||
|
)
|
||||||
|
|
||||||
# Group by staff
|
# Group by staff
|
||||||
staff_ratings = queryset.values("staff").annotate(
|
staff_ratings = queryset.values("staff").annotate(
|
||||||
avg_rating=Avg("rating"),
|
avg_rating=Avg("rating"),
|
||||||
|
|||||||
@ -0,0 +1,126 @@
|
|||||||
|
"""
|
||||||
|
Management command to backfill monthly physician ratings from existing
|
||||||
|
individual ratings that have not yet been aggregated.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python manage.py backfill_physician_monthly_ratings
|
||||||
|
python manage.py backfill_physician_monthly_ratings --year 2026 --month 4
|
||||||
|
python manage.py backfill_physician_monthly_ratings --dry-run
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from apps.physicians.adapter import DoctorRatingAdapter
|
||||||
|
from apps.physicians.models import PhysicianIndividualRating
|
||||||
|
from apps.organizations.models import Hospital
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Backfill PhysicianMonthlyRating aggregates from unaggregated PhysicianIndividualRating records"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("--year", type=int, help="Specific year to backfill (e.g. 2026)")
|
||||||
|
parser.add_argument("--month", type=int, help="Specific month to backfill (1-12)")
|
||||||
|
parser.add_argument("--hospital-id", type=str, help="Optional hospital UUID to limit backfill")
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Show what would be aggregated without saving changes",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
year: Optional[int] = options.get("year")
|
||||||
|
month: Optional[int] = options.get("month")
|
||||||
|
hospital_id: Optional[str] = options.get("hospital_id")
|
||||||
|
dry_run: bool = options["dry_run"]
|
||||||
|
|
||||||
|
hospital = None
|
||||||
|
if hospital_id:
|
||||||
|
try:
|
||||||
|
hospital = Hospital.objects.get(id=hospital_id)
|
||||||
|
except Hospital.DoesNotExist:
|
||||||
|
self.stdout.write(self.style.ERROR(f"Hospital {hospital_id} not found"))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build queryset of unaggregated records
|
||||||
|
qs = PhysicianIndividualRating.objects.filter(is_aggregated=False)
|
||||||
|
if year:
|
||||||
|
qs = qs.filter(rating_date__year=year)
|
||||||
|
if month:
|
||||||
|
qs = qs.filter(rating_date__month=month)
|
||||||
|
if hospital:
|
||||||
|
qs = qs.filter(hospital=hospital)
|
||||||
|
|
||||||
|
total_unaggregated = qs.count()
|
||||||
|
if total_unaggregated == 0:
|
||||||
|
self.stdout.write(self.style.SUCCESS("No unaggregated individual ratings found."))
|
||||||
|
return
|
||||||
|
|
||||||
|
self.stdout.write(f"Found {total_unaggregated} unaggregated individual rating(s).")
|
||||||
|
|
||||||
|
# Determine distinct periods/hospitals to process
|
||||||
|
periods = (
|
||||||
|
qs.values("rating_date__year", "rating_date__month", "hospital")
|
||||||
|
.distinct()
|
||||||
|
.order_by("rating_date__year", "rating_date__month", "hospital")
|
||||||
|
)
|
||||||
|
|
||||||
|
total_aggregated = 0
|
||||||
|
total_skipped = 0
|
||||||
|
total_errors = 0
|
||||||
|
|
||||||
|
for period in periods:
|
||||||
|
p_year = period["rating_date__year"]
|
||||||
|
p_month = period["rating_date__month"]
|
||||||
|
p_hospital_id = period["hospital"]
|
||||||
|
|
||||||
|
p_hospital = None
|
||||||
|
if p_hospital_id:
|
||||||
|
try:
|
||||||
|
p_hospital = Hospital.objects.get(id=p_hospital_id)
|
||||||
|
except Hospital.DoesNotExist:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(f"Skipping {p_year}-{p_month:02d} hospital {p_hospital_id} (not found)")
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
label = f"{p_year}-{p_month:02d}"
|
||||||
|
if p_hospital:
|
||||||
|
label += f" ({p_hospital.name})"
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
count = qs.filter(
|
||||||
|
rating_date__year=p_year,
|
||||||
|
rating_date__month=p_month,
|
||||||
|
hospital=p_hospital_id,
|
||||||
|
).count()
|
||||||
|
self.stdout.write(f"[DRY RUN] Would aggregate {count} rating(s) for {label}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.stdout.write(f"Aggregating {label} ...")
|
||||||
|
results = DoctorRatingAdapter.aggregate_monthly_ratings(year=p_year, month=p_month, hospital=p_hospital)
|
||||||
|
total_aggregated += results["aggregated"]
|
||||||
|
total_skipped += results.get("skipped_unlinked", 0)
|
||||||
|
total_errors += len(results["errors"])
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
f" -> Aggregated: {results['aggregated']}, "
|
||||||
|
f"Skipped (unlinked): {results.get('skipped_unlinked', 0)}, "
|
||||||
|
f"Errors: {len(results['errors'])}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"\nBackfill complete: {total_aggregated} monthly record(s) updated, "
|
||||||
|
f"{total_skipped} unlinked rating(s) skipped, "
|
||||||
|
f"{total_errors} error(s)."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(self.style.NOTICE("\nDry run complete. No changes were saved."))
|
||||||
@ -420,7 +420,11 @@ def fetch_his_doctor_ratings(self, job_id: str, from_date_iso: str, to_date_iso:
|
|||||||
calls _fetch_and_process_his_doctor_ratings() directly.
|
calls _fetch_and_process_his_doctor_ratings() directly.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return _fetch_and_process_his_doctor_ratings(job_id, from_date_iso, to_date_iso)
|
result = _fetch_and_process_his_doctor_ratings(job_id, from_date_iso, to_date_iso)
|
||||||
|
# Trigger aggregation for any newly imported (or previously pending) ratings
|
||||||
|
if result.get("success"):
|
||||||
|
auto_aggregate_daily.delay()
|
||||||
|
return result
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise self.retry(exc=exc)
|
raise self.retry(exc=exc)
|
||||||
|
|
||||||
|
|||||||
228
apps/projects/export_utils.py
Normal file
228
apps/projects/export_utils.py
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
|
||||||
|
|
||||||
|
NAVY_FILL = PatternFill(start_color="005696", end_color="005696", fill_type="solid")
|
||||||
|
BLUE_FILL = PatternFill(start_color="E0F2FE", end_color="E0F2FE", fill_type="solid")
|
||||||
|
GREEN_FILL = PatternFill(start_color="DCFCE7", end_color="DCFCE7", fill_type="solid")
|
||||||
|
LIGHT_FILL = PatternFill(start_color="F8FAFC", end_color="F8FAFC", fill_type="solid")
|
||||||
|
WHITE_FONT = Font(color="FFFFFF", bold=True, size=11)
|
||||||
|
HEADER_FONT = Font(color="FFFFFF", bold=True, size=12)
|
||||||
|
TITLE_FONT = Font(color="005696", bold=True, size=14)
|
||||||
|
SECTION_FONT = Font(color="005696", bold=True, size=11)
|
||||||
|
BODY_FONT = Font(size=10)
|
||||||
|
BOLD_FONT = Font(bold=True, size=10)
|
||||||
|
THIN_BORDER = Border(
|
||||||
|
left=Side(style="thin", color="E2E8F0"),
|
||||||
|
right=Side(style="thin", color="E2E8F0"),
|
||||||
|
top=Side(style="thin", color="E2E8F0"),
|
||||||
|
bottom=Side(style="thin", color="E2E8F0"),
|
||||||
|
)
|
||||||
|
WRAP_ALIGN = Alignment(wrap_text=True, vertical="top")
|
||||||
|
CENTER_ALIGN = Alignment(horizontal="center", vertical="center")
|
||||||
|
|
||||||
|
|
||||||
|
def _write_header_row(ws, row, headers, fill=None, font=None):
|
||||||
|
col = 1
|
||||||
|
for h in headers:
|
||||||
|
cell = ws.cell(row=row, column=col, value=h)
|
||||||
|
cell.font = font or WHITE_FONT
|
||||||
|
cell.fill = fill or NAVY_FILL
|
||||||
|
cell.alignment = CENTER_ALIGN
|
||||||
|
cell.border = THIN_BORDER
|
||||||
|
col += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _write_row(ws, row, values, fill=None, font=None):
|
||||||
|
col = 1
|
||||||
|
for v in values:
|
||||||
|
cell = ws.cell(row=row, column=col, value=v)
|
||||||
|
cell.font = font or BODY_FONT
|
||||||
|
cell.fill = fill or PatternFill()
|
||||||
|
cell.alignment = WRAP_ALIGN
|
||||||
|
cell.border = THIN_BORDER
|
||||||
|
col += 1
|
||||||
|
|
||||||
|
|
||||||
|
def export_project_excel(project):
|
||||||
|
wb = Workbook()
|
||||||
|
|
||||||
|
# --- Sheet 1: Project Overview ---
|
||||||
|
ws_overview = wb.active
|
||||||
|
ws_overview.title = "Project Overview"
|
||||||
|
|
||||||
|
ws_overview.merge_cells("A1:D1")
|
||||||
|
title_cell = ws_overview.cell(row=1, column=1, value=f"QI Project: {project.name}")
|
||||||
|
title_cell.font = TITLE_FONT
|
||||||
|
|
||||||
|
info_data = [
|
||||||
|
("Description", project.description),
|
||||||
|
("Hospital", project.hospital.name if project.hospital else ""),
|
||||||
|
("Department", project.department.name if project.department else ""),
|
||||||
|
("Project Lead", project.project_lead.get_full_name() if project.project_lead else ""),
|
||||||
|
("Created By", project.created_by.get_full_name() if project.created_by else ""),
|
||||||
|
("Status", project.get_status_display()),
|
||||||
|
("Start Date", str(project.start_date) if project.start_date else ""),
|
||||||
|
("Target Completion", str(project.target_completion_date) if project.target_completion_date else ""),
|
||||||
|
("Actual Completion", str(project.actual_completion_date) if project.actual_completion_date else ""),
|
||||||
|
("Outcome", project.outcome_description or ""),
|
||||||
|
]
|
||||||
|
|
||||||
|
_write_header_row(ws_overview, 3, ["Field", "Value"])
|
||||||
|
for i, (field, value) in enumerate(info_data, start=4):
|
||||||
|
fill = LIGHT_FILL if i % 2 == 0 else None
|
||||||
|
_write_row(ws_overview, i, [field, value], fill=fill)
|
||||||
|
ws_overview.cell(row=i, column=1).font = BOLD_FONT
|
||||||
|
|
||||||
|
ws_overview.column_dimensions["A"].width = 25
|
||||||
|
ws_overview.column_dimensions["B"].width = 60
|
||||||
|
|
||||||
|
# Team Members
|
||||||
|
row = 4 + len(info_data) + 1
|
||||||
|
ws_overview.merge_cells(f"A{row}:D{row}")
|
||||||
|
ws_overview.cell(row=row, column=1, value="Team Members").font = SECTION_FONT
|
||||||
|
row += 1
|
||||||
|
_write_header_row(ws_overview, row, ["Name", "Email", "Role(s)"])
|
||||||
|
row += 1
|
||||||
|
for member in project.team_members.all():
|
||||||
|
roles = ", ".join(member.get_role_names()) if hasattr(member, "get_role_names") else ""
|
||||||
|
_write_row(ws_overview, row, [member.get_full_name(), member.email, roles])
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
# --- Sheet 2: PDCA Phases ---
|
||||||
|
ws_pdca = wb.create_sheet("PDCA Cycle")
|
||||||
|
|
||||||
|
ws_pdca.merge_cells("A1:F1")
|
||||||
|
ws_pdca.cell(row=1, column=1, value="PDCA Cycle").font = TITLE_FONT
|
||||||
|
|
||||||
|
_write_header_row(ws_pdca, 3, ["Phase", "Title", "Status", "Owner", "Start Date", "Due Date"])
|
||||||
|
|
||||||
|
row = 4
|
||||||
|
phase_fills = {
|
||||||
|
"plan": PatternFill(start_color="FEF3C7", end_color="FEF3C7", fill_type="solid"),
|
||||||
|
"do": PatternFill(start_color="DBEAFE", end_color="DBEAFE", fill_type="solid"),
|
||||||
|
"check": PatternFill(start_color="F3E8FF", end_color="F3E8FF", fill_type="solid"),
|
||||||
|
"act": PatternFill(start_color="DCFCE7", end_color="DCFCE7", fill_type="solid"),
|
||||||
|
}
|
||||||
|
|
||||||
|
for phase_key, phase_label in [("plan", "Plan"), ("do", "Do"), ("check", "Check"), ("act", "Act")]:
|
||||||
|
pdca_phase = None
|
||||||
|
for p in project.pdca_phases.all():
|
||||||
|
if p.phase == phase_key:
|
||||||
|
pdca_phase = p
|
||||||
|
break
|
||||||
|
|
||||||
|
fill = phase_fills.get(phase_key, LIGHT_FILL)
|
||||||
|
if pdca_phase:
|
||||||
|
_write_row(
|
||||||
|
ws_pdca,
|
||||||
|
row,
|
||||||
|
[
|
||||||
|
phase_label,
|
||||||
|
pdca_phase.title,
|
||||||
|
pdca_phase.get_status_display(),
|
||||||
|
pdca_phase.owner.get_full_name() if pdca_phase.owner else "",
|
||||||
|
str(pdca_phase.start_date) if pdca_phase.start_date else "",
|
||||||
|
str(pdca_phase.due_date) if pdca_phase.due_date else "",
|
||||||
|
],
|
||||||
|
fill=fill,
|
||||||
|
)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
if pdca_phase.description:
|
||||||
|
ws_pdca.merge_cells(f"B{row}:F{row}")
|
||||||
|
desc_cell = ws_pdca.cell(row=row, column=2, value=f"Description: {pdca_phase.description}")
|
||||||
|
desc_cell.font = Font(size=9, italic=True, color="64748B")
|
||||||
|
desc_cell.alignment = WRAP_ALIGN
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
if pdca_phase.findings:
|
||||||
|
ws_pdca.merge_cells(f"B{row}:F{row}")
|
||||||
|
find_cell = ws_pdca.cell(row=row, column=2, value=f"Findings: {pdca_phase.findings}")
|
||||||
|
find_cell.font = Font(size=9, italic=True, color="64748B")
|
||||||
|
find_cell.alignment = WRAP_ALIGN
|
||||||
|
row += 1
|
||||||
|
else:
|
||||||
|
_write_row(ws_pdca, row, [phase_label, "(Not started)", "", "", "", ""], fill=fill)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
ws_pdca.column_dimensions["A"].width = 12
|
||||||
|
ws_pdca.column_dimensions["B"].width = 40
|
||||||
|
ws_pdca.column_dimensions["C"].width = 15
|
||||||
|
ws_pdca.column_dimensions["D"].width = 20
|
||||||
|
ws_pdca.column_dimensions["E"].width = 15
|
||||||
|
ws_pdca.column_dimensions["F"].width = 15
|
||||||
|
|
||||||
|
# --- Sheet 3: Phase Tasks ---
|
||||||
|
ws_tasks = wb.create_sheet("Phase Tasks")
|
||||||
|
|
||||||
|
ws_tasks.merge_cells("A1:G1")
|
||||||
|
ws_tasks.cell(row=1, column=1, value="Tasks by PDCA Phase").font = TITLE_FONT
|
||||||
|
|
||||||
|
row = 3
|
||||||
|
phase_order = ["plan", "do", "check", "act"]
|
||||||
|
for phase_key in phase_order:
|
||||||
|
phase_label = dict(PDCAPhaseChoices.choices).get(phase_key, phase_key)
|
||||||
|
phase_obj = None
|
||||||
|
for p in project.pdca_phases.all():
|
||||||
|
if p.phase == phase_key:
|
||||||
|
phase_obj = p
|
||||||
|
break
|
||||||
|
|
||||||
|
# Phase header
|
||||||
|
ws_tasks.merge_cells(f"A{row}:G{row}")
|
||||||
|
phase_header = ws_tasks.cell(row=row, column=1, value=f"{phase_label} Phase")
|
||||||
|
phase_header.font = SECTION_FONT
|
||||||
|
phase_header.fill = LIGHT_FILL
|
||||||
|
phase_header.alignment = CENTER_ALIGN
|
||||||
|
phase_header.border = THIN_BORDER
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
_write_header_row(ws_tasks, row, ["Title", "Description", "Assigned To", "Status", "Due Date", "Completed"])
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
if phase_obj:
|
||||||
|
phase_tasks = phase_obj.tasks.all().order_by("order")
|
||||||
|
if phase_tasks:
|
||||||
|
for task in phase_tasks:
|
||||||
|
fill = GREEN_FILL if task.status == "completed" else None
|
||||||
|
_write_row(
|
||||||
|
ws_tasks,
|
||||||
|
row,
|
||||||
|
[
|
||||||
|
task.title,
|
||||||
|
task.description,
|
||||||
|
task.assigned_to.get_full_name() if task.assigned_to else "",
|
||||||
|
task.get_status_display(),
|
||||||
|
str(task.due_date) if task.due_date else "",
|
||||||
|
str(task.completed_date) if task.completed_date else "",
|
||||||
|
],
|
||||||
|
fill=fill,
|
||||||
|
)
|
||||||
|
row += 1
|
||||||
|
else:
|
||||||
|
ws_tasks.merge_cells(f"A{row}:G{row}")
|
||||||
|
no_tasks = ws_tasks.cell(row=row, column=1, value="No tasks in this phase")
|
||||||
|
no_tasks.font = Font(size=10, italic=True, color="94A3B8")
|
||||||
|
no_tasks.alignment = CENTER_ALIGN
|
||||||
|
row += 1
|
||||||
|
else:
|
||||||
|
ws_tasks.merge_cells(f"A{row}:G{row}")
|
||||||
|
no_phase = ws_tasks.cell(row=row, column=1, value="Phase not started")
|
||||||
|
no_phase.font = Font(size=10, italic=True, color="94A3B8")
|
||||||
|
no_phase.alignment = CENTER_ALIGN
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
row += 1 # spacing between phases
|
||||||
|
|
||||||
|
ws_tasks.column_dimensions["A"].width = 30
|
||||||
|
ws_tasks.column_dimensions["B"].width = 40
|
||||||
|
ws_tasks.column_dimensions["C"].width = 20
|
||||||
|
ws_tasks.column_dimensions["D"].width = 15
|
||||||
|
ws_tasks.column_dimensions["E"].width = 15
|
||||||
|
ws_tasks.column_dimensions["F"].width = 15
|
||||||
|
|
||||||
|
return wb
|
||||||
@ -150,7 +150,7 @@ class QIProjectTaskForm(forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = QIProjectTask
|
model = QIProjectTask
|
||||||
fields = ["title", "description", "assigned_to", "status", "due_date", "order"]
|
fields = ["title", "description", "assigned_to", "status", "due_date", "order", "pdca_phase"]
|
||||||
widgets = {
|
widgets = {
|
||||||
"title": forms.TextInput(
|
"title": forms.TextInput(
|
||||||
attrs={
|
attrs={
|
||||||
@ -187,15 +187,31 @@ class QIProjectTaskForm(forms.ModelForm):
|
|||||||
"min": 0,
|
"min": 0,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
"pdca_phase": forms.Select(
|
||||||
|
attrs={
|
||||||
|
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
|
||||||
|
}
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
self.project = kwargs.pop("project", None)
|
self.project = kwargs.pop("project", None)
|
||||||
|
self.current_phase = kwargs.pop("current_phase", None)
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
# Make order field not required (has default value of 0)
|
# Make order field not required (has default value of 0)
|
||||||
self.fields["order"].required = False
|
self.fields["order"].required = False
|
||||||
|
|
||||||
|
# Filter pdca_phase choices based on project
|
||||||
|
if self.project:
|
||||||
|
self.fields["pdca_phase"].queryset = self.project.pdca_phases.all().order_by("order")
|
||||||
|
if self.current_phase:
|
||||||
|
self.fields["pdca_phase"].initial = self.current_phase.pk
|
||||||
|
# Optionally hide if we're in a phase-specific context
|
||||||
|
else:
|
||||||
|
self.fields["pdca_phase"].queryset = PDCAPhase.objects.none()
|
||||||
|
self.fields["pdca_phase"].widget = forms.HiddenInput()
|
||||||
|
|
||||||
# Filter assigned_to choices based on project hospital
|
# Filter assigned_to choices based on project hospital
|
||||||
if self.project and self.project.hospital:
|
if self.project and self.project.hospital:
|
||||||
self.fields["assigned_to"].queryset = User.objects.filter(
|
self.fields["assigned_to"].queryset = User.objects.filter(
|
||||||
@ -368,7 +384,7 @@ class ConvertToProjectForm(forms.Form):
|
|||||||
# Inline formset for task templates (used with QIProject templates)
|
# Inline formset for task templates (used with QIProject templates)
|
||||||
class TaskTemplateForm(forms.ModelForm):
|
class TaskTemplateForm(forms.ModelForm):
|
||||||
"""Simplified form for task templates (no project field needed)"""
|
"""Simplified form for task templates (no project field needed)"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = QIProjectTask
|
model = QIProjectTask
|
||||||
fields = ["title", "description"]
|
fields = ["title", "description"]
|
||||||
|
|||||||
@ -4,15 +4,24 @@ Projects models - Quality Improvement (QI) projects tracking
|
|||||||
This module implements QI project management:
|
This module implements QI project management:
|
||||||
- Project tracking
|
- Project tracking
|
||||||
- Task management
|
- Task management
|
||||||
|
- PDCA cycle phases
|
||||||
- Milestone tracking
|
- Milestone tracking
|
||||||
- Outcome measurement
|
- Outcome measurement
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from apps.core.models import StatusChoices, TimeStampedModel, UUIDModel
|
from apps.core.models import StatusChoices, TimeStampedModel, UUIDModel
|
||||||
|
|
||||||
|
|
||||||
|
class PDCAPhaseChoices(models.TextChoices):
|
||||||
|
PLAN = "plan", _("Plan")
|
||||||
|
DO = "do", _("Do")
|
||||||
|
CHECK = "check", _("Check")
|
||||||
|
ACT = "act", _("Act")
|
||||||
|
|
||||||
|
|
||||||
class QIProject(UUIDModel, TimeStampedModel):
|
class QIProject(UUIDModel, TimeStampedModel):
|
||||||
"""
|
"""
|
||||||
Quality Improvement Project.
|
Quality Improvement Project.
|
||||||
@ -95,6 +104,13 @@ class QIProjectTask(UUIDModel, TimeStampedModel):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
project = models.ForeignKey(QIProject, on_delete=models.CASCADE, related_name="tasks")
|
project = models.ForeignKey(QIProject, on_delete=models.CASCADE, related_name="tasks")
|
||||||
|
pdca_phase = models.ForeignKey(
|
||||||
|
"PDCAPhase",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="tasks",
|
||||||
|
)
|
||||||
|
|
||||||
title = models.CharField(max_length=500)
|
title = models.CharField(max_length=500)
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
@ -119,3 +135,41 @@ class QIProjectTask(UUIDModel, TimeStampedModel):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.project.name} - {self.title}"
|
return f"{self.project.name} - {self.title}"
|
||||||
|
|
||||||
|
|
||||||
|
class PDCAPhase(UUIDModel, TimeStampedModel):
|
||||||
|
"""
|
||||||
|
PDCA (Plan-Do-Check-Act) phase within a QI project.
|
||||||
|
Each project can have up to 4 phases, one per PDCA stage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
project = models.ForeignKey(QIProject, on_delete=models.CASCADE, related_name="pdca_phases")
|
||||||
|
phase = models.CharField(max_length=10, choices=PDCAPhaseChoices.choices)
|
||||||
|
|
||||||
|
title = models.CharField(max_length=300, blank=True)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
"accounts.User",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="owned_pdca_phases",
|
||||||
|
)
|
||||||
|
|
||||||
|
status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.PENDING)
|
||||||
|
|
||||||
|
start_date = models.DateField(null=True, blank=True)
|
||||||
|
due_date = models.DateField(null=True, blank=True)
|
||||||
|
completed_date = models.DateField(null=True, blank=True)
|
||||||
|
|
||||||
|
findings = models.TextField(blank=True)
|
||||||
|
|
||||||
|
order = models.IntegerField(default=0)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = [("project", "phase")]
|
||||||
|
ordering = ["project", "order"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.project.name} - {self.get_phase_display()}"
|
||||||
|
|||||||
@ -13,11 +13,12 @@ from django.shortcuts import get_object_or_404, redirect, render
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from apps.core.decorators import block_source_user
|
from apps.core.decorators import block_source_user
|
||||||
|
from apps.core.models import StatusChoices
|
||||||
from apps.organizations.models import Hospital
|
from apps.organizations.models import Hospital
|
||||||
from apps.px_action_center.models import PXAction
|
from apps.px_action_center.models import PXAction
|
||||||
|
|
||||||
from .forms import ConvertToProjectForm, QIProjectForm, QIProjectTaskForm, QIProjectTemplateForm, TaskTemplateFormSet
|
from .forms import ConvertToProjectForm, QIProjectForm, QIProjectTaskForm, QIProjectTemplateForm, TaskTemplateFormSet
|
||||||
from .models import QIProject, QIProjectTask
|
from .models import QIProject, QIProjectTask, PDCAPhase, PDCAPhaseChoices
|
||||||
|
|
||||||
|
|
||||||
@block_source_user
|
@block_source_user
|
||||||
@ -100,7 +101,7 @@ def project_detail(request, pk):
|
|||||||
project = get_object_or_404(
|
project = get_object_or_404(
|
||||||
QIProject.objects.filter(is_template=False)
|
QIProject.objects.filter(is_template=False)
|
||||||
.select_related("hospital", "department", "project_lead")
|
.select_related("hospital", "department", "project_lead")
|
||||||
.prefetch_related("team_members", "related_actions", "tasks"),
|
.prefetch_related("team_members", "related_actions", "tasks", "pdca_phases"),
|
||||||
pk=pk,
|
pk=pk,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -110,16 +111,17 @@ def project_detail(request, pk):
|
|||||||
messages.error(request, _("You don't have permission to view this project."))
|
messages.error(request, _("You don't have permission to view this project."))
|
||||||
return redirect("projects:project_list")
|
return redirect("projects:project_list")
|
||||||
|
|
||||||
# Get tasks
|
|
||||||
tasks = project.tasks.all().order_by("order", "created_at")
|
|
||||||
|
|
||||||
# Get related actions
|
# Get related actions
|
||||||
related_actions = project.related_actions.all()
|
related_actions = project.related_actions.all()
|
||||||
|
|
||||||
|
# Get PDCA phases
|
||||||
|
pdca_phases = {phase.phase: phase for phase in project.pdca_phases.all()}
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"project": project,
|
"project": project,
|
||||||
"tasks": tasks,
|
|
||||||
"related_actions": related_actions,
|
"related_actions": related_actions,
|
||||||
|
"pdca_phases": pdca_phases,
|
||||||
|
"pdca_phase_choices": PDCAPhaseChoices.choices,
|
||||||
"can_edit": user.is_px_admin() or user.is_hospital_admin or user.is_department_manager,
|
"can_edit": user.is_px_admin() or user.is_hospital_admin or user.is_department_manager,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -362,12 +364,22 @@ def project_save_as_template(request, pk):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _task_redirect(project, task):
|
||||||
|
"""Helper to redirect to phase detail if task belongs to a phase, else project detail."""
|
||||||
|
if task and task.pdca_phase:
|
||||||
|
return redirect("projects:pdca_phase_detail", pk=project.pk, phase=task.pdca_phase.phase)
|
||||||
|
return redirect("projects:project_detail", pk=project.pk)
|
||||||
|
|
||||||
|
|
||||||
@block_source_user
|
@block_source_user
|
||||||
@login_required
|
@login_required
|
||||||
def task_create(request, project_pk):
|
def task_create(request, project_pk, phase=None):
|
||||||
"""Add a new task to a project"""
|
"""Add a new task to a project (optionally within a PDCA phase)"""
|
||||||
user = request.user
|
user = request.user
|
||||||
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
|
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
|
||||||
|
current_phase = None
|
||||||
|
if phase and phase in [c[0] for c in PDCAPhaseChoices.choices]:
|
||||||
|
current_phase = get_object_or_404(PDCAPhase, phase=phase, project=project)
|
||||||
|
|
||||||
# Check permission
|
# Check permission
|
||||||
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
|
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
|
||||||
@ -375,19 +387,20 @@ def task_create(request, project_pk):
|
|||||||
return redirect("projects:project_detail", pk=project.pk)
|
return redirect("projects:project_detail", pk=project.pk)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = QIProjectTaskForm(request.POST, project=project)
|
form = QIProjectTaskForm(request.POST, project=project, current_phase=current_phase)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
task = form.save(commit=False)
|
task = form.save(commit=False)
|
||||||
task.project = project
|
task.project = project
|
||||||
task.save()
|
task.save()
|
||||||
messages.success(request, _("Task added successfully."))
|
messages.success(request, _("Task added successfully."))
|
||||||
return redirect("projects:project_detail", pk=project.pk)
|
return _task_redirect(project, task)
|
||||||
else:
|
else:
|
||||||
form = QIProjectTaskForm(project=project)
|
form = QIProjectTaskForm(project=project, current_phase=current_phase)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"form": form,
|
"form": form,
|
||||||
"project": project,
|
"project": project,
|
||||||
|
"pdca_phase": current_phase,
|
||||||
"is_create": True,
|
"is_create": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -396,11 +409,14 @@ def task_create(request, project_pk):
|
|||||||
|
|
||||||
@block_source_user
|
@block_source_user
|
||||||
@login_required
|
@login_required
|
||||||
def task_edit(request, project_pk, task_pk):
|
def task_edit(request, project_pk, task_pk, phase=None):
|
||||||
"""Edit an existing task"""
|
"""Edit an existing task"""
|
||||||
user = request.user
|
user = request.user
|
||||||
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
|
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
|
||||||
task = get_object_or_404(QIProjectTask, pk=task_pk, project=project)
|
task = get_object_or_404(QIProjectTask, pk=task_pk, project=project)
|
||||||
|
current_phase = None
|
||||||
|
if phase and phase in [c[0] for c in PDCAPhaseChoices.choices]:
|
||||||
|
current_phase = get_object_or_404(PDCAPhase, phase=phase, project=project)
|
||||||
|
|
||||||
# Check permission
|
# Check permission
|
||||||
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
|
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
|
||||||
@ -408,7 +424,7 @@ def task_edit(request, project_pk, task_pk):
|
|||||||
return redirect("projects:project_detail", pk=project.pk)
|
return redirect("projects:project_detail", pk=project.pk)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = QIProjectTaskForm(request.POST, instance=task, project=project)
|
form = QIProjectTaskForm(request.POST, instance=task, project=project, current_phase=current_phase)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
task = form.save()
|
task = form.save()
|
||||||
# If status changed to completed, set completed_date
|
# If status changed to completed, set completed_date
|
||||||
@ -418,14 +434,15 @@ def task_edit(request, project_pk, task_pk):
|
|||||||
task.completed_date = timezone.now().date()
|
task.completed_date = timezone.now().date()
|
||||||
task.save()
|
task.save()
|
||||||
messages.success(request, _("Task updated successfully."))
|
messages.success(request, _("Task updated successfully."))
|
||||||
return redirect("projects:project_detail", pk=project.pk)
|
return _task_redirect(project, task)
|
||||||
else:
|
else:
|
||||||
form = QIProjectTaskForm(instance=task, project=project)
|
form = QIProjectTaskForm(instance=task, project=project, current_phase=current_phase)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"form": form,
|
"form": form,
|
||||||
"project": project,
|
"project": project,
|
||||||
"task": task,
|
"task": task,
|
||||||
|
"pdca_phase": current_phase,
|
||||||
"is_create": False,
|
"is_create": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -434,7 +451,7 @@ def task_edit(request, project_pk, task_pk):
|
|||||||
|
|
||||||
@block_source_user
|
@block_source_user
|
||||||
@login_required
|
@login_required
|
||||||
def task_delete(request, project_pk, task_pk):
|
def task_delete(request, project_pk, task_pk, phase=None):
|
||||||
"""Delete a task"""
|
"""Delete a task"""
|
||||||
user = request.user
|
user = request.user
|
||||||
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
|
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
|
||||||
@ -445,14 +462,17 @@ def task_delete(request, project_pk, task_pk):
|
|||||||
messages.error(request, _("You don't have permission to delete tasks in this project."))
|
messages.error(request, _("You don't have permission to delete tasks in this project."))
|
||||||
return redirect("projects:project_detail", pk=project.pk)
|
return redirect("projects:project_detail", pk=project.pk)
|
||||||
|
|
||||||
|
redirect_target = _task_redirect(project, task)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
task.delete()
|
task.delete()
|
||||||
messages.success(request, _("Task deleted successfully."))
|
messages.success(request, _("Task deleted successfully."))
|
||||||
return redirect("projects:project_detail", pk=project.pk)
|
return redirect_target
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"project": project,
|
"project": project,
|
||||||
"task": task,
|
"task": task,
|
||||||
|
"pdca_phase": task.pdca_phase,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, "projects/task_delete_confirm.html", context)
|
return render(request, "projects/task_delete_confirm.html", context)
|
||||||
@ -460,7 +480,7 @@ def task_delete(request, project_pk, task_pk):
|
|||||||
|
|
||||||
@block_source_user
|
@block_source_user
|
||||||
@login_required
|
@login_required
|
||||||
def task_toggle_status(request, project_pk, task_pk):
|
def task_toggle_status(request, project_pk, task_pk, phase=None):
|
||||||
"""Quick toggle task status between pending and completed"""
|
"""Quick toggle task status between pending and completed"""
|
||||||
user = request.user
|
user = request.user
|
||||||
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
|
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
|
||||||
@ -482,7 +502,7 @@ def task_toggle_status(request, project_pk, task_pk):
|
|||||||
|
|
||||||
task.save()
|
task.save()
|
||||||
messages.success(request, _("Task status updated."))
|
messages.success(request, _("Task status updated."))
|
||||||
return redirect("projects:project_detail", pk=project.pk)
|
return _task_redirect(project, task)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@ -581,20 +601,20 @@ def template_create(request):
|
|||||||
template.is_template = True
|
template.is_template = True
|
||||||
template.created_by = user
|
template.created_by = user
|
||||||
template.save()
|
template.save()
|
||||||
|
|
||||||
# Save task templates formset
|
# Save task templates formset
|
||||||
formset = TaskTemplateFormSet(request.POST, instance=template, prefix='tasktemplate_set')
|
formset = TaskTemplateFormSet(request.POST, instance=template, prefix="tasktemplate_set")
|
||||||
if formset.is_valid():
|
if formset.is_valid():
|
||||||
formset.save()
|
formset.save()
|
||||||
|
|
||||||
messages.success(request, _("Project template created successfully."))
|
messages.success(request, _("Project template created successfully."))
|
||||||
return redirect("projects:template_list")
|
return redirect("projects:template_list")
|
||||||
else:
|
else:
|
||||||
# Form is invalid, show formset with errors
|
# Form is invalid, show formset with errors
|
||||||
formset = TaskTemplateFormSet(request.POST, prefix='tasktemplate_set')
|
formset = TaskTemplateFormSet(request.POST, prefix="tasktemplate_set")
|
||||||
else:
|
else:
|
||||||
form = QIProjectTemplateForm(request=request)
|
form = QIProjectTemplateForm(request=request)
|
||||||
formset = TaskTemplateFormSet(prefix='tasktemplate_set')
|
formset = TaskTemplateFormSet(prefix="tasktemplate_set")
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"form": form,
|
"form": form,
|
||||||
@ -620,7 +640,7 @@ def template_edit(request, pk):
|
|||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = QIProjectTemplateForm(request.POST, instance=template, request=request)
|
form = QIProjectTemplateForm(request.POST, instance=template, request=request)
|
||||||
formset = TaskTemplateFormSet(request.POST, instance=template, prefix='tasktemplate_set')
|
formset = TaskTemplateFormSet(request.POST, instance=template, prefix="tasktemplate_set")
|
||||||
if form.is_valid() and formset.is_valid():
|
if form.is_valid() and formset.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
formset.save()
|
formset.save()
|
||||||
@ -628,7 +648,7 @@ def template_edit(request, pk):
|
|||||||
return redirect("projects:template_list")
|
return redirect("projects:template_list")
|
||||||
else:
|
else:
|
||||||
form = QIProjectTemplateForm(instance=template, request=request)
|
form = QIProjectTemplateForm(instance=template, request=request)
|
||||||
formset = TaskTemplateFormSet(instance=template, prefix='tasktemplate_set')
|
formset = TaskTemplateFormSet(instance=template, prefix="tasktemplate_set")
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"form": form,
|
"form": form,
|
||||||
@ -746,3 +766,136 @@ def convert_action_to_project(request, action_pk):
|
|||||||
}
|
}
|
||||||
|
|
||||||
return render(request, "projects/convert_action.html", context)
|
return render(request, "projects/convert_action.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@block_source_user
|
||||||
|
@login_required
|
||||||
|
def pdca_phase_detail(request, pk, phase):
|
||||||
|
"""Detail view for a PDCA phase, showing phase info and its tasks"""
|
||||||
|
if phase not in [c[0] for c in PDCAPhaseChoices.choices]:
|
||||||
|
messages.error(request, _("Invalid PDCA phase."))
|
||||||
|
return redirect("projects:project_detail", pk=pk)
|
||||||
|
|
||||||
|
project = get_object_or_404(QIProject, pk=pk, is_template=False)
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
|
||||||
|
messages.error(request, _("You don't have permission."))
|
||||||
|
return redirect("projects:project_list")
|
||||||
|
|
||||||
|
pdca_phase, created = PDCAPhase.objects.get_or_create(
|
||||||
|
project=project,
|
||||||
|
phase=phase,
|
||||||
|
defaults={"title": PDCAPhaseChoices(phase).label},
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks = pdca_phase.tasks.all().order_by("order", "created_at")
|
||||||
|
can_edit = user.is_px_admin() or user.is_hospital_admin or user.is_department_manager
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
phase_task_counts = {p.phase: p.tasks.count() for p in project.pdca_phases.prefetch_related("tasks").all()}
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"project": project,
|
||||||
|
"pdca_phase": pdca_phase,
|
||||||
|
"phase_key": phase,
|
||||||
|
"phase_label": PDCAPhaseChoices(phase).label,
|
||||||
|
"tasks": tasks,
|
||||||
|
"can_edit": can_edit,
|
||||||
|
"today": timezone.now().date(),
|
||||||
|
"pdca_phase_choices": PDCAPhaseChoices.choices,
|
||||||
|
"phase_task_counts": phase_task_counts,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, "projects/pdca_phase_detail.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@block_source_user
|
||||||
|
@login_required
|
||||||
|
def pdca_phase_edit(request, pk, phase):
|
||||||
|
"""Edit a PDCA phase for a QI project"""
|
||||||
|
if phase not in [c[0] for c in PDCAPhaseChoices.choices]:
|
||||||
|
messages.error(request, _("Invalid PDCA phase."))
|
||||||
|
return redirect("projects:project_detail", pk=pk)
|
||||||
|
|
||||||
|
project = get_object_or_404(QIProject, pk=pk, is_template=False)
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
|
||||||
|
messages.error(request, _("You don't have permission."))
|
||||||
|
return redirect("projects:project_list")
|
||||||
|
|
||||||
|
can_edit = user.is_px_admin() or user.is_hospital_admin or user.is_department_manager
|
||||||
|
if not can_edit:
|
||||||
|
messages.error(request, _("You don't have permission to edit this project."))
|
||||||
|
return redirect("projects:pdca_phase_detail", pk=project.pk, phase=phase)
|
||||||
|
|
||||||
|
pdca_phase, created = PDCAPhase.objects.get_or_create(
|
||||||
|
project=project,
|
||||||
|
phase=phase,
|
||||||
|
defaults={"title": PDCAPhaseChoices(phase).label},
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
pdca_phase.title = request.POST.get("title", pdca_phase.title)
|
||||||
|
pdca_phase.description = request.POST.get("description", "")
|
||||||
|
pdca_phase.findings = request.POST.get("findings", "")
|
||||||
|
pdca_phase.status = request.POST.get("status", StatusChoices.PENDING)
|
||||||
|
pdca_phase.start_date = request.POST.get("start_date") or None
|
||||||
|
pdca_phase.due_date = request.POST.get("due_date") or None
|
||||||
|
|
||||||
|
owner_id = request.POST.get("owner")
|
||||||
|
if owner_id:
|
||||||
|
from apps.accounts.models import User
|
||||||
|
|
||||||
|
try:
|
||||||
|
pdca_phase.owner = User.objects.get(pk=owner_id)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
pdca_phase.owner = None
|
||||||
|
|
||||||
|
pdca_phase.save()
|
||||||
|
messages.success(request, _("%(phase)s phase updated successfully.") % {"phase": PDCAPhaseChoices(phase).label})
|
||||||
|
return redirect("projects:pdca_phase_detail", pk=project.pk, phase=phase)
|
||||||
|
|
||||||
|
from apps.accounts.models import User
|
||||||
|
|
||||||
|
team_members = project.team_members.all()
|
||||||
|
if project.project_lead:
|
||||||
|
team_members = team_members | User.objects.filter(pk=project.project_lead.pk)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"project": project,
|
||||||
|
"pdca_phase": pdca_phase,
|
||||||
|
"phase_key": phase,
|
||||||
|
"phase_label": PDCAPhaseChoices(phase).label,
|
||||||
|
"team_members": team_members.distinct(),
|
||||||
|
"status_choices": StatusChoices.choices,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, "projects/pdca_phase_form.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@block_source_user
|
||||||
|
@login_required
|
||||||
|
def project_export_excel(request, pk):
|
||||||
|
"""Export QI project with PDCA data as Excel"""
|
||||||
|
from django.http import HttpResponse
|
||||||
|
|
||||||
|
project = get_object_or_404(QIProject, pk=pk, is_template=False)
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
|
||||||
|
messages.error(request, _("You don't have permission."))
|
||||||
|
return redirect("projects:project_list")
|
||||||
|
|
||||||
|
from .export_utils import export_project_excel
|
||||||
|
|
||||||
|
workbook = export_project_excel(project)
|
||||||
|
response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||||
|
filename = f"QI_Project_{project.name.replace(' ', '_')}_{project.pk}.xlsx"
|
||||||
|
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||||
|
workbook.save(response)
|
||||||
|
return response
|
||||||
|
|||||||
@ -2,33 +2,46 @@ from django.urls import path
|
|||||||
|
|
||||||
from . import ui_views
|
from . import ui_views
|
||||||
|
|
||||||
app_name = 'projects'
|
app_name = "projects"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# QI Project Views
|
# QI Project Views
|
||||||
path('', ui_views.project_list, name='project_list'),
|
path("", ui_views.project_list, name="project_list"),
|
||||||
path('create/', ui_views.project_create, name='project_create'),
|
path("create/", ui_views.project_create, name="project_create"),
|
||||||
path('create/from-template/<uuid:template_pk>/', ui_views.project_create, name='project_create_from_template'),
|
path("create/from-template/<uuid:template_pk>/", ui_views.project_create, name="project_create_from_template"),
|
||||||
path('<uuid:pk>/', ui_views.project_detail, name='project_detail'),
|
path("<uuid:pk>/", ui_views.project_detail, name="project_detail"),
|
||||||
path('<uuid:pk>/edit/', ui_views.project_edit, name='project_edit'),
|
path("<uuid:pk>/edit/", ui_views.project_edit, name="project_edit"),
|
||||||
path('<uuid:pk>/delete/', ui_views.project_delete, name='project_delete'),
|
path("<uuid:pk>/delete/", ui_views.project_delete, name="project_delete"),
|
||||||
|
# Task Management (legacy project-level + new phase-level)
|
||||||
# Task Management
|
path("<uuid:project_pk>/tasks/add/", ui_views.task_create, name="task_create"),
|
||||||
path('<uuid:project_pk>/tasks/add/', ui_views.task_create, name='task_create'),
|
path("<uuid:project_pk>/tasks/<uuid:task_pk>/edit/", ui_views.task_edit, name="task_edit"),
|
||||||
path('<uuid:project_pk>/tasks/<uuid:task_pk>/edit/', ui_views.task_edit, name='task_edit'),
|
path("<uuid:project_pk>/tasks/<uuid:task_pk>/delete/", ui_views.task_delete, name="task_delete"),
|
||||||
path('<uuid:project_pk>/tasks/<uuid:task_pk>/delete/', ui_views.task_delete, name='task_delete'),
|
path("<uuid:project_pk>/tasks/<uuid:task_pk>/toggle/", ui_views.task_toggle_status, name="task_toggle_status"),
|
||||||
path('<uuid:project_pk>/tasks/<uuid:task_pk>/toggle/', ui_views.task_toggle_status, name='task_toggle_status'),
|
# PDCA Phase Management
|
||||||
|
path("<uuid:pk>/pdca/<str:phase>/", ui_views.pdca_phase_detail, name="pdca_phase_detail"),
|
||||||
|
path("<uuid:pk>/pdca/<str:phase>/edit/", ui_views.pdca_phase_edit, name="pdca_phase_edit"),
|
||||||
|
path("<uuid:project_pk>/pdca/<str:phase>/tasks/add/", ui_views.task_create, name="phase_task_create"),
|
||||||
|
path("<uuid:project_pk>/pdca/<str:phase>/tasks/<uuid:task_pk>/edit/", ui_views.task_edit, name="phase_task_edit"),
|
||||||
|
path(
|
||||||
|
"<uuid:project_pk>/pdca/<str:phase>/tasks/<uuid:task_pk>/delete/",
|
||||||
|
ui_views.task_delete,
|
||||||
|
name="phase_task_delete",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"<uuid:project_pk>/pdca/<str:phase>/tasks/<uuid:task_pk>/toggle/",
|
||||||
|
ui_views.task_toggle_status,
|
||||||
|
name="phase_task_toggle_status",
|
||||||
|
),
|
||||||
# Template Management
|
# Template Management
|
||||||
path('templates/', ui_views.template_list, name='template_list'),
|
path("templates/", ui_views.template_list, name="template_list"),
|
||||||
path('templates/create/', ui_views.template_create, name='template_create'),
|
path("templates/create/", ui_views.template_create, name="template_create"),
|
||||||
path('templates/<uuid:pk>/', ui_views.template_detail, name='template_detail'),
|
path("templates/<uuid:pk>/", ui_views.template_detail, name="template_detail"),
|
||||||
path('templates/<uuid:pk>/edit/', ui_views.template_edit, name='template_edit'),
|
path("templates/<uuid:pk>/edit/", ui_views.template_edit, name="template_edit"),
|
||||||
path('templates/<uuid:pk>/delete/', ui_views.template_delete, name='template_delete'),
|
path("templates/<uuid:pk>/delete/", ui_views.template_delete, name="template_delete"),
|
||||||
|
|
||||||
# Save Project as Template
|
# Save Project as Template
|
||||||
path('<uuid:pk>/save-as-template/', ui_views.project_save_as_template, name='project_save_as_template'),
|
path("<uuid:pk>/save-as-template/", ui_views.project_save_as_template, name="project_save_as_template"),
|
||||||
|
# Export
|
||||||
|
path("<uuid:pk>/export/excel/", ui_views.project_export_excel, name="project_export_excel"),
|
||||||
# PX Action Conversion
|
# PX Action Conversion
|
||||||
path('convert-action/<uuid:action_pk>/', ui_views.convert_action_to_project, name='convert_action'),
|
path("convert-action/<uuid:action_pk>/", ui_views.convert_action_to_project, name="convert_action"),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -326,7 +326,7 @@ class RoutingRule(UUIDModel, TimeStampedModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Routing target
|
# Routing target
|
||||||
assign_to_role = models.CharField(max_length=50, blank=True, help_text="Role to assign to (e.g., 'PX Coordinator')")
|
assign_to_role = models.CharField(max_length=50, blank=True, help_text="Role to assign to (e.g., 'PX Staff')")
|
||||||
assign_to_user = models.ForeignKey(
|
assign_to_user = models.ForeignKey(
|
||||||
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="routing_rules"
|
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="routing_rules"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -553,7 +553,7 @@ def action_create_from_ai(request, complaint_id):
|
|||||||
|
|
||||||
# Check permission
|
# Check permission
|
||||||
user = request.user
|
user = request.user
|
||||||
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_coordinator()):
|
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_staff()):
|
||||||
return JsonResponse({"success": False, "error": "You do not have permission to create actions."}, status=403)
|
return JsonResponse({"success": False, "error": "You do not have permission to create actions."}, status=403)
|
||||||
|
|
||||||
# Get action data from POST
|
# Get action data from POST
|
||||||
@ -651,7 +651,7 @@ def action_create_from_observation_ai(request, observation_id):
|
|||||||
observation = get_object_or_404(Observation, id=observation_id)
|
observation = get_object_or_404(Observation, id=observation_id)
|
||||||
|
|
||||||
user = request.user
|
user = request.user
|
||||||
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_coordinator()):
|
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_staff()):
|
||||||
return JsonResponse({"success": False, "error": "You do not have permission to create actions."}, status=403)
|
return JsonResponse({"success": False, "error": "You do not have permission to create actions."}, status=403)
|
||||||
|
|
||||||
action_text = request.POST.get("action", "")
|
action_text = request.POST.get("action", "")
|
||||||
|
|||||||
@ -28,7 +28,7 @@ class PXActionViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
Permissions:
|
Permissions:
|
||||||
- All authenticated users can view actions
|
- All authenticated users can view actions
|
||||||
- PX Admins and PX Coordinators can create/manage actions
|
- PX Admins and PX Staff can create/manage actions
|
||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = PXAction.objects.all()
|
queryset = PXAction.objects.all()
|
||||||
|
|||||||
363
apps/social/setup_docs copy/google_business.md
Normal file
363
apps/social/setup_docs copy/google_business.md
Normal file
@ -0,0 +1,363 @@
|
|||||||
|
# Google Business Profile API Setup Guide
|
||||||
|
|
||||||
|
This guide provides step-by-step instructions for setting up Google Business Profile (formerly Google My Business) API integration for managing reviews.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Overview](#overview)
|
||||||
|
2. [Prerequisites](#prerequisites)
|
||||||
|
3. [Google Cloud Console Setup](#google-cloud-console-setup)
|
||||||
|
4. [Environment Configuration](#environment-configuration)
|
||||||
|
5. [OAuth Redirect URI Configuration](#oauth-redirect-uri-configuration)
|
||||||
|
6. [Permissions & Scopes](#permissions--scopes)
|
||||||
|
7. [Development vs Production](#development-vs-production)
|
||||||
|
8. [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**API Version:** My Business API v4 / Account Management v1
|
||||||
|
**Base URL:** Google API Services
|
||||||
|
**Auth Method:** OAuth 2.0
|
||||||
|
|
||||||
|
### Features Supported
|
||||||
|
- Fetch business locations
|
||||||
|
- Read Google reviews for locations
|
||||||
|
- Reply to reviews as the business owner
|
||||||
|
- Monitor review ratings and feedback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- A Google account with owner/manager access to a Google Business Profile
|
||||||
|
- Access to [Google Cloud Console](https://console.cloud.google.com/)
|
||||||
|
- A verified business location on Google Maps
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Google Cloud Console Setup
|
||||||
|
|
||||||
|
### Step 1: Create a New Project
|
||||||
|
|
||||||
|
1. Navigate to [Google Cloud Console](https://console.cloud.google.com/)
|
||||||
|
2. Click on the project selector dropdown at the top
|
||||||
|
3. Click **"New Project"**
|
||||||
|
4. Enter project details:
|
||||||
|
- **Project Name:** e.g., "PX360 Social Integration"
|
||||||
|
- **Organization:** Select your organization (if applicable)
|
||||||
|
5. Click **"Create"**
|
||||||
|
6. Select your new project
|
||||||
|
|
||||||
|
### Step 2: Enable Required APIs
|
||||||
|
|
||||||
|
1. Go to **"APIs & Services"** → **"Library"**
|
||||||
|
2. Search for and enable the following APIs:
|
||||||
|
- **Google My Business API** (Note: May require verification)
|
||||||
|
- **My Business Account Management API**
|
||||||
|
- **My Business Business Information API**
|
||||||
|
|
||||||
|
> ⚠️ **Important:** Google My Business API requires approval from Google. You may need to fill out a form explaining your use case.
|
||||||
|
|
||||||
|
### Step 3: Configure OAuth Consent Screen
|
||||||
|
|
||||||
|
1. Go to **"APIs & Services"** → **"OAuth consent screen"**
|
||||||
|
2. Select **"External"** user type (unless you have a Google Workspace account)
|
||||||
|
3. Click **"Create"**
|
||||||
|
4. Fill in the required fields:
|
||||||
|
- **App Name:** Your application name
|
||||||
|
- **User Support Email:** Your support email
|
||||||
|
- **App Logo:** Upload your logo
|
||||||
|
- **Application Home Page:** Your website URL
|
||||||
|
- **Authorized Domains:** Your domain(s)
|
||||||
|
- **Developer Contact Email:** Your email
|
||||||
|
5. Click **"Save and Continue"**
|
||||||
|
6. Add scopes (click "Add or Remove Scopes"):
|
||||||
|
- `https://www.googleapis.com/auth/business.manage`
|
||||||
|
7. Click **"Save and Continue"**
|
||||||
|
8. Add test users (for development)
|
||||||
|
9. Click **"Save and Continue"**
|
||||||
|
|
||||||
|
### Step 4: Create OAuth 2.0 Credentials
|
||||||
|
|
||||||
|
1. Go to **"APIs & Services"** → **"Credentials"**
|
||||||
|
2. Click **"Create Credentials"** → **"OAuth client ID"**
|
||||||
|
3. Select **"Web application"**
|
||||||
|
4. Configure:
|
||||||
|
- **Name:** e.g., "PX360 Web Client"
|
||||||
|
- **Authorized JavaScript origins:**
|
||||||
|
- Development: `http://127.0.0.1:8000`
|
||||||
|
- Production: `https://yourdomain.com`
|
||||||
|
- **Authorized redirect URIs:**
|
||||||
|
- Development: `http://127.0.0.1:8000/social/callback/GO/`
|
||||||
|
- Production: `https://yourdomain.com/social/callback/GO/`
|
||||||
|
5. Click **"Create"**
|
||||||
|
6. **Download the JSON file** - This is your credentials file
|
||||||
|
|
||||||
|
### Step 5: Save Credentials File
|
||||||
|
|
||||||
|
1. Rename the downloaded JSON file to `gmb_client_secrets.json`
|
||||||
|
2. Place it in your project's `secrets/` directory:
|
||||||
|
```
|
||||||
|
your_project/
|
||||||
|
├── secrets/
|
||||||
|
│ └── gmb_client_secrets.json
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
The JSON file structure should look like:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"web": {
|
||||||
|
"client_id": "xxxxx.apps.googleusercontent.com",
|
||||||
|
"project_id": "your-project-id",
|
||||||
|
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||||
|
"token_uri": "https://oauth2.googleapis.com/token",
|
||||||
|
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||||
|
"client_secret": "your-client-secret",
|
||||||
|
"redirect_uris": ["http://127.0.0.1:8000/social/callback/GO/"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
### Django Settings (settings.py)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Google Business Profile API Configuration
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||||
|
|
||||||
|
# Google My Business (Reviews) Configuration
|
||||||
|
GMB_CLIENT_SECRETS_FILE = BASE_DIR / 'secrets' / 'gmb_client_secrets.json'
|
||||||
|
GMB_REDIRECT_URI = 'https://yourdomain.com/social/callback/GO/'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables (.env)
|
||||||
|
|
||||||
|
While the credentials are in a JSON file, you can set the redirect URI via environment:
|
||||||
|
|
||||||
|
```env
|
||||||
|
GMB_REDIRECT_URI=https://yourdomain.com/social/callback/GO/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OAuth Redirect URI Configuration
|
||||||
|
|
||||||
|
The redirect URI must match exactly what's configured in Google Cloud Console.
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```
|
||||||
|
http://127.0.0.1:8000/social/callback/GO/
|
||||||
|
http://localhost:8000/social/callback/GO/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
```
|
||||||
|
https://yourdomain.com/social/callback/GO/
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **Note:** Google accepts both HTTP and HTTPS for `localhost`/`127.0.0.1`, but production must use HTTPS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permissions & Scopes
|
||||||
|
|
||||||
|
The application requires the following OAuth scope:
|
||||||
|
|
||||||
|
| Scope | Description | Required |
|
||||||
|
|-------|-------------|----------|
|
||||||
|
| `https://www.googleapis.com/auth/business.manage` | Full access to manage business listings and reviews | ✅ Yes |
|
||||||
|
|
||||||
|
### Code Reference
|
||||||
|
|
||||||
|
```python
|
||||||
|
# apps/social/utils/google.py
|
||||||
|
SCOPES = ['https://www.googleapis.com/auth/business.manage']
|
||||||
|
|
||||||
|
API_VERSION_MYBUSINESS = 'v4'
|
||||||
|
API_VERSION_ACCOUNT_MGMT = 'v1'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development vs Production
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| `GMB_REDIRECT_URI` | `http://127.0.0.1:8000/social/callback/GO/` |
|
||||||
|
| Protocol | HTTP allowed for localhost |
|
||||||
|
| App Verification | Not required for testing |
|
||||||
|
| User Access | Only added test users |
|
||||||
|
|
||||||
|
### Production Setup
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| `GMB_REDIRECT_URI` | `https://yourdomain.com/social/callback/GO/` |
|
||||||
|
| Protocol | **HTTPS required** |
|
||||||
|
| App Verification | **Required** by Google |
|
||||||
|
| User Access | Any Google account |
|
||||||
|
|
||||||
|
### Google App Verification
|
||||||
|
|
||||||
|
For production, if your app requests sensitive scopes, you may need to go through Google's verification process:
|
||||||
|
|
||||||
|
1. Submit your app for verification in Google Cloud Console
|
||||||
|
2. Provide a demo video showing how the integration works
|
||||||
|
3. Wait for Google's review (can take several days to weeks)
|
||||||
|
|
||||||
|
**Alternative:** Use a service account for internal business use (no verification needed if only accessing your own business data).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Service Account Alternative (Recommended for Internal Use)
|
||||||
|
|
||||||
|
If you're only managing your own business locations, consider using a Service Account:
|
||||||
|
|
||||||
|
### Step 1: Create Service Account
|
||||||
|
|
||||||
|
1. Go to **"IAM & Admin"** → **"Service Accounts"**
|
||||||
|
2. Click **"Create Service Account"**
|
||||||
|
3. Enter name and description
|
||||||
|
4. Click **"Create and Continue"**
|
||||||
|
5. Skip optional steps
|
||||||
|
6. Click **"Done"**
|
||||||
|
|
||||||
|
### Step 2: Create Key
|
||||||
|
|
||||||
|
1. Click on the created service account
|
||||||
|
2. Go to **"Keys"** tab
|
||||||
|
3. Click **"Add Key"** → **"Create new key"**
|
||||||
|
4. Select **"JSON"**
|
||||||
|
5. Click **"Create"**
|
||||||
|
6. Save the JSON file securely
|
||||||
|
|
||||||
|
### Step 3: Grant Business Access
|
||||||
|
|
||||||
|
1. Go to [Google Business Profile Manager](https://business.google.com/)
|
||||||
|
2. Select your business
|
||||||
|
3. Go to **"Users"** → **"Add users"**
|
||||||
|
4. Add the service account email (found in the JSON file)
|
||||||
|
5. Grant appropriate access level (Owner or Manager)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Error: "Access Denied - Requested client not authorized"
|
||||||
|
|
||||||
|
**Cause:** OAuth consent screen not configured or app not verified.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Ensure OAuth consent screen is properly configured
|
||||||
|
2. Add user as a test user if app is in testing mode
|
||||||
|
3. Submit app for verification if needed for production
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "Invalid Grant"
|
||||||
|
|
||||||
|
**Cause:** Authorization code expired or already used.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Authorization codes are single-use and expire quickly
|
||||||
|
- Ensure your code handles the callback immediately
|
||||||
|
- Check that redirect URI matches exactly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "The caller does not have permission"
|
||||||
|
|
||||||
|
**Cause:** User doesn't have access to the business location.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Verify user is an Owner or Manager of the Google Business Profile
|
||||||
|
2. Check business account permissions at business.google.com
|
||||||
|
3. Ensure the correct account is selected during OAuth
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "API Not Enabled"
|
||||||
|
|
||||||
|
**Cause:** Required APIs not enabled in Google Cloud Console.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Go to APIs & Services → Library
|
||||||
|
2. Enable: Google My Business API
|
||||||
|
3. Enable: My Business Account Management API
|
||||||
|
4. Wait a few minutes for changes to propagate
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "Token Refresh Failed"
|
||||||
|
|
||||||
|
**Cause:** Refresh token expired or revoked.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Google OAuth tokens expire after 6 months of inactivity
|
||||||
|
- User must re-authenticate
|
||||||
|
- Ensure `offline_access` is requested during initial auth
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "Quota Exceeded"
|
||||||
|
|
||||||
|
**Cause:** API quota limit reached.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Default quota: varies by API method
|
||||||
|
- Request higher quota in Google Cloud Console
|
||||||
|
- Implement rate limiting in your application
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Quotas & Limits
|
||||||
|
|
||||||
|
| Resource | Default Limit |
|
||||||
|
|----------|---------------|
|
||||||
|
| Read Requests | 150 per minute |
|
||||||
|
| Write Requests | 50 per minute |
|
||||||
|
| Locations per Account | 10,000 |
|
||||||
|
|
||||||
|
The application implements rate limiting to stay within these bounds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After setup, verify the integration:
|
||||||
|
|
||||||
|
1. Ensure `gmb_client_secrets.json` is in place
|
||||||
|
2. Navigate to `/social/` in your application
|
||||||
|
3. Click "Connect Google Business"
|
||||||
|
4. Authorize with your Google account
|
||||||
|
5. Select your business location
|
||||||
|
6. Verify reviews are fetched
|
||||||
|
7. Test replying to a review
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support Resources
|
||||||
|
|
||||||
|
- [Google Business Profile API Documentation](https://developers.google.com/my-business)
|
||||||
|
- [OAuth 2.0 for Web Server Applications](https://developers.google.com/identity/protocols/oauth2/web-server)
|
||||||
|
- [Google Cloud Console Support](https://support.google.com/cloud/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last Updated: February 2026*
|
||||||
|
*API Version: My Business v4 / Account Management v1*
|
||||||
308
apps/social/setup_docs copy/linkedin.md
Normal file
308
apps/social/setup_docs copy/linkedin.md
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
# LinkedIn API Setup Guide
|
||||||
|
|
||||||
|
This guide provides step-by-step instructions for setting up LinkedIn API integration for the Social Media Management System.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Overview](#overview)
|
||||||
|
2. [Prerequisites](#prerequisites)
|
||||||
|
3. [LinkedIn Developer Portal Setup](#linkedin-developer-portal-setup)
|
||||||
|
4. [Environment Configuration](#environment-configuration)
|
||||||
|
5. [OAuth Redirect URI Configuration](#oauth-redirect-uri-configuration)
|
||||||
|
6. [Permissions & Scopes](#permissions--scopes)
|
||||||
|
7. [Webhook Configuration (Optional)](#webhook-configuration-optional)
|
||||||
|
8. [Development vs Production](#development-vs-production)
|
||||||
|
9. [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**API Version:** RestLi 2.0 (Version 202411)
|
||||||
|
**Base URL:** `https://api.linkedin.com/rest`
|
||||||
|
**Auth URL:** `https://www.linkedin.com/oauth/v2/authorization`
|
||||||
|
**Token URL:** `https://www.linkedin.com/oauth/v2/accessToken`
|
||||||
|
|
||||||
|
### Features Supported
|
||||||
|
- Fetch organization posts
|
||||||
|
- Read and manage comments on organization posts
|
||||||
|
- Reply to comments as the organization
|
||||||
|
- Webhook support for real-time comment notifications
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- A LinkedIn account with admin access to a LinkedIn Company/Organization Page
|
||||||
|
- Access to [LinkedIn Developer Portal](https://www.linkedin.com/developers/)
|
||||||
|
- HTTPS-enabled server for production (required for redirect URIs)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LinkedIn Developer Portal Setup
|
||||||
|
|
||||||
|
### Step 1: Create a New App
|
||||||
|
|
||||||
|
1. Navigate to [LinkedIn Developer Portal](https://www.linkedin.com/developers/)
|
||||||
|
2. Click **"Create App"**
|
||||||
|
3. Fill in the required details:
|
||||||
|
- **App Name:** Your application name (e.g., "PX360 Social Manager")
|
||||||
|
- **LinkedIn Page:** Select your company/organization page
|
||||||
|
- **Privacy Policy URL:** Your privacy policy URL
|
||||||
|
- **App Logo:** Upload your app logo (required for review)
|
||||||
|
4. Click **"Create App"**
|
||||||
|
|
||||||
|
### Step 2: Request API Products
|
||||||
|
|
||||||
|
1. In your app dashboard, go to **"Products"** tab
|
||||||
|
2. Request access to the following products:
|
||||||
|
- **Marketing API** (for posts and comments management)
|
||||||
|
- **Share on LinkedIn** (for posting content)
|
||||||
|
- **Sign In with LinkedIn** (optional, for user authentication)
|
||||||
|
|
||||||
|
3. Some products require LinkedIn approval. Submit a detailed use case explaining:
|
||||||
|
> "We are building a Social Media Management Tool that allows organizations to manage and respond to comments on their LinkedIn posts from a centralized dashboard. This helps community managers respond faster and maintain engagement with their audience."
|
||||||
|
|
||||||
|
### Step 3: Get Credentials
|
||||||
|
|
||||||
|
1. Go to **"Auth"** tab in your app dashboard
|
||||||
|
2. Copy the following values:
|
||||||
|
- **Client ID** → This is your `LINKEDIN_CLIENT_ID`
|
||||||
|
- **Client Secret** → Click "Show" to reveal → This is your `LINKEDIN_CLIENT_SECRET`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
Add the following to your `settings.py` or `.env` file:
|
||||||
|
|
||||||
|
### Django Settings (settings.py)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# LinkedIn API Configuration
|
||||||
|
LINKEDIN_CLIENT_ID = 'your_client_id_here'
|
||||||
|
LINKEDIN_CLIENT_SECRET = 'your_client_secret_here'
|
||||||
|
LINKEDIN_REDIRECT_URI = 'https://yourdomain.com/social/callback/LI/'
|
||||||
|
LINKEDIN_WEBHOOK_VERIFY_TOKEN = 'your_random_secret_string_123'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables (.env)
|
||||||
|
|
||||||
|
```env
|
||||||
|
LINKEDIN_CLIENT_ID=your_client_id_here
|
||||||
|
LINKEDIN_CLIENT_SECRET=your_client_secret_here
|
||||||
|
LINKEDIN_REDIRECT_URI=https://yourdomain.com/social/callback/LI/
|
||||||
|
LINKEDIN_WEBHOOK_VERIFY_TOKEN=your_random_secret_string_123
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OAuth Redirect URI Configuration
|
||||||
|
|
||||||
|
### Step 1: Add Redirect URI in LinkedIn App
|
||||||
|
|
||||||
|
1. Go to **"Auth"** tab → **"OAuth 2.0 settings"**
|
||||||
|
2. Click **"Add redirect URL"**
|
||||||
|
3. Add your callback URL:
|
||||||
|
|
||||||
|
**Development:**
|
||||||
|
```
|
||||||
|
http://127.0.0.1:8000/social/callback/LI/
|
||||||
|
http://localhost:8000/social/callback/LI/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Production:**
|
||||||
|
```
|
||||||
|
https://yourdomain.com/social/callback/LI/
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **Important:** LinkedIn only accepts HTTPS URLs in production. For local development, `http://127.0.0.1` or `http://localhost` is allowed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permissions & Scopes
|
||||||
|
|
||||||
|
The application requests the following OAuth scopes:
|
||||||
|
|
||||||
|
| Scope | Description | Required |
|
||||||
|
|-------|-------------|----------|
|
||||||
|
| `r_organization_social` | Read organization posts and comments | ✅ Yes |
|
||||||
|
| `w_organization_social` | Post content and reply to comments as organization | ✅ Yes |
|
||||||
|
| `rw_organization_admin` | Manage organization account settings | ✅ Yes |
|
||||||
|
|
||||||
|
### Code Reference
|
||||||
|
|
||||||
|
```python
|
||||||
|
# apps/social/utils/linkedin.py
|
||||||
|
SCOPES = [
|
||||||
|
"r_organization_social",
|
||||||
|
"w_organization_social",
|
||||||
|
"rw_organization_admin"
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Webhook Configuration (Optional)
|
||||||
|
|
||||||
|
Webhooks allow real-time notifications when new comments are posted.
|
||||||
|
|
||||||
|
### Step 1: Create Webhook Endpoint
|
||||||
|
|
||||||
|
Your application should have an endpoint to receive LinkedIn webhooks:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /social/webhooks/linkedin/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Register Webhook
|
||||||
|
|
||||||
|
1. In LinkedIn Developer Portal, go to **"Products"** → **"Marketing API"**
|
||||||
|
2. Configure webhook subscriptions for:
|
||||||
|
- `socialActions` (comments and reactions)
|
||||||
|
|
||||||
|
### Step 3: Verify Webhook
|
||||||
|
|
||||||
|
LinkedIn sends a verification request with a challenge. Your server must respond with the challenge:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Webhook verification handler
|
||||||
|
def verify_webhook(request):
|
||||||
|
challenge = request.GET.get('challenge')
|
||||||
|
verify_token = request.GET.get('verifyToken')
|
||||||
|
|
||||||
|
if verify_token == settings.LINKEDIN_WEBHOOK_VERIFY_TOKEN:
|
||||||
|
return HttpResponse(challenge, status=200)
|
||||||
|
return HttpResponse(status=403)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development vs Production
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| `LINKEDIN_REDIRECT_URI` | `http://127.0.0.1:8000/social/callback/LI/` |
|
||||||
|
| Protocol | HTTP allowed |
|
||||||
|
| App Review | Not required for testing |
|
||||||
|
|
||||||
|
### Production Setup
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| `LINKEDIN_REDIRECT_URI` | `https://yourdomain.com/social/callback/LI/` |
|
||||||
|
| Protocol | **HTTPS required** |
|
||||||
|
| App Review | Required for Marketing API access |
|
||||||
|
| Rate Limits | Higher limits for approved apps |
|
||||||
|
|
||||||
|
### Using ngrok for Local Testing
|
||||||
|
|
||||||
|
If you need to test webhooks locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install ngrok
|
||||||
|
npm install -g ngrok
|
||||||
|
|
||||||
|
# Create tunnel to local server
|
||||||
|
ngrok http 8000
|
||||||
|
|
||||||
|
# Use the ngrok URL as your redirect URI
|
||||||
|
# Example: https://abc123.ngrok.io/social/callback/LI/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Error: "Access Denied"
|
||||||
|
|
||||||
|
**Cause:** User doesn't have admin access to the organization page.
|
||||||
|
|
||||||
|
**Solution:** Ensure the authenticating user has one of these roles:
|
||||||
|
- Super Admin
|
||||||
|
- Content Admin
|
||||||
|
- Curator
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "Invalid Redirect URI"
|
||||||
|
|
||||||
|
**Cause:** The redirect URI in your request doesn't match what's configured in LinkedIn.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Check exact URL in LinkedIn Developer Portal → Auth → OAuth 2.0 settings
|
||||||
|
2. Ensure trailing slashes match
|
||||||
|
3. Verify protocol (http vs https)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "Scope Not Authorized"
|
||||||
|
|
||||||
|
**Cause:** Your app hasn't been approved for the requested scope.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Check Products tab in LinkedIn Developer Portal
|
||||||
|
2. Submit use case for Marketing API if not approved
|
||||||
|
3. Wait for LinkedIn review (can take 1-5 business days)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "Token Expired"
|
||||||
|
|
||||||
|
**Cause:** Access tokens expire after 60 days.
|
||||||
|
|
||||||
|
**Solution:** The application automatically refreshes tokens using refresh tokens. Ensure:
|
||||||
|
- User reconnects if refresh fails
|
||||||
|
- `offline_access` scope was granted during initial authorization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: Rate Limit (429)
|
||||||
|
|
||||||
|
**Cause:** Too many API requests in a short period.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Application implements automatic retry with exponential backoff
|
||||||
|
- Default rate limit: 100,000 requests per day per app
|
||||||
|
- Check `X-RateLimit-Reset` header for when limit resets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Rate Limits
|
||||||
|
|
||||||
|
| Endpoint Type | Rate Limit |
|
||||||
|
|---------------|------------|
|
||||||
|
| Profile API | 100,000/day |
|
||||||
|
| Share API | 100,000/day |
|
||||||
|
| Social Actions (Comments) | 100,000/day |
|
||||||
|
|
||||||
|
The application handles rate limits automatically with retry logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After setup, verify the integration:
|
||||||
|
|
||||||
|
1. Navigate to `/social/` in your application
|
||||||
|
2. Click "Connect LinkedIn Account"
|
||||||
|
3. Authorize with LinkedIn
|
||||||
|
4. Verify organization posts are fetched
|
||||||
|
5. Test replying to a comment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support Resources
|
||||||
|
|
||||||
|
- [LinkedIn Marketing API Documentation](https://learn.microsoft.com/en-us/linkedin/marketing/)
|
||||||
|
- [LinkedIn Developer Forums](https://www.linkedin.com/developers/forum/)
|
||||||
|
- [API Status Page](https://www.linkedin-status.com/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last Updated: February 2026*
|
||||||
|
*API Version: RestLi 2.0 (202411)*
|
||||||
450
apps/social/setup_docs copy/meta.md
Normal file
450
apps/social/setup_docs copy/meta.md
Normal file
@ -0,0 +1,450 @@
|
|||||||
|
# Meta (Facebook & Instagram) API Setup Guide
|
||||||
|
|
||||||
|
This guide provides step-by-step instructions for setting up Meta Graph API integration for managing Facebook Pages and Instagram Business accounts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Overview](#overview)
|
||||||
|
2. [Prerequisites](#prerequisites)
|
||||||
|
3. [Meta for Developers Setup](#meta-for-developers-setup)
|
||||||
|
4. [Environment Configuration](#environment-configuration)
|
||||||
|
5. [OAuth Redirect URI Configuration](#oauth-redirect-uri-configuration)
|
||||||
|
6. [Permissions & Scopes](#permissions--scopes)
|
||||||
|
7. [Webhook Configuration](#webhook-configuration)
|
||||||
|
8. [Development vs Production](#development-vs-production)
|
||||||
|
9. [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**API Version:** Graph API v24.0
|
||||||
|
**Base URL:** `https://graph.facebook.com/v24.0`
|
||||||
|
**Auth URL:** `https://www.facebook.com/v24.0/dialog/oauth`
|
||||||
|
|
||||||
|
### Features Supported
|
||||||
|
- **Facebook Pages:**
|
||||||
|
- Fetch page posts
|
||||||
|
- Read comments on posts
|
||||||
|
- Reply to comments as the Page
|
||||||
|
|
||||||
|
- **Instagram Business:**
|
||||||
|
- Fetch Instagram media posts
|
||||||
|
- Read comments on posts
|
||||||
|
- Reply to comments (with nesting limitations)
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
1. User authenticates with Facebook
|
||||||
|
2. App discovers all Facebook Pages the user manages
|
||||||
|
3. For each Page, app also discovers linked Instagram Business accounts
|
||||||
|
4. Page Access Tokens are permanent (don't expire if app is active)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- A Facebook account with admin access to at least one Facebook Page
|
||||||
|
- An Instagram Business Account linked to your Facebook Page
|
||||||
|
- Access to [Meta for Developers](https://developers.facebook.com/)
|
||||||
|
- HTTPS-enabled server for production
|
||||||
|
|
||||||
|
### Linking Instagram to Facebook Page
|
||||||
|
|
||||||
|
1. Go to your Facebook Page settings
|
||||||
|
2. Navigate to **Instagram** → **Connect Account**
|
||||||
|
3. Log in to your Instagram Business account
|
||||||
|
4. Authorize the connection
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Meta for Developers Setup
|
||||||
|
|
||||||
|
### Step 1: Create a Meta App
|
||||||
|
|
||||||
|
1. Navigate to [Meta for Developers](https://developers.facebook.com/apps/)
|
||||||
|
2. Click **"Create App"**
|
||||||
|
3. Select **"Business"** as the app type
|
||||||
|
4. Fill in the details:
|
||||||
|
- **App Name:** Your application name (e.g., "PX360 Social Manager")
|
||||||
|
- **App Contact Email:** Your contact email
|
||||||
|
- **Business Account:** Select your business (if applicable)
|
||||||
|
5. Click **"Create App"**
|
||||||
|
6. Complete security verification if prompted
|
||||||
|
|
||||||
|
### Step 2: Configure Basic Settings
|
||||||
|
|
||||||
|
1. Go to **"Settings"** → **"Basic"**
|
||||||
|
2. Fill in required fields:
|
||||||
|
- **Privacy Policy URL:** Your privacy policy URL
|
||||||
|
- **User Data Deletion:** Provide deletion instructions or URL
|
||||||
|
- **Category:** Select "Business Tools"
|
||||||
|
3. Add **App Domains** (your application's domain)
|
||||||
|
4. Click **"Save Changes"**
|
||||||
|
|
||||||
|
### Step 3: Add Facebook Login Product
|
||||||
|
|
||||||
|
1. Go to **"Add Products"** (left sidebar)
|
||||||
|
2. Find **"Facebook Login"** and click **"Set Up"**
|
||||||
|
3. Select **"Web"** as platform
|
||||||
|
4. Enter your site URL
|
||||||
|
5. Configure OAuth settings (see Redirect URI section below)
|
||||||
|
|
||||||
|
### Step 4: Get App Credentials
|
||||||
|
|
||||||
|
1. Go to **"Settings"** → **"Basic"**
|
||||||
|
2. Copy the following:
|
||||||
|
- **App ID** → This is your `META_APP_ID`
|
||||||
|
- **App Secret** → Click "Show" → This is your `META_APP_SECRET`
|
||||||
|
|
||||||
|
> ⚠️ **Important:** Never expose your App Secret in client-side code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
### Django Settings (settings.py)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Meta (Facebook/Instagram) API Configuration
|
||||||
|
META_APP_ID = 'your_app_id_here'
|
||||||
|
META_APP_SECRET = 'your_app_secret_here'
|
||||||
|
META_REDIRECT_URI = 'https://yourdomain.com/social/callback/META/'
|
||||||
|
META_WEBHOOK_VERIFY_TOKEN = 'your_random_secret_string_here'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables (.env)
|
||||||
|
|
||||||
|
```env
|
||||||
|
META_APP_ID=your_app_id_here
|
||||||
|
META_APP_SECRET=your_app_secret_here
|
||||||
|
META_REDIRECT_URI=https://yourdomain.com/social/callback/META/
|
||||||
|
META_WEBHOOK_VERIFY_TOKEN=your_random_secret_string_here
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OAuth Redirect URI Configuration
|
||||||
|
|
||||||
|
### In Meta Developer Portal
|
||||||
|
|
||||||
|
1. Go to **"Facebook Login"** → **"Settings"**
|
||||||
|
2. Under **"Valid OAuth Redirect URIs"**, add:
|
||||||
|
- Development: `http://127.0.0.1:8000/social/callback/META/`
|
||||||
|
- Production: `https://yourdomain.com/social/callback/META/`
|
||||||
|
3. Click **"Save Changes"**
|
||||||
|
|
||||||
|
### Development vs Production URIs
|
||||||
|
|
||||||
|
| Environment | Redirect URI |
|
||||||
|
|-------------|--------------|
|
||||||
|
| Development | `http://127.0.0.1:8000/social/callback/META/` |
|
||||||
|
| Production | `https://yourdomain.com/social/callback/META/` |
|
||||||
|
|
||||||
|
> ⚠️ **Note:** Meta accepts HTTP for localhost but requires HTTPS for production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permissions & Scopes
|
||||||
|
|
||||||
|
The application requests the following OAuth scopes:
|
||||||
|
|
||||||
|
| Scope | Description | Required |
|
||||||
|
|-------|-------------|----------|
|
||||||
|
| `pages_manage_engagement` | Reply to comments | ✅ Yes |
|
||||||
|
| `pages_read_engagement` | Read comments and reactions | ✅ Yes |
|
||||||
|
| `pages_show_list` | Discover pages and get tokens | ✅ Yes |
|
||||||
|
| `pages_read_user_content` | Read user-generated content | ✅ Yes |
|
||||||
|
| `instagram_basic` | Basic Instagram info | ✅ Yes |
|
||||||
|
| `instagram_manage_comments` | Manage Instagram comments | ✅ Yes |
|
||||||
|
| `public_profile` | Basic user profile | ✅ Yes |
|
||||||
|
|
||||||
|
### Code Reference
|
||||||
|
|
||||||
|
```python
|
||||||
|
# apps/social/utils/meta.py
|
||||||
|
BASE_GRAPH_URL = "https://graph.facebook.com/v24.0"
|
||||||
|
BASE_AUTH_URL = "https://www.facebook.com/v24.0"
|
||||||
|
|
||||||
|
META_SCOPES = [
|
||||||
|
"pages_manage_engagement",
|
||||||
|
"pages_read_engagement",
|
||||||
|
"pages_show_list",
|
||||||
|
"pages_read_user_content",
|
||||||
|
"instagram_basic",
|
||||||
|
"instagram_manage_comments",
|
||||||
|
"public_profile",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### App Review Requirements
|
||||||
|
|
||||||
|
Some permissions require Meta's App Review:
|
||||||
|
|
||||||
|
1. Go to **"App Review"** → **"Permissions and Features"**
|
||||||
|
2. Request permissions that require review
|
||||||
|
3. Submit detailed use case and screencast
|
||||||
|
4. Typical use case explanation:
|
||||||
|
> "This application helps businesses manage their social media presence by allowing them to read and respond to comments on their Facebook Pages and Instagram Business accounts from a centralized dashboard."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Webhook Configuration
|
||||||
|
|
||||||
|
Webhooks allow real-time notifications for new comments.
|
||||||
|
|
||||||
|
### Step 1: Create Webhook Endpoint
|
||||||
|
|
||||||
|
Your application needs an endpoint to receive webhook events:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /social/webhooks/meta/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Configure Webhook in Meta Portal
|
||||||
|
|
||||||
|
1. Go to **"Webhooks"** in your app dashboard
|
||||||
|
2. Click **"Add Subscription"**
|
||||||
|
3. Enter your callback URL:
|
||||||
|
```
|
||||||
|
https://yourdomain.com/social/webhooks/meta/
|
||||||
|
```
|
||||||
|
4. Enter your verify token (from `META_WEBHOOK_VERIFY_TOKEN`)
|
||||||
|
5. Click **"Verify and Save"**
|
||||||
|
|
||||||
|
### Step 3: Subscribe to Events
|
||||||
|
|
||||||
|
1. After verification, select subscriptions:
|
||||||
|
- **Page Events:** `feed`, `comments`
|
||||||
|
- **Instagram Events:** `comments`, `mentions`
|
||||||
|
|
||||||
|
### Step 4: Subscribe Individual Pages
|
||||||
|
|
||||||
|
For each Page, subscribe to webhook events:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Done automatically during account connection
|
||||||
|
from apps.social.services.meta import MetaService
|
||||||
|
|
||||||
|
MetaService.subscribe_webhook(page_id, page_access_token)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Webhook Verification Handler
|
||||||
|
|
||||||
|
```python
|
||||||
|
def verify_webhook(request):
|
||||||
|
mode = request.GET.get('hub.mode')
|
||||||
|
challenge = request.GET.get('hub.challenge')
|
||||||
|
verify_token = request.GET.get('hub.verify_token')
|
||||||
|
|
||||||
|
if mode == 'subscribe' and verify_token == settings.META_WEBHOOK_VERIFY_TOKEN:
|
||||||
|
return HttpResponse(challenge, status=200)
|
||||||
|
return HttpResponse(status=403)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development vs Production
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| `META_REDIRECT_URI` | `http://127.0.0.1:8000/social/callback/META/` |
|
||||||
|
| Protocol | HTTP allowed for localhost |
|
||||||
|
| App Mode | Development |
|
||||||
|
| App Review | Not required for testing |
|
||||||
|
| Test Users | Add yourself and team members |
|
||||||
|
|
||||||
|
### Production Setup
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| `META_REDIRECT_URI` | `https://yourdomain.com/social/callback/META/` |
|
||||||
|
| Protocol | **HTTPS required** |
|
||||||
|
| App Mode | Live |
|
||||||
|
| App Review | Required for sensitive permissions |
|
||||||
|
| Rate Limits | Higher limits for approved apps |
|
||||||
|
|
||||||
|
### Using ngrok for Local Webhooks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install ngrok
|
||||||
|
npm install -g ngrok
|
||||||
|
|
||||||
|
# Create tunnel
|
||||||
|
ngrok http 8000
|
||||||
|
|
||||||
|
# Use ngrok URL for webhook
|
||||||
|
# Example: https://abc123.ngrok.io/social/webhooks/meta/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Error: "Error Validating Verification Code"
|
||||||
|
|
||||||
|
**Cause:** Redirect URI mismatch.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Check exact URL in Facebook Login → Settings → Valid OAuth Redirect URIs
|
||||||
|
2. Ensure trailing slashes match
|
||||||
|
3. Verify protocol (http vs https)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "Permission Error (Code 200)"
|
||||||
|
|
||||||
|
**Cause:** Missing permissions or user doesn't have page access.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Verify all required scopes are requested
|
||||||
|
2. Ensure user has Page Admin role
|
||||||
|
3. Check if permission needs App Review approval
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "Invalid OAuth Access Token (Code 190)"
|
||||||
|
|
||||||
|
**Cause:** Token expired or invalid.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Page tokens should be permanent, but user tokens expire
|
||||||
|
2. User may need to re-authenticate
|
||||||
|
3. Check if app is in Development mode and user is a test user
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "Unsupported Post Request (Code 100)"
|
||||||
|
|
||||||
|
**Cause:** Trying to reply to a reply on Instagram (nested replies not supported).
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Instagram only supports 1 level of comment nesting
|
||||||
|
- You can reply to a top-level comment, but cannot reply to a reply
|
||||||
|
- The application handles this gracefully
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "Non-Existent Field 'name' (Instagram)"
|
||||||
|
|
||||||
|
**Cause:** Instagram comments use `username`, not `name` for author.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- The application dynamically selects fields based on platform
|
||||||
|
- This is handled automatically in the code
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "Rate Limit (Code 4, 17, 32)"
|
||||||
|
|
||||||
|
**Cause:** Too many API requests.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Application implements automatic retry with delay
|
||||||
|
- Wait for rate limit to reset (usually 1 hour)
|
||||||
|
- Reduce polling frequency
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "Page Not Found (Code 404)"
|
||||||
|
|
||||||
|
**Cause:** Page doesn't exist or user doesn't have access.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Verify page exists and is published
|
||||||
|
2. Ensure user has Page Admin/Editor role
|
||||||
|
3. Check page ID is correct
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Rate Limits
|
||||||
|
|
||||||
|
| Resource | Rate Limit |
|
||||||
|
|----------|------------|
|
||||||
|
| Graph API Calls | 200 calls/hour per user per app |
|
||||||
|
| Page-level Calls | Higher limits for page tokens |
|
||||||
|
| Webhook Events | Unlimited |
|
||||||
|
|
||||||
|
The application implements rate limiting to stay within bounds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Facebook vs Instagram ID Detection
|
||||||
|
|
||||||
|
The application automatically detects platform based on ID format:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Instagram IDs typically start with 17 or 18
|
||||||
|
if str(comment_id).startswith('17') and str(comment_id).isdigit():
|
||||||
|
platform = 'IG'
|
||||||
|
elif '_' in str(comment_id):
|
||||||
|
platform = 'FB' # Facebook IDs often contain underscore
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Page Access Token Lifecycle
|
||||||
|
|
||||||
|
| Token Type | Lifetime | Notes |
|
||||||
|
|------------|----------|-------|
|
||||||
|
| User Access Token | ~60 days | Short-lived, can be exchanged |
|
||||||
|
| Page Access Token | **Permanent** | Doesn't expire if app remains active |
|
||||||
|
| Instagram Token | Same as Page | Uses Page token for access |
|
||||||
|
|
||||||
|
> ✅ **Good News:** Page tokens are permanent. Once a user connects their account, the integration continues working indefinitely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After setup, verify the integration:
|
||||||
|
|
||||||
|
### For Facebook:
|
||||||
|
1. Navigate to `/social/` in your application
|
||||||
|
2. Click "Connect Facebook/Instagram"
|
||||||
|
3. Authorize with Facebook
|
||||||
|
4. Select your Facebook Page
|
||||||
|
5. Verify posts are fetched
|
||||||
|
6. Test replying to a comment
|
||||||
|
|
||||||
|
### For Instagram:
|
||||||
|
1. After connecting Facebook, Instagram accounts are auto-discovered
|
||||||
|
2. Verify Instagram Business account appears in account list
|
||||||
|
3. Check if Instagram media is fetched
|
||||||
|
4. Test replying to an Instagram comment
|
||||||
|
|
||||||
|
### Testing in Django Shell
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apps.social.services.meta import MetaService
|
||||||
|
from apps.social.models import SocialAccount
|
||||||
|
|
||||||
|
# Test Facebook
|
||||||
|
fb_account = SocialAccount.objects.filter(platform='FB').first()
|
||||||
|
posts = MetaService.fetch_posts(fb_account.platform_id, fb_account.access_token, 'FB')
|
||||||
|
print(f"Found {len(posts)} FB posts")
|
||||||
|
|
||||||
|
# Test Instagram
|
||||||
|
ig_account = SocialAccount.objects.filter(platform='IG').first()
|
||||||
|
if ig_account:
|
||||||
|
media = MetaService.fetch_posts(ig_account.platform_id, ig_account.access_token, 'IG')
|
||||||
|
print(f"Found {len(media)} IG media posts")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support Resources
|
||||||
|
|
||||||
|
- [Meta Graph API Documentation](https://developers.facebook.com/docs/graph-api/)
|
||||||
|
- [Facebook Login Guide](https://developers.facebook.com/docs/facebook-login/)
|
||||||
|
- [Instagram Graph API](https://developers.facebook.com/docs/instagram-api/)
|
||||||
|
- [Webhooks Documentation](https://developers.facebook.com/docs/graph-api/webhooks/)
|
||||||
|
- [Meta Bug Tracker](https://developers.facebook.com/support/bugs/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last Updated: February 2026*
|
||||||
|
*API Version: Meta Graph API v24.0*
|
||||||
378
apps/social/setup_docs copy/tik_tok.md
Normal file
378
apps/social/setup_docs copy/tik_tok.md
Normal file
@ -0,0 +1,378 @@
|
|||||||
|
# TikTok Business API Setup Guide
|
||||||
|
|
||||||
|
This guide provides step-by-step instructions for setting up TikTok Business API integration for managing ad comments.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Overview](#overview)
|
||||||
|
2. [Prerequisites](#prerequisites)
|
||||||
|
3. [TikTok Business Center Setup](#tiktok-business-center-setup)
|
||||||
|
4. [Environment Configuration](#environment-configuration)
|
||||||
|
5. [OAuth Redirect URI Configuration](#oauth-redirect-uri-configuration)
|
||||||
|
6. [Permissions & Scopes](#permissions--scopes)
|
||||||
|
7. [Development vs Production](#development-vs-production)
|
||||||
|
8. [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**API Version:** Business API v1.3
|
||||||
|
**Base URL:** `https://business-api.tiktok.com/open_api/v1.3/`
|
||||||
|
**Auth Portal:** `https://business-api.tiktok.com/portal/auth`
|
||||||
|
**Token Endpoint:** `oauth2/access_token/`
|
||||||
|
|
||||||
|
### Features Supported
|
||||||
|
- Fetch advertisements (which act as "content")
|
||||||
|
- Read comments on advertisements
|
||||||
|
- Reply to ad comments
|
||||||
|
|
||||||
|
### ⚠️ Critical Limitations
|
||||||
|
|
||||||
|
> **Important:** This implementation **only supports Ad Comments (Paid Ads)**. TikTok's API does **NOT** support organic video comment management. You cannot:
|
||||||
|
> - Fetch comments on regular TikTok videos
|
||||||
|
> - Reply to organic video comments
|
||||||
|
> - Manage comments on non-ad content
|
||||||
|
|
||||||
|
This is a TikTok API limitation, not an application limitation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- A TikTok account with access to [TikTok Business Center](https://business.tiktok.com/)
|
||||||
|
- Admin or Analyst access to a TikTok Ad Account
|
||||||
|
- An approved TikTok App in Business Center
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TikTok Business Center Setup
|
||||||
|
|
||||||
|
### Step 1: Access TikTok Business Center
|
||||||
|
|
||||||
|
1. Navigate to [TikTok Business Center](https://business.tiktok.com/)
|
||||||
|
2. Sign in with your TikTok account
|
||||||
|
3. If you don't have a Business Center, create one
|
||||||
|
|
||||||
|
> ⚠️ **Note:** This is **NOT** the same as the "TikTok for Developers" portal used for the Display API. You must use the Business Center.
|
||||||
|
|
||||||
|
### Step 2: Create or Access Your App
|
||||||
|
|
||||||
|
1. Go to **User Center** (top right) → **App Management** → **My Apps**
|
||||||
|
2. If you don't have an app, click **"Create App"**
|
||||||
|
3. Fill in the required details:
|
||||||
|
- **App Name:** Your application name
|
||||||
|
- **App Description:** Describe your use case
|
||||||
|
- **Category:** Select "Business Tools" or similar
|
||||||
|
4. Submit for approval
|
||||||
|
|
||||||
|
### Step 3: Get App Credentials
|
||||||
|
|
||||||
|
Once your app is created/approved:
|
||||||
|
|
||||||
|
1. Go to **User Center** → **App Management** → **My Apps**
|
||||||
|
2. Select your app
|
||||||
|
3. Find the credentials:
|
||||||
|
- **App ID** → This is your `TIKTOK_CLIENT_KEY`
|
||||||
|
- **App Secret** → Click "View" to reveal → This is your `TIKTOK_CLIENT_SECRET`
|
||||||
|
|
||||||
|
> ⚠️ **Important:** Store these credentials securely. The App Secret is only shown once.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
### Django Settings (settings.py)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# TikTok Business API Configuration
|
||||||
|
TIKTOK_CLIENT_KEY = 'your_app_id_here'
|
||||||
|
TIKTOK_CLIENT_SECRET = 'your_app_secret_here'
|
||||||
|
TIKTOK_REDIRECT_URI = 'https://yourdomain.com/social/callback/TT/'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables (.env)
|
||||||
|
|
||||||
|
```env
|
||||||
|
TIKTOK_CLIENT_KEY=your_app_id_here
|
||||||
|
TIKTOK_CLIENT_SECRET=your_app_secret_here
|
||||||
|
TIKTOK_REDIRECT_URI=https://yourdomain.com/social/callback/TT/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OAuth Redirect URI Configuration
|
||||||
|
|
||||||
|
### Step 1: Configure Redirect URI in TikTok App
|
||||||
|
|
||||||
|
1. In your app settings, go to **App Settings** → **Login Kit** / **Redirect URI settings**
|
||||||
|
2. Add your callback URL:
|
||||||
|
|
||||||
|
**Development:**
|
||||||
|
```
|
||||||
|
http://127.0.0.1:8000/social/callback/TT/
|
||||||
|
http://localhost:8000/social/callback/TT/
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **Note:** TikTok often rejects `localhost` URLs. Use ngrok for local testing (see below).
|
||||||
|
|
||||||
|
**Production:**
|
||||||
|
```
|
||||||
|
https://yourdomain.com/social/callback/TT/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using ngrok for Local Development
|
||||||
|
|
||||||
|
TikTok may reject HTTP/localhost redirect URIs. Use ngrok:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install ngrok
|
||||||
|
npm install -g ngrok
|
||||||
|
|
||||||
|
# Create tunnel
|
||||||
|
ngrok http 8000
|
||||||
|
|
||||||
|
# Use the ngrok URL as your redirect URI
|
||||||
|
# Example: https://abc123.ngrok.io/social/callback/TT/
|
||||||
|
|
||||||
|
# Update settings.py
|
||||||
|
TIKTOK_REDIRECT_URI = 'https://abc123.ngrok.io/social/callback/TT/'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permissions & Scopes
|
||||||
|
|
||||||
|
The application requests the following OAuth scopes:
|
||||||
|
|
||||||
|
| Scope | Description | Required |
|
||||||
|
|-------|-------------|----------|
|
||||||
|
| `user.info.basic` | Basic user information | ✅ Yes |
|
||||||
|
| `ad.read` | Read advertisement data | ✅ Yes |
|
||||||
|
| `comment.manage` | Manage ad comments | ✅ Yes |
|
||||||
|
|
||||||
|
### Code Reference
|
||||||
|
|
||||||
|
```python
|
||||||
|
# apps/social/utils/tiktok.py
|
||||||
|
class TikTokConstants:
|
||||||
|
BASE_URL = "https://business-api.tiktok.com/open_api/v1.3/"
|
||||||
|
|
||||||
|
SCOPES = "user.info.basic,ad.read,comment.manage"
|
||||||
|
|
||||||
|
ENDPOINTS = {
|
||||||
|
"AUTH": "https://business-api.tiktok.com/portal/auth",
|
||||||
|
"TOKEN": "oauth2/access_token/",
|
||||||
|
"USER_INFO": "user/info/",
|
||||||
|
"AD_LIST": "ad/get/",
|
||||||
|
"COMMENT_LIST": "comment/list/",
|
||||||
|
"COMMENT_REPLY": "comment/reply/",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Requesting Permissions
|
||||||
|
|
||||||
|
1. In TikTok Business Center, go to **App Management** → **Permissions**
|
||||||
|
2. Request permission for:
|
||||||
|
- **Ads Management** (for `ad.read`)
|
||||||
|
- **Comments Management** (for `comment.manage`)
|
||||||
|
3. Submit a use case explaining:
|
||||||
|
> "We are building a Social Media Management Tool for managing ad comments. This allows advertisers to respond to user engagement on their TikTok advertisements from a centralized dashboard."
|
||||||
|
|
||||||
|
> ⚠️ **Note:** TikTok may reject generic requests. Be specific about your use case.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ad Account Access Requirements
|
||||||
|
|
||||||
|
### User Permissions
|
||||||
|
|
||||||
|
When connecting via OAuth, the authenticating user must have proper access to the Ad Account:
|
||||||
|
|
||||||
|
| Role | Can Sync Ads | Can Reply to Comments |
|
||||||
|
|------|--------------|----------------------|
|
||||||
|
| Admin | ✅ Yes | ✅ Yes |
|
||||||
|
| Analyst | ✅ Yes | ❌ No (read-only) |
|
||||||
|
| Operator | ✅ Yes | ✅ Yes |
|
||||||
|
|
||||||
|
### Granting Access
|
||||||
|
|
||||||
|
1. In TikTok Business Center, go to **Ad Accounts**
|
||||||
|
2. Select your ad account
|
||||||
|
3. Go to **User Permissions**
|
||||||
|
4. Add users with appropriate roles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development vs Production
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| `TIKTOK_REDIRECT_URI` | `https://xxx.ngrok.io/social/callback/TT/` (via ngrok) |
|
||||||
|
| Protocol | HTTPS recommended (ngrok) |
|
||||||
|
| App Status | Sandbox/Testing mode |
|
||||||
|
|
||||||
|
### Production Setup
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| `TIKTOK_REDIRECT_URI` | `https://yourdomain.com/social/callback/TT/` |
|
||||||
|
| Protocol | **HTTPS required** |
|
||||||
|
| App Status | Approved/Production mode |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Error: "Invalid Redirect URI"
|
||||||
|
|
||||||
|
**Cause:** The redirect URI doesn't match TikTok's configuration.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Verify exact URL in TikTok Business Center → App Settings → Redirect URI
|
||||||
|
2. Ensure HTTPS is used (or ngrok URL)
|
||||||
|
3. Check for trailing slashes
|
||||||
|
4. Wait a few minutes for changes to propagate
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "Permission Denied for Ad Account"
|
||||||
|
|
||||||
|
**Cause:** User doesn't have access to the ad account.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Verify user has Admin, Operator, or Analyst role
|
||||||
|
2. Check ad account permissions in Business Center
|
||||||
|
3. Ensure correct ad account is selected during OAuth flow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "Scope Not Authorized"
|
||||||
|
|
||||||
|
**Cause:** App hasn't been approved for requested permissions.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Go to App Management → Permissions
|
||||||
|
2. Request required permissions with detailed use case
|
||||||
|
3. Wait for TikTok approval (can take several days)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "No Ads Found"
|
||||||
|
|
||||||
|
**Cause:** No active ads in the ad account, or ads don't have comments.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Verify ads exist and are active in TikTok Ads Manager
|
||||||
|
2. Ensure ads have received comments
|
||||||
|
3. Check that ad account is properly linked
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "API Rate Limit Exceeded"
|
||||||
|
|
||||||
|
**Cause:** Too many API requests.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- TikTok Business API has rate limits (varies by endpoint)
|
||||||
|
- Implement exponential backoff
|
||||||
|
- Wait for limit to reset
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "Cannot Reply to Comment"
|
||||||
|
|
||||||
|
**Cause:** User has Analyst role (read-only) or comment is deleted.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Ensure user has Admin or Operator role
|
||||||
|
2. Verify the comment still exists
|
||||||
|
3. Check that the ad is still active
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Rate Limits
|
||||||
|
|
||||||
|
| Endpoint | Rate Limit |
|
||||||
|
|----------|------------|
|
||||||
|
| Ad List | 10 requests/second |
|
||||||
|
| Comment List | 10 requests/second |
|
||||||
|
| Comment Reply | 10 requests/second |
|
||||||
|
|
||||||
|
The application implements rate limiting to stay within these bounds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints Used
|
||||||
|
|
||||||
|
| Endpoint | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `oauth2/access_token/` | Exchange auth code for access token |
|
||||||
|
| `user/info/` | Get authenticated user information |
|
||||||
|
| `ad/get/` | Fetch advertisements for an advertiser |
|
||||||
|
| `comment/list/` | List comments on an advertisement |
|
||||||
|
| `comment/reply/` | Reply to a comment |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After setup, verify the integration:
|
||||||
|
|
||||||
|
1. Navigate to `/social/` in your application
|
||||||
|
2. Click "Connect TikTok"
|
||||||
|
3. Authorize with TikTok Business account
|
||||||
|
4. Select your advertiser account
|
||||||
|
5. Verify ads are fetched
|
||||||
|
6. Check if comments on ads are loaded
|
||||||
|
7. Test replying to an ad comment
|
||||||
|
|
||||||
|
### Testing in Django Shell
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apps.social.services.tiktok import TikTokService
|
||||||
|
from apps.social.models import SocialAccount
|
||||||
|
|
||||||
|
account = SocialAccount.objects.filter(platform='TT').first()
|
||||||
|
|
||||||
|
# Test getting ads
|
||||||
|
ads = TikTokService.fetch_ads(account)
|
||||||
|
print(f"Found {len(ads)} ads")
|
||||||
|
|
||||||
|
# Test getting comments
|
||||||
|
if ads:
|
||||||
|
comments = TikTokService.fetch_comments(account, ads[0]['ad_id'])
|
||||||
|
print(f"Found {len(comments)} comments")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
1. **Organic Content Not Supported:** TikTok's Business API only supports ad management. You cannot manage comments on organic (non-ad) videos.
|
||||||
|
|
||||||
|
2. **Advertiser ID Required:** You need a valid TikTok Advertiser ID with active ads to use this integration.
|
||||||
|
|
||||||
|
3. **App Approval:** TikTok may take several days to approve your app and permission requests.
|
||||||
|
|
||||||
|
4. **HTTPS Required:** Production redirect URIs must use HTTPS.
|
||||||
|
|
||||||
|
5. **Regional Availability:** TikTok Business API may not be available in all regions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support Resources
|
||||||
|
|
||||||
|
- [TikTok Business API Documentation](https://ads.tiktok.com/marketing_api/docs)
|
||||||
|
- [TikTok Business Center](https://business.tiktok.com/)
|
||||||
|
- [TikTok Marketing API Forum](https://community.tiktok.com/)
|
||||||
|
- [TikTok Ads Manager](https://ads.tiktok.com/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last Updated: February 2026*
|
||||||
|
*API Version: TikTok Business API v1.3*
|
||||||
412
apps/social/setup_docs copy/x_twitter.md
Normal file
412
apps/social/setup_docs copy/x_twitter.md
Normal file
@ -0,0 +1,412 @@
|
|||||||
|
# X (Twitter) API Setup Guide
|
||||||
|
|
||||||
|
This guide provides step-by-step instructions for setting up X (formerly Twitter) API integration for managing tweets and replies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Overview](#overview)
|
||||||
|
2. [Prerequisites](#prerequisites)
|
||||||
|
3. [X Developer Portal Setup](#x-developer-portal-setup)
|
||||||
|
4. [Environment Configuration](#environment-configuration)
|
||||||
|
5. [OAuth Redirect URI Configuration](#oauth-redirect-uri-configuration)
|
||||||
|
6. [Permissions & Scopes](#permissions--scopes)
|
||||||
|
7. [API Tiers & Limitations](#api-tiers--limitations)
|
||||||
|
8. [Development vs Production](#development-vs-production)
|
||||||
|
9. [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**API Version:** X API v2
|
||||||
|
**Base URL:** `https://api.twitter.com/2`
|
||||||
|
**Auth URL:** `https://twitter.com/i/oauth2/authorize`
|
||||||
|
**Token URL:** `https://api.twitter.com/2/oauth2/token`
|
||||||
|
**Auth Method:** OAuth 2.0 with PKCE (Proof Key for Code Exchange)
|
||||||
|
|
||||||
|
### Features Supported
|
||||||
|
- Fetch user tweets
|
||||||
|
- Read tweet replies (conversation threads)
|
||||||
|
- Reply to tweets
|
||||||
|
- Automatic token refresh
|
||||||
|
|
||||||
|
### ⚠️ Important Limitations
|
||||||
|
- **Search API** (for fetching replies) requires **Basic tier or higher**
|
||||||
|
- **Free tier** cannot search for replies - only direct mentions accessible
|
||||||
|
- The system supports both tiers with graceful degradation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- An X (Twitter) account
|
||||||
|
- Access to [X Developer Portal](https://developer.twitter.com/)
|
||||||
|
- HTTPS-enabled server for production (required for OAuth redirect URIs)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## X Developer Portal Setup
|
||||||
|
|
||||||
|
### Step 1: Apply for Developer Access
|
||||||
|
|
||||||
|
1. Navigate to [X Developer Portal](https://developer.twitter.com/en/portal/dashboard)
|
||||||
|
2. Sign in with your X account
|
||||||
|
3. Click **"Sign up"** for free access (or choose a paid tier)
|
||||||
|
4. Fill out the application form:
|
||||||
|
- **Country:** Select your country
|
||||||
|
- **Use Case:** Select "Building tools for my own use" or appropriate option
|
||||||
|
- **Description:** Describe your use case in detail:
|
||||||
|
> "We are building a social media management dashboard that allows organizations to manage and respond to comments and replies on their X/Twitter posts from a centralized interface. This helps community managers respond faster to audience engagement."
|
||||||
|
5. Review and accept the developer agreement
|
||||||
|
6. Click **"Submit"**
|
||||||
|
|
||||||
|
### Step 2: Create a Project & App
|
||||||
|
|
||||||
|
1. Once approved, go to the [Developer Portal](https://developer.twitter.com/en/portal/dashboard)
|
||||||
|
2. Create a **Project**:
|
||||||
|
- **Project Name:** e.g., "PX360 Social"
|
||||||
|
- **Use Case:** Select "Building tools for my own use"
|
||||||
|
- **Project Description:** Brief description of your application
|
||||||
|
3. Create an **App** within the project:
|
||||||
|
- **App Name:** Unique name for your app (must be globally unique)
|
||||||
|
- **Environment:** Select "Production"
|
||||||
|
|
||||||
|
### Step 3: Configure OAuth 2.0
|
||||||
|
|
||||||
|
1. In your app settings, go to **"Settings"** tab
|
||||||
|
2. Scroll to **"User authentication settings"**
|
||||||
|
3. Click **"Set up"**
|
||||||
|
4. Select **"Web App, Automated App or Bot"**
|
||||||
|
5. Configure OAuth 2.0:
|
||||||
|
|
||||||
|
**General Settings:**
|
||||||
|
- **Callback URI / Redirect URL:**
|
||||||
|
- Development: `http://127.0.0.1:8000/social/callback/X/`
|
||||||
|
- Production: `https://yourdomain.com/social/callback/X/`
|
||||||
|
- **Website URL:** Your application URL
|
||||||
|
- **Terms of Service URL:** (Optional) Your ToS URL
|
||||||
|
- **Privacy Policy URL:** (Optional) Your privacy policy URL
|
||||||
|
|
||||||
|
6. Click **"Save"**
|
||||||
|
|
||||||
|
### Step 4: Get API Credentials
|
||||||
|
|
||||||
|
1. Go to **"Keys and Tokens"** tab in your app
|
||||||
|
2. Under **"OAuth 2.0 Client ID and Client Secret"**:
|
||||||
|
- Click **"Regenerate"** if needed
|
||||||
|
- Copy the **Client ID** → This is your `X_CLIENT_ID`
|
||||||
|
- Copy the **Client Secret** → This is your `X_CLIENT_SECRET`
|
||||||
|
|
||||||
|
> ⚠️ **Important:** The Client Secret is only shown once. Store it securely!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
### Django Settings (settings.py)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# X (Twitter) API Configuration
|
||||||
|
X_CLIENT_ID = 'your_client_id_here'
|
||||||
|
X_CLIENT_SECRET = 'your_client_secret_here'
|
||||||
|
X_REDIRECT_URI = 'https://yourdomain.com/social/callback/X/'
|
||||||
|
|
||||||
|
# TIER CONFIGURATION
|
||||||
|
# Set to True if you have Enterprise Access (Full-Archive Search)
|
||||||
|
# Set to False for Free/Basic/Pro tiers (Recent Search)
|
||||||
|
X_USE_ENTERPRISE = False
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables (.env)
|
||||||
|
|
||||||
|
```env
|
||||||
|
X_CLIENT_ID=your_client_id_here
|
||||||
|
X_CLIENT_SECRET=your_client_secret_here
|
||||||
|
X_REDIRECT_URI=https://yourdomain.com/social/callback/X/
|
||||||
|
X_USE_ENTERPRISE=False
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OAuth Redirect URI Configuration
|
||||||
|
|
||||||
|
The redirect URI must match exactly what's configured in the X Developer Portal.
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```
|
||||||
|
http://127.0.0.1:8000/social/callback/X/
|
||||||
|
http://localhost:8000/social/callback/X/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
```
|
||||||
|
https://yourdomain.com/social/callback/X/
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **Note:** X requires HTTPS for production redirect URIs. Localhost is allowed for development.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permissions & Scopes
|
||||||
|
|
||||||
|
The application requests the following OAuth scopes:
|
||||||
|
|
||||||
|
| Scope | Description | Required |
|
||||||
|
|-------|-------------|----------|
|
||||||
|
| `tweet.read` | Read tweets and timeline | ✅ Yes |
|
||||||
|
| `tweet.write` | Post and reply to tweets | ✅ Yes |
|
||||||
|
| `users.read` | Read user profile information | ✅ Yes |
|
||||||
|
| `offline.access` | Refresh tokens for long-term access | ✅ Yes |
|
||||||
|
|
||||||
|
### Code Reference
|
||||||
|
|
||||||
|
```python
|
||||||
|
# apps/social/utils/x.py
|
||||||
|
class XConfig:
|
||||||
|
BASE_URL = "https://api.twitter.com/2"
|
||||||
|
AUTH_URL = "https://twitter.com/i/oauth2/authorize"
|
||||||
|
TOKEN_URL = "https://api.twitter.com/2/oauth2/token"
|
||||||
|
|
||||||
|
SCOPES = [
|
||||||
|
"tweet.read",
|
||||||
|
"tweet.write",
|
||||||
|
"users.read",
|
||||||
|
"offline.access"
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Tiers & Limitations
|
||||||
|
|
||||||
|
X API has different access tiers with varying capabilities:
|
||||||
|
|
||||||
|
### Free Tier ($0/month)
|
||||||
|
|
||||||
|
| Feature | Limit |
|
||||||
|
|---------|-------|
|
||||||
|
| Post Tweets | 1,500/month |
|
||||||
|
| User Lookup | 500/month |
|
||||||
|
| **Search API** | ❌ **NOT AVAILABLE** |
|
||||||
|
|
||||||
|
> ⚠️ **Critical:** Free tier cannot use the search endpoint, which means **fetching tweet replies is not possible**. You will see a 403 Forbidden error when trying to search.
|
||||||
|
|
||||||
|
### Basic Tier ($100/month)
|
||||||
|
|
||||||
|
| Feature | Limit |
|
||||||
|
|---------|-------|
|
||||||
|
| Post Tweets | 3,000/month |
|
||||||
|
| Recent Search | 60 requests/15 min |
|
||||||
|
| User Lookup | 900/15 min |
|
||||||
|
| **Reply Fetching** | ✅ **SUPPORTED** |
|
||||||
|
|
||||||
|
> ✅ **Recommended:** Basic tier is the minimum required for full functionality.
|
||||||
|
|
||||||
|
### Pro Tier ($5,000/month)
|
||||||
|
|
||||||
|
| Feature | Limit |
|
||||||
|
|---------|-------|
|
||||||
|
| Post Tweets | 100,000/month |
|
||||||
|
| Recent Search | 300 requests/15 min |
|
||||||
|
| Full-Archive Search | Available |
|
||||||
|
|
||||||
|
### Enterprise Tier (Custom)
|
||||||
|
|
||||||
|
| Feature | Limit |
|
||||||
|
|---------|-------|
|
||||||
|
| Full-Archive Search | ✅ Available |
|
||||||
|
| Higher Rate Limits | Custom |
|
||||||
|
|
||||||
|
### Setting the Tier
|
||||||
|
|
||||||
|
```python
|
||||||
|
# For Basic/Pro tiers (Recent Search)
|
||||||
|
X_USE_ENTERPRISE = False # Uses tweets/search/recent
|
||||||
|
|
||||||
|
# For Enterprise tier (Full-Archive Search)
|
||||||
|
X_USE_ENTERPRISE = True # Uses tweets/search/all
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development vs Production
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| `X_REDIRECT_URI` | `http://127.0.0.1:8000/social/callback/X/` |
|
||||||
|
| Protocol | HTTP allowed for localhost |
|
||||||
|
| `X_USE_ENTERPRISE` | `False` |
|
||||||
|
| Rate Limits | Lower limits per 15-min window |
|
||||||
|
|
||||||
|
### Production Setup
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| `X_REDIRECT_URI` | `https://yourdomain.com/social/callback/X/` |
|
||||||
|
| Protocol | **HTTPS required** |
|
||||||
|
| `X_USE_ENTERPRISE` | Based on your tier |
|
||||||
|
| Rate Limits | Higher limits for paid tiers |
|
||||||
|
|
||||||
|
### Using ngrok for Local Testing
|
||||||
|
|
||||||
|
If you need to test with HTTPS locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install ngrok
|
||||||
|
npm install -g ngrok
|
||||||
|
|
||||||
|
# Create tunnel to local server
|
||||||
|
ngrok http 8000
|
||||||
|
|
||||||
|
# Use the ngrok URL as your redirect URI
|
||||||
|
# Example: https://abc123.ngrok.io/social/callback/X/
|
||||||
|
|
||||||
|
# Update X Developer Portal with the ngrok URL
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Error: "403 Forbidden on Search"
|
||||||
|
|
||||||
|
**Cause:** You're on the Free tier which doesn't include Search API access.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Upgrade to at least **Basic tier** ($100/month)
|
||||||
|
2. Or, accept limited functionality (cannot fetch replies)
|
||||||
|
3. Check your tier in X Developer Portal → Products
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "Invalid Redirect URI"
|
||||||
|
|
||||||
|
**Cause:** The redirect URI doesn't match what's configured in X Developer Portal.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Go to X Developer Portal → Your App → Settings
|
||||||
|
2. Check "User authentication settings"
|
||||||
|
3. Ensure redirect URI matches exactly (including trailing slash)
|
||||||
|
4. Wait a few minutes for changes to propagate
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "Client Authentication Failed"
|
||||||
|
|
||||||
|
**Cause:** Invalid Client ID/Secret or incorrect auth header format.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Verify credentials in X Developer Portal → Keys and Tokens
|
||||||
|
2. Ensure you're using **Basic Auth** for token exchange:
|
||||||
|
```python
|
||||||
|
# Correct: Base64 encoded "client_id:client_secret"
|
||||||
|
Authorization: Basic base64(client_id:client_secret)
|
||||||
|
```
|
||||||
|
3. Regenerate credentials if needed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "Rate Limit Exceeded (429)"
|
||||||
|
|
||||||
|
**Cause:** Too many API requests in the rate limit window.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Application handles rate limits with automatic retry
|
||||||
|
- Check `x-rate-limit-reset` header for reset time
|
||||||
|
- Wait for the window to reset (usually 15 minutes)
|
||||||
|
- Consider upgrading to a higher tier for more capacity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "Token Expired"
|
||||||
|
|
||||||
|
**Cause:** Access token expired (typically after 2 hours).
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Application automatically refreshes tokens using `offline.access` scope
|
||||||
|
- If refresh fails, user needs to re-authenticate
|
||||||
|
- Check that refresh token is stored in database
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "PKCE Verification Failed"
|
||||||
|
|
||||||
|
**Cause:** Code verifier doesn't match the challenge used during authorization.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Application generates PKCE pair automatically
|
||||||
|
- Ensure `code_verifier` is stored in session during auth flow
|
||||||
|
- Verify S256 challenge method is used
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Rate Limits
|
||||||
|
|
||||||
|
### Free Tier
|
||||||
|
|
||||||
|
| Endpoint | Rate Limit |
|
||||||
|
|----------|------------|
|
||||||
|
| Post Tweet | 1,500/month |
|
||||||
|
| Get User | 500/month |
|
||||||
|
|
||||||
|
### Basic Tier
|
||||||
|
|
||||||
|
| Endpoint | Rate Limit |
|
||||||
|
|----------|------------|
|
||||||
|
| Post Tweet | 3,000/month |
|
||||||
|
| Recent Search | 60/15 min |
|
||||||
|
| Get User | 900/15 min |
|
||||||
|
|
||||||
|
### Pro Tier
|
||||||
|
|
||||||
|
| Endpoint | Rate Limit |
|
||||||
|
|----------|------------|
|
||||||
|
| Post Tweet | 100,000/month |
|
||||||
|
| Recent Search | 300/15 min |
|
||||||
|
| Get User | 900/15 min |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After setup, verify the integration:
|
||||||
|
|
||||||
|
1. Navigate to `/social/` in your application
|
||||||
|
2. Click "Connect X (Twitter)"
|
||||||
|
3. Authorize with your X account
|
||||||
|
4. Verify your profile is fetched
|
||||||
|
5. Check if tweets are loaded
|
||||||
|
6. Test posting a reply to a tweet
|
||||||
|
|
||||||
|
### Testing Search (Basic+ Tier Required)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In Django shell
|
||||||
|
from apps.social.services.x import XService
|
||||||
|
from apps.social.models import SocialAccount
|
||||||
|
|
||||||
|
account = SocialAccount.objects.filter(platform='X').first()
|
||||||
|
tweets = XService.get_user_tweets(account)
|
||||||
|
print(f"Fetched {len(tweets)} tweets")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support Resources
|
||||||
|
|
||||||
|
- [X API Documentation](https://developer.twitter.com/en/docs/twitter-api)
|
||||||
|
- [X API v2 Reference](https://developer.twitter.com/en/docs/twitter-api/users/lookup/api-reference)
|
||||||
|
- [OAuth 2.0 PKCE Guide](https://developer.twitter.com/en/docs/authentication/oauth-2-0/authorization-code)
|
||||||
|
- [X Developer Forum](https://twittercommunity.com/)
|
||||||
|
- [X API Status](https://api.twitterstat.us/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last Updated: February 2026*
|
||||||
|
*API Version: X API v2*
|
||||||
442
apps/social/setup_docs copy/youtube.md
Normal file
442
apps/social/setup_docs copy/youtube.md
Normal file
@ -0,0 +1,442 @@
|
|||||||
|
# YouTube Data API Setup Guide
|
||||||
|
|
||||||
|
This guide provides step-by-step instructions for setting up YouTube Data API integration for managing video comments.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Overview](#overview)
|
||||||
|
2. [Prerequisites](#prerequisites)
|
||||||
|
3. [Google Cloud Console Setup](#google-cloud-console-setup)
|
||||||
|
4. [Environment Configuration](#environment-configuration)
|
||||||
|
5. [OAuth Redirect URI Configuration](#oauth-redirect-uri-configuration)
|
||||||
|
6. [Permissions & Scopes](#permissions--scopes)
|
||||||
|
7. [Development vs Production](#development-vs-production)
|
||||||
|
8. [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**API Version:** YouTube Data API v3
|
||||||
|
**API Service Name:** `youtube`
|
||||||
|
**API Version:** `v3`
|
||||||
|
**Auth Method:** OAuth 2.0
|
||||||
|
|
||||||
|
### Features Supported
|
||||||
|
- Fetch channel videos
|
||||||
|
- Read video comments
|
||||||
|
- Reply to comments
|
||||||
|
- Automatic token refresh
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- A Google account with a YouTube channel
|
||||||
|
- Access to [Google Cloud Console](https://console.cloud.google.com/)
|
||||||
|
- Videos uploaded to your YouTube channel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Google Cloud Console Setup
|
||||||
|
|
||||||
|
### Step 1: Create a New Project
|
||||||
|
|
||||||
|
1. Navigate to [Google Cloud Console](https://console.cloud.google.com/)
|
||||||
|
2. Click on the project selector dropdown at the top
|
||||||
|
3. Click **"New Project"**
|
||||||
|
4. Enter project details:
|
||||||
|
- **Project Name:** e.g., "PX360 YouTube Integration"
|
||||||
|
- **Organization:** Select your organization (if applicable)
|
||||||
|
5. Click **"Create"**
|
||||||
|
6. Select your new project
|
||||||
|
|
||||||
|
### Step 2: Enable YouTube Data API
|
||||||
|
|
||||||
|
1. Go to **"APIs & Services"** → **"Library"**
|
||||||
|
2. Search for **"YouTube Data API v3"**
|
||||||
|
3. Click on it and click **"Enable"**
|
||||||
|
|
||||||
|
### Step 3: Configure OAuth Consent Screen
|
||||||
|
|
||||||
|
1. Go to **"APIs & Services"** → **"OAuth consent screen"**
|
||||||
|
2. Select **"External"** user type (unless you have a Google Workspace account)
|
||||||
|
3. Click **"Create"**
|
||||||
|
4. Fill in the required fields:
|
||||||
|
- **App Name:** Your application name
|
||||||
|
- **User Support Email:** Your support email
|
||||||
|
- **App Logo:** Upload your logo
|
||||||
|
- **Application Home Page:** Your website URL
|
||||||
|
- **Authorized Domains:** Your domain(s)
|
||||||
|
- **Developer Contact Email:** Your email
|
||||||
|
5. Click **"Save and Continue"**
|
||||||
|
6. Add scopes (click "Add or Remove Scopes"):
|
||||||
|
- `https://www.googleapis.com/auth/youtube.readonly`
|
||||||
|
- `https://www.googleapis.com/auth/youtube.force-ssl`
|
||||||
|
7. Click **"Save and Continue"**
|
||||||
|
8. Add test users (for development)
|
||||||
|
9. Click **"Save and Continue"**
|
||||||
|
|
||||||
|
### Step 4: Create OAuth 2.0 Credentials
|
||||||
|
|
||||||
|
1. Go to **"APIs & Services"** → **"Credentials"**
|
||||||
|
2. Click **"Create Credentials"** → **"OAuth client ID"**
|
||||||
|
3. Select **"Web application"**
|
||||||
|
4. Configure:
|
||||||
|
- **Name:** e.g., "PX360 YouTube Client"
|
||||||
|
- **Authorized JavaScript origins:**
|
||||||
|
- Development: `http://127.0.0.1:8000`
|
||||||
|
- Production: `https://yourdomain.com`
|
||||||
|
- **Authorized redirect URIs:**
|
||||||
|
- Development: `http://127.0.0.1:8000/social/callback/YT/`
|
||||||
|
- Production: `https://yourdomain.com/social/callback/YT/`
|
||||||
|
5. Click **"Create"**
|
||||||
|
6. **Download the JSON file** - This is your credentials file
|
||||||
|
|
||||||
|
### Step 5: Save Credentials File
|
||||||
|
|
||||||
|
1. Rename the downloaded JSON file to `yt_client_secrets.json`
|
||||||
|
2. Place it in your project's `secrets/` directory:
|
||||||
|
```
|
||||||
|
your_project/
|
||||||
|
├── secrets/
|
||||||
|
│ ├── gmb_client_secrets.json
|
||||||
|
│ └── yt_client_secrets.json
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
The JSON file structure:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"web": {
|
||||||
|
"client_id": "xxxxx.apps.googleusercontent.com",
|
||||||
|
"project_id": "your-project-id",
|
||||||
|
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||||
|
"token_uri": "https://oauth2.googleapis.com/token",
|
||||||
|
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||||
|
"client_secret": "your-client-secret",
|
||||||
|
"redirect_uris": ["http://127.0.0.1:8000/social/callback/YT/"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
### Django Settings (settings.py)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# YouTube Data API Configuration
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||||
|
|
||||||
|
# YouTube API Configuration
|
||||||
|
YOUTUBE_CLIENT_SECRETS_FILE = BASE_DIR / 'secrets' / 'yt_client_secrets.json'
|
||||||
|
YOUTUBE_REDIRECT_URI = 'https://yourdomain.com/social/callback/YT/'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables (.env)
|
||||||
|
|
||||||
|
```env
|
||||||
|
YOUTUBE_REDIRECT_URI=https://yourdomain.com/social/callback/YT/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OAuth Redirect URI Configuration
|
||||||
|
|
||||||
|
The redirect URI must match exactly what's configured in Google Cloud Console.
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```
|
||||||
|
http://127.0.0.1:8000/social/callback/YT/
|
||||||
|
http://localhost:8000/social/callback/YT/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
```
|
||||||
|
https://yourdomain.com/social/callback/YT/
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **Note:** Google accepts both HTTP and HTTPS for `localhost`/`127.0.0.1`, but production must use HTTPS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permissions & Scopes
|
||||||
|
|
||||||
|
The application requests the following OAuth scopes:
|
||||||
|
|
||||||
|
| Scope | Description | Required |
|
||||||
|
|-------|-------------|----------|
|
||||||
|
| `https://www.googleapis.com/auth/youtube.readonly` | Read channel, videos, comments | ✅ Yes |
|
||||||
|
| `https://www.googleapis.com/auth/youtube.force-ssl` | Post replies to comments | ✅ Yes |
|
||||||
|
|
||||||
|
### Code Reference
|
||||||
|
|
||||||
|
```python
|
||||||
|
# apps/social/utils/youtube.py
|
||||||
|
YOUTUBE_SCOPES = [
|
||||||
|
'https://www.googleapis.com/auth/youtube.readonly',
|
||||||
|
'https://www.googleapis.com/auth/youtube.force-ssl'
|
||||||
|
]
|
||||||
|
|
||||||
|
YOUTUBE_API_SERVICE_NAME = 'youtube'
|
||||||
|
YOUTUBE_API_VERSION = 'v3'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development vs Production
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| `YOUTUBE_REDIRECT_URI` | `http://127.0.0.1:8000/social/callback/YT/` |
|
||||||
|
| Protocol | HTTP allowed for localhost |
|
||||||
|
| App Verification | Not required for testing |
|
||||||
|
| User Access | Only added test users |
|
||||||
|
|
||||||
|
### Production Setup
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| `YOUTUBE_REDIRECT_URI` | `https://yourdomain.com/social/callback/YT/` |
|
||||||
|
| Protocol | **HTTPS required** |
|
||||||
|
| App Verification | Required for sensitive scopes |
|
||||||
|
| User Access | Any Google account |
|
||||||
|
|
||||||
|
### Google App Verification
|
||||||
|
|
||||||
|
For production, if your app requests sensitive scopes (like YouTube), you may need verification:
|
||||||
|
|
||||||
|
1. Submit your app for verification in Google Cloud Console
|
||||||
|
2. Provide a demo video showing the integration
|
||||||
|
3. Wait for Google's review (can take several days)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## YouTube API Quotas
|
||||||
|
|
||||||
|
YouTube Data API has strict quota limits:
|
||||||
|
|
||||||
|
### Default Quota
|
||||||
|
|
||||||
|
| Resource | Daily Quota |
|
||||||
|
|----------|-------------|
|
||||||
|
| Total API Units | 10,000 units/day |
|
||||||
|
| Search | 100 units/request |
|
||||||
|
| Videos List | 1 unit/request |
|
||||||
|
| Comments List | 1 unit/request |
|
||||||
|
| Comments Insert | 50 units/request |
|
||||||
|
|
||||||
|
### Quota Calculation Example
|
||||||
|
|
||||||
|
```
|
||||||
|
Daily sync of 50 videos:
|
||||||
|
- 50 video list requests = 50 units
|
||||||
|
- Comments for each video (avg 5 pages) = 250 units
|
||||||
|
- Total: ~300 units/day (well within limits)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Requesting Higher Quota
|
||||||
|
|
||||||
|
If you need more quota:
|
||||||
|
|
||||||
|
1. Go to Google Cloud Console → **"IAM & Admin"** → **"Quotas"**
|
||||||
|
2. Filter by "YouTube Data API"
|
||||||
|
3. Click **"Edit Quotas"**
|
||||||
|
4. Submit a request explaining your use case
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Error: "Access Denied - Requested client not authorized"
|
||||||
|
|
||||||
|
**Cause:** OAuth consent screen not configured or app not verified.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Ensure OAuth consent screen is properly configured
|
||||||
|
2. Add user as a test user if app is in testing mode
|
||||||
|
3. Submit app for verification if needed for production
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "Invalid Grant"
|
||||||
|
|
||||||
|
**Cause:** Authorization code expired or already used.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Authorization codes are single-use and expire quickly
|
||||||
|
- Ensure your code handles the callback immediately
|
||||||
|
- Check that redirect URI matches exactly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "Quota Exceeded"
|
||||||
|
|
||||||
|
**Cause:** Daily API quota exceeded.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Check quota usage in Google Cloud Console
|
||||||
|
2. Optimize API calls to use fewer units
|
||||||
|
3. Request quota increase if needed
|
||||||
|
4. Wait for quota reset (daily at midnight Pacific Time)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "Comments Disabled for This Video"
|
||||||
|
|
||||||
|
**Cause:** Video owner has disabled comments.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- This is expected behavior for some videos
|
||||||
|
- Application handles this gracefully
|
||||||
|
- Skip videos with disabled comments
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "No Uploads Playlist ID Found"
|
||||||
|
|
||||||
|
**Cause:** Channel has no uploaded videos or credentials incomplete.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Ensure the YouTube channel has uploaded videos
|
||||||
|
2. Check that the uploads playlist ID is stored in credentials
|
||||||
|
3. Re-authenticate if needed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "Token Refresh Failed"
|
||||||
|
|
||||||
|
**Cause:** Refresh token expired or revoked.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Google OAuth tokens expire after 6 months of inactivity
|
||||||
|
- User must re-authenticate
|
||||||
|
- Ensure `offline` access type is requested
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Error: "Comment Thread Not Found"
|
||||||
|
|
||||||
|
**Cause:** Comment was deleted or video was removed.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Application handles 404 errors gracefully
|
||||||
|
- Skip deleted comments during sync
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Rate Limits & Best Practices
|
||||||
|
|
||||||
|
### Rate Limits
|
||||||
|
|
||||||
|
| Resource | Limit |
|
||||||
|
|----------|-------|
|
||||||
|
| Requests per second | Varies by endpoint |
|
||||||
|
| Daily quota units | 10,000 (default) |
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
|
||||||
|
1. **Batch Requests:** Minimize API calls by fetching multiple items
|
||||||
|
2. **Pagination:** Use page tokens for large result sets
|
||||||
|
3. **Caching:** Cache video/comment data locally
|
||||||
|
4. **Delta Sync:** Only fetch new comments since last sync
|
||||||
|
5. **Error Handling:** Gracefully handle quota and rate limit errors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Token Lifecycle
|
||||||
|
|
||||||
|
| Token Type | Lifetime | Notes |
|
||||||
|
|------------|----------|-------|
|
||||||
|
| Access Token | ~1 hour | Short-lived |
|
||||||
|
| Refresh Token | 6 months | Expires with inactivity |
|
||||||
|
| Offline Access | Indefinite | Requires `offline` access type |
|
||||||
|
|
||||||
|
> ⚠️ **Note:** If a refresh token expires, the user must re-authenticate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After setup, verify the integration:
|
||||||
|
|
||||||
|
1. Ensure `yt_client_secrets.json` is in place
|
||||||
|
2. Navigate to `/social/` in your application
|
||||||
|
3. Click "Connect YouTube"
|
||||||
|
4. Authorize with your Google account
|
||||||
|
5. Verify your channel is detected
|
||||||
|
6. Check if videos are fetched
|
||||||
|
7. Test replying to a comment
|
||||||
|
|
||||||
|
### Testing in Django Shell
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apps.social.services.youtube import YouTubeService
|
||||||
|
from apps.social.models import SocialAccount
|
||||||
|
|
||||||
|
account = SocialAccount.objects.filter(platform='YT').first()
|
||||||
|
|
||||||
|
# Test getting videos
|
||||||
|
videos = YouTubeService.fetch_user_videos(account)
|
||||||
|
print(f"Found {len(videos)} videos")
|
||||||
|
|
||||||
|
# Test getting comments
|
||||||
|
if videos:
|
||||||
|
video_id = videos[0]['id']
|
||||||
|
comments = YouTubeService.fetch_video_comments(account, video_id)
|
||||||
|
print(f"Found {len(comments)} comments for video {video_id}")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sharing Credentials with Google Business
|
||||||
|
|
||||||
|
If you're already using Google Business API, you can use the same Google Cloud project:
|
||||||
|
|
||||||
|
1. Both APIs use OAuth 2.0 with similar configuration
|
||||||
|
2. You can create separate OAuth clients or use the same one
|
||||||
|
3. The `secrets/` directory can contain multiple credential files:
|
||||||
|
- `gmb_client_secrets.json` (Google Business)
|
||||||
|
- `yt_client_secrets.json` (YouTube)
|
||||||
|
|
||||||
|
### Single OAuth Client for Both
|
||||||
|
|
||||||
|
You can use a single OAuth client for both services:
|
||||||
|
|
||||||
|
1. Enable both APIs in Google Cloud Console
|
||||||
|
2. Request scopes for both services during OAuth
|
||||||
|
3. Use the same credentials file
|
||||||
|
4. Update settings to point to the same file:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# If using single OAuth client
|
||||||
|
GOOGLE_CLIENT_SECRETS_FILE = BASE_DIR / 'secrets' / 'google_client_secrets.json'
|
||||||
|
GMB_CLIENT_SECRETS_FILE = GOOGLE_CLIENT_SECRETS_FILE
|
||||||
|
YOUTUBE_CLIENT_SECRETS_FILE = GOOGLE_CLIENT_SECRETS_FILE
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support Resources
|
||||||
|
|
||||||
|
- [YouTube Data API Documentation](https://developers.google.com/youtube/v3)
|
||||||
|
- [YouTube API Code Samples](https://developers.google.com/youtube/v3/code_samples)
|
||||||
|
- [OAuth 2.0 for Web Server Applications](https://developers.google.com/identity/protocols/oauth2/web-server)
|
||||||
|
- [YouTube API Quota Calculator](https://developers.google.com/youtube/v3/determine_quota_cost)
|
||||||
|
- [Google Cloud Console Support](https://support.google.com/cloud/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last Updated: February 2026*
|
||||||
|
*API Version: YouTube Data API v3*
|
||||||
@ -121,243 +121,4 @@ The JSON file structure should look like:
|
|||||||
"redirect_uris": ["http://127.0.0.1:8000/social/callback/GO/"]
|
"redirect_uris": ["http://127.0.0.1:8000/social/callback/GO/"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Environment Configuration
|
|
||||||
|
|
||||||
### Django Settings (settings.py)
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Google Business Profile API Configuration
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
|
||||||
|
|
||||||
# Google My Business (Reviews) Configuration
|
|
||||||
GMB_CLIENT_SECRETS_FILE = BASE_DIR / 'secrets' / 'gmb_client_secrets.json'
|
|
||||||
GMB_REDIRECT_URI = 'https://yourdomain.com/social/callback/GO/'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables (.env)
|
|
||||||
|
|
||||||
While the credentials are in a JSON file, you can set the redirect URI via environment:
|
|
||||||
|
|
||||||
```env
|
|
||||||
GMB_REDIRECT_URI=https://yourdomain.com/social/callback/GO/
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## OAuth Redirect URI Configuration
|
|
||||||
|
|
||||||
The redirect URI must match exactly what's configured in Google Cloud Console.
|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
```
|
|
||||||
http://127.0.0.1:8000/social/callback/GO/
|
|
||||||
http://localhost:8000/social/callback/GO/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production
|
|
||||||
|
|
||||||
```
|
|
||||||
https://yourdomain.com/social/callback/GO/
|
|
||||||
```
|
|
||||||
|
|
||||||
> ⚠️ **Note:** Google accepts both HTTP and HTTPS for `localhost`/`127.0.0.1`, but production must use HTTPS.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Permissions & Scopes
|
|
||||||
|
|
||||||
The application requires the following OAuth scope:
|
|
||||||
|
|
||||||
| Scope | Description | Required |
|
|
||||||
|-------|-------------|----------|
|
|
||||||
| `https://www.googleapis.com/auth/business.manage` | Full access to manage business listings and reviews | ✅ Yes |
|
|
||||||
|
|
||||||
### Code Reference
|
|
||||||
|
|
||||||
```python
|
|
||||||
# apps/social/utils/google.py
|
|
||||||
SCOPES = ['https://www.googleapis.com/auth/business.manage']
|
|
||||||
|
|
||||||
API_VERSION_MYBUSINESS = 'v4'
|
|
||||||
API_VERSION_ACCOUNT_MGMT = 'v1'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development vs Production
|
|
||||||
|
|
||||||
### Development Setup
|
|
||||||
|
|
||||||
| Setting | Value |
|
|
||||||
|---------|-------|
|
|
||||||
| `GMB_REDIRECT_URI` | `http://127.0.0.1:8000/social/callback/GO/` |
|
|
||||||
| Protocol | HTTP allowed for localhost |
|
|
||||||
| App Verification | Not required for testing |
|
|
||||||
| User Access | Only added test users |
|
|
||||||
|
|
||||||
### Production Setup
|
|
||||||
|
|
||||||
| Setting | Value |
|
|
||||||
|---------|-------|
|
|
||||||
| `GMB_REDIRECT_URI` | `https://yourdomain.com/social/callback/GO/` |
|
|
||||||
| Protocol | **HTTPS required** |
|
|
||||||
| App Verification | **Required** by Google |
|
|
||||||
| User Access | Any Google account |
|
|
||||||
|
|
||||||
### Google App Verification
|
|
||||||
|
|
||||||
For production, if your app requests sensitive scopes, you may need to go through Google's verification process:
|
|
||||||
|
|
||||||
1. Submit your app for verification in Google Cloud Console
|
|
||||||
2. Provide a demo video showing how the integration works
|
|
||||||
3. Wait for Google's review (can take several days to weeks)
|
|
||||||
|
|
||||||
**Alternative:** Use a service account for internal business use (no verification needed if only accessing your own business data).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Service Account Alternative (Recommended for Internal Use)
|
|
||||||
|
|
||||||
If you're only managing your own business locations, consider using a Service Account:
|
|
||||||
|
|
||||||
### Step 1: Create Service Account
|
|
||||||
|
|
||||||
1. Go to **"IAM & Admin"** → **"Service Accounts"**
|
|
||||||
2. Click **"Create Service Account"**
|
|
||||||
3. Enter name and description
|
|
||||||
4. Click **"Create and Continue"**
|
|
||||||
5. Skip optional steps
|
|
||||||
6. Click **"Done"**
|
|
||||||
|
|
||||||
### Step 2: Create Key
|
|
||||||
|
|
||||||
1. Click on the created service account
|
|
||||||
2. Go to **"Keys"** tab
|
|
||||||
3. Click **"Add Key"** → **"Create new key"**
|
|
||||||
4. Select **"JSON"**
|
|
||||||
5. Click **"Create"**
|
|
||||||
6. Save the JSON file securely
|
|
||||||
|
|
||||||
### Step 3: Grant Business Access
|
|
||||||
|
|
||||||
1. Go to [Google Business Profile Manager](https://business.google.com/)
|
|
||||||
2. Select your business
|
|
||||||
3. Go to **"Users"** → **"Add users"**
|
|
||||||
4. Add the service account email (found in the JSON file)
|
|
||||||
5. Grant appropriate access level (Owner or Manager)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Error: "Access Denied - Requested client not authorized"
|
|
||||||
|
|
||||||
**Cause:** OAuth consent screen not configured or app not verified.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Ensure OAuth consent screen is properly configured
|
|
||||||
2. Add user as a test user if app is in testing mode
|
|
||||||
3. Submit app for verification if needed for production
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "Invalid Grant"
|
|
||||||
|
|
||||||
**Cause:** Authorization code expired or already used.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
- Authorization codes are single-use and expire quickly
|
|
||||||
- Ensure your code handles the callback immediately
|
|
||||||
- Check that redirect URI matches exactly
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "The caller does not have permission"
|
|
||||||
|
|
||||||
**Cause:** User doesn't have access to the business location.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Verify user is an Owner or Manager of the Google Business Profile
|
|
||||||
2. Check business account permissions at business.google.com
|
|
||||||
3. Ensure the correct account is selected during OAuth
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "API Not Enabled"
|
|
||||||
|
|
||||||
**Cause:** Required APIs not enabled in Google Cloud Console.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Go to APIs & Services → Library
|
|
||||||
2. Enable: Google My Business API
|
|
||||||
3. Enable: My Business Account Management API
|
|
||||||
4. Wait a few minutes for changes to propagate
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "Token Refresh Failed"
|
|
||||||
|
|
||||||
**Cause:** Refresh token expired or revoked.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
- Google OAuth tokens expire after 6 months of inactivity
|
|
||||||
- User must re-authenticate
|
|
||||||
- Ensure `offline_access` is requested during initial auth
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "Quota Exceeded"
|
|
||||||
|
|
||||||
**Cause:** API quota limit reached.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
- Default quota: varies by API method
|
|
||||||
- Request higher quota in Google Cloud Console
|
|
||||||
- Implement rate limiting in your application
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Quotas & Limits
|
|
||||||
|
|
||||||
| Resource | Default Limit |
|
|
||||||
|----------|---------------|
|
|
||||||
| Read Requests | 150 per minute |
|
|
||||||
| Write Requests | 50 per minute |
|
|
||||||
| Locations per Account | 10,000 |
|
|
||||||
|
|
||||||
The application implements rate limiting to stay within these bounds.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
After setup, verify the integration:
|
|
||||||
|
|
||||||
1. Ensure `gmb_client_secrets.json` is in place
|
|
||||||
2. Navigate to `/social/` in your application
|
|
||||||
3. Click "Connect Google Business"
|
|
||||||
4. Authorize with your Google account
|
|
||||||
5. Select your business location
|
|
||||||
6. Verify reviews are fetched
|
|
||||||
7. Test replying to a review
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Support Resources
|
|
||||||
|
|
||||||
- [Google Business Profile API Documentation](https://developers.google.com/my-business)
|
|
||||||
- [OAuth 2.0 for Web Server Applications](https://developers.google.com/identity/protocols/oauth2/web-server)
|
|
||||||
- [Google Cloud Console Support](https://support.google.com/cloud/)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Last Updated: February 2026*
|
|
||||||
*API Version: My Business v4 / Account Management v1*
|
|
||||||
@ -71,238 +71,3 @@ This guide provides step-by-step instructions for setting up LinkedIn API integr
|
|||||||
2. Copy the following values:
|
2. Copy the following values:
|
||||||
- **Client ID** → This is your `LINKEDIN_CLIENT_ID`
|
- **Client ID** → This is your `LINKEDIN_CLIENT_ID`
|
||||||
- **Client Secret** → Click "Show" to reveal → This is your `LINKEDIN_CLIENT_SECRET`
|
- **Client Secret** → Click "Show" to reveal → This is your `LINKEDIN_CLIENT_SECRET`
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Environment Configuration
|
|
||||||
|
|
||||||
Add the following to your `settings.py` or `.env` file:
|
|
||||||
|
|
||||||
### Django Settings (settings.py)
|
|
||||||
|
|
||||||
```python
|
|
||||||
# LinkedIn API Configuration
|
|
||||||
LINKEDIN_CLIENT_ID = 'your_client_id_here'
|
|
||||||
LINKEDIN_CLIENT_SECRET = 'your_client_secret_here'
|
|
||||||
LINKEDIN_REDIRECT_URI = 'https://yourdomain.com/social/callback/LI/'
|
|
||||||
LINKEDIN_WEBHOOK_VERIFY_TOKEN = 'your_random_secret_string_123'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables (.env)
|
|
||||||
|
|
||||||
```env
|
|
||||||
LINKEDIN_CLIENT_ID=your_client_id_here
|
|
||||||
LINKEDIN_CLIENT_SECRET=your_client_secret_here
|
|
||||||
LINKEDIN_REDIRECT_URI=https://yourdomain.com/social/callback/LI/
|
|
||||||
LINKEDIN_WEBHOOK_VERIFY_TOKEN=your_random_secret_string_123
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## OAuth Redirect URI Configuration
|
|
||||||
|
|
||||||
### Step 1: Add Redirect URI in LinkedIn App
|
|
||||||
|
|
||||||
1. Go to **"Auth"** tab → **"OAuth 2.0 settings"**
|
|
||||||
2. Click **"Add redirect URL"**
|
|
||||||
3. Add your callback URL:
|
|
||||||
|
|
||||||
**Development:**
|
|
||||||
```
|
|
||||||
http://127.0.0.1:8000/social/callback/LI/
|
|
||||||
http://localhost:8000/social/callback/LI/
|
|
||||||
```
|
|
||||||
|
|
||||||
**Production:**
|
|
||||||
```
|
|
||||||
https://yourdomain.com/social/callback/LI/
|
|
||||||
```
|
|
||||||
|
|
||||||
> ⚠️ **Important:** LinkedIn only accepts HTTPS URLs in production. For local development, `http://127.0.0.1` or `http://localhost` is allowed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Permissions & Scopes
|
|
||||||
|
|
||||||
The application requests the following OAuth scopes:
|
|
||||||
|
|
||||||
| Scope | Description | Required |
|
|
||||||
|-------|-------------|----------|
|
|
||||||
| `r_organization_social` | Read organization posts and comments | ✅ Yes |
|
|
||||||
| `w_organization_social` | Post content and reply to comments as organization | ✅ Yes |
|
|
||||||
| `rw_organization_admin` | Manage organization account settings | ✅ Yes |
|
|
||||||
|
|
||||||
### Code Reference
|
|
||||||
|
|
||||||
```python
|
|
||||||
# apps/social/utils/linkedin.py
|
|
||||||
SCOPES = [
|
|
||||||
"r_organization_social",
|
|
||||||
"w_organization_social",
|
|
||||||
"rw_organization_admin"
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Webhook Configuration (Optional)
|
|
||||||
|
|
||||||
Webhooks allow real-time notifications when new comments are posted.
|
|
||||||
|
|
||||||
### Step 1: Create Webhook Endpoint
|
|
||||||
|
|
||||||
Your application should have an endpoint to receive LinkedIn webhooks:
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /social/webhooks/linkedin/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Register Webhook
|
|
||||||
|
|
||||||
1. In LinkedIn Developer Portal, go to **"Products"** → **"Marketing API"**
|
|
||||||
2. Configure webhook subscriptions for:
|
|
||||||
- `socialActions` (comments and reactions)
|
|
||||||
|
|
||||||
### Step 3: Verify Webhook
|
|
||||||
|
|
||||||
LinkedIn sends a verification request with a challenge. Your server must respond with the challenge:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Webhook verification handler
|
|
||||||
def verify_webhook(request):
|
|
||||||
challenge = request.GET.get('challenge')
|
|
||||||
verify_token = request.GET.get('verifyToken')
|
|
||||||
|
|
||||||
if verify_token == settings.LINKEDIN_WEBHOOK_VERIFY_TOKEN:
|
|
||||||
return HttpResponse(challenge, status=200)
|
|
||||||
return HttpResponse(status=403)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development vs Production
|
|
||||||
|
|
||||||
### Development Setup
|
|
||||||
|
|
||||||
| Setting | Value |
|
|
||||||
|---------|-------|
|
|
||||||
| `LINKEDIN_REDIRECT_URI` | `http://127.0.0.1:8000/social/callback/LI/` |
|
|
||||||
| Protocol | HTTP allowed |
|
|
||||||
| App Review | Not required for testing |
|
|
||||||
|
|
||||||
### Production Setup
|
|
||||||
|
|
||||||
| Setting | Value |
|
|
||||||
|---------|-------|
|
|
||||||
| `LINKEDIN_REDIRECT_URI` | `https://yourdomain.com/social/callback/LI/` |
|
|
||||||
| Protocol | **HTTPS required** |
|
|
||||||
| App Review | Required for Marketing API access |
|
|
||||||
| Rate Limits | Higher limits for approved apps |
|
|
||||||
|
|
||||||
### Using ngrok for Local Testing
|
|
||||||
|
|
||||||
If you need to test webhooks locally:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install ngrok
|
|
||||||
npm install -g ngrok
|
|
||||||
|
|
||||||
# Create tunnel to local server
|
|
||||||
ngrok http 8000
|
|
||||||
|
|
||||||
# Use the ngrok URL as your redirect URI
|
|
||||||
# Example: https://abc123.ngrok.io/social/callback/LI/
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Error: "Access Denied"
|
|
||||||
|
|
||||||
**Cause:** User doesn't have admin access to the organization page.
|
|
||||||
|
|
||||||
**Solution:** Ensure the authenticating user has one of these roles:
|
|
||||||
- Super Admin
|
|
||||||
- Content Admin
|
|
||||||
- Curator
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "Invalid Redirect URI"
|
|
||||||
|
|
||||||
**Cause:** The redirect URI in your request doesn't match what's configured in LinkedIn.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Check exact URL in LinkedIn Developer Portal → Auth → OAuth 2.0 settings
|
|
||||||
2. Ensure trailing slashes match
|
|
||||||
3. Verify protocol (http vs https)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "Scope Not Authorized"
|
|
||||||
|
|
||||||
**Cause:** Your app hasn't been approved for the requested scope.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Check Products tab in LinkedIn Developer Portal
|
|
||||||
2. Submit use case for Marketing API if not approved
|
|
||||||
3. Wait for LinkedIn review (can take 1-5 business days)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "Token Expired"
|
|
||||||
|
|
||||||
**Cause:** Access tokens expire after 60 days.
|
|
||||||
|
|
||||||
**Solution:** The application automatically refreshes tokens using refresh tokens. Ensure:
|
|
||||||
- User reconnects if refresh fails
|
|
||||||
- `offline_access` scope was granted during initial authorization
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: Rate Limit (429)
|
|
||||||
|
|
||||||
**Cause:** Too many API requests in a short period.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
- Application implements automatic retry with exponential backoff
|
|
||||||
- Default rate limit: 100,000 requests per day per app
|
|
||||||
- Check `X-RateLimit-Reset` header for when limit resets
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Rate Limits
|
|
||||||
|
|
||||||
| Endpoint Type | Rate Limit |
|
|
||||||
|---------------|------------|
|
|
||||||
| Profile API | 100,000/day |
|
|
||||||
| Share API | 100,000/day |
|
|
||||||
| Social Actions (Comments) | 100,000/day |
|
|
||||||
|
|
||||||
The application handles rate limits automatically with retry logic.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
After setup, verify the integration:
|
|
||||||
|
|
||||||
1. Navigate to `/social/` in your application
|
|
||||||
2. Click "Connect LinkedIn Account"
|
|
||||||
3. Authorize with LinkedIn
|
|
||||||
4. Verify organization posts are fetched
|
|
||||||
5. Test replying to a comment
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Support Resources
|
|
||||||
|
|
||||||
- [LinkedIn Marketing API Documentation](https://learn.microsoft.com/en-us/linkedin/marketing/)
|
|
||||||
- [LinkedIn Developer Forums](https://www.linkedin.com/developers/forum/)
|
|
||||||
- [API Status Page](https://www.linkedin-status.com/)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Last Updated: February 2026*
|
|
||||||
*API Version: RestLi 2.0 (202411)*
|
|
||||||
@ -99,352 +99,3 @@ This guide provides step-by-step instructions for setting up Meta Graph API inte
|
|||||||
- **App Secret** → Click "Show" → This is your `META_APP_SECRET`
|
- **App Secret** → Click "Show" → This is your `META_APP_SECRET`
|
||||||
|
|
||||||
> ⚠️ **Important:** Never expose your App Secret in client-side code.
|
> ⚠️ **Important:** Never expose your App Secret in client-side code.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Environment Configuration
|
|
||||||
|
|
||||||
### Django Settings (settings.py)
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Meta (Facebook/Instagram) API Configuration
|
|
||||||
META_APP_ID = 'your_app_id_here'
|
|
||||||
META_APP_SECRET = 'your_app_secret_here'
|
|
||||||
META_REDIRECT_URI = 'https://yourdomain.com/social/callback/META/'
|
|
||||||
META_WEBHOOK_VERIFY_TOKEN = 'your_random_secret_string_here'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables (.env)
|
|
||||||
|
|
||||||
```env
|
|
||||||
META_APP_ID=your_app_id_here
|
|
||||||
META_APP_SECRET=your_app_secret_here
|
|
||||||
META_REDIRECT_URI=https://yourdomain.com/social/callback/META/
|
|
||||||
META_WEBHOOK_VERIFY_TOKEN=your_random_secret_string_here
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## OAuth Redirect URI Configuration
|
|
||||||
|
|
||||||
### In Meta Developer Portal
|
|
||||||
|
|
||||||
1. Go to **"Facebook Login"** → **"Settings"**
|
|
||||||
2. Under **"Valid OAuth Redirect URIs"**, add:
|
|
||||||
- Development: `http://127.0.0.1:8000/social/callback/META/`
|
|
||||||
- Production: `https://yourdomain.com/social/callback/META/`
|
|
||||||
3. Click **"Save Changes"**
|
|
||||||
|
|
||||||
### Development vs Production URIs
|
|
||||||
|
|
||||||
| Environment | Redirect URI |
|
|
||||||
|-------------|--------------|
|
|
||||||
| Development | `http://127.0.0.1:8000/social/callback/META/` |
|
|
||||||
| Production | `https://yourdomain.com/social/callback/META/` |
|
|
||||||
|
|
||||||
> ⚠️ **Note:** Meta accepts HTTP for localhost but requires HTTPS for production.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Permissions & Scopes
|
|
||||||
|
|
||||||
The application requests the following OAuth scopes:
|
|
||||||
|
|
||||||
| Scope | Description | Required |
|
|
||||||
|-------|-------------|----------|
|
|
||||||
| `pages_manage_engagement` | Reply to comments | ✅ Yes |
|
|
||||||
| `pages_read_engagement` | Read comments and reactions | ✅ Yes |
|
|
||||||
| `pages_show_list` | Discover pages and get tokens | ✅ Yes |
|
|
||||||
| `pages_read_user_content` | Read user-generated content | ✅ Yes |
|
|
||||||
| `instagram_basic` | Basic Instagram info | ✅ Yes |
|
|
||||||
| `instagram_manage_comments` | Manage Instagram comments | ✅ Yes |
|
|
||||||
| `public_profile` | Basic user profile | ✅ Yes |
|
|
||||||
|
|
||||||
### Code Reference
|
|
||||||
|
|
||||||
```python
|
|
||||||
# apps/social/utils/meta.py
|
|
||||||
BASE_GRAPH_URL = "https://graph.facebook.com/v24.0"
|
|
||||||
BASE_AUTH_URL = "https://www.facebook.com/v24.0"
|
|
||||||
|
|
||||||
META_SCOPES = [
|
|
||||||
"pages_manage_engagement",
|
|
||||||
"pages_read_engagement",
|
|
||||||
"pages_show_list",
|
|
||||||
"pages_read_user_content",
|
|
||||||
"instagram_basic",
|
|
||||||
"instagram_manage_comments",
|
|
||||||
"public_profile",
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### App Review Requirements
|
|
||||||
|
|
||||||
Some permissions require Meta's App Review:
|
|
||||||
|
|
||||||
1. Go to **"App Review"** → **"Permissions and Features"**
|
|
||||||
2. Request permissions that require review
|
|
||||||
3. Submit detailed use case and screencast
|
|
||||||
4. Typical use case explanation:
|
|
||||||
> "This application helps businesses manage their social media presence by allowing them to read and respond to comments on their Facebook Pages and Instagram Business accounts from a centralized dashboard."
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Webhook Configuration
|
|
||||||
|
|
||||||
Webhooks allow real-time notifications for new comments.
|
|
||||||
|
|
||||||
### Step 1: Create Webhook Endpoint
|
|
||||||
|
|
||||||
Your application needs an endpoint to receive webhook events:
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /social/webhooks/meta/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Configure Webhook in Meta Portal
|
|
||||||
|
|
||||||
1. Go to **"Webhooks"** in your app dashboard
|
|
||||||
2. Click **"Add Subscription"**
|
|
||||||
3. Enter your callback URL:
|
|
||||||
```
|
|
||||||
https://yourdomain.com/social/webhooks/meta/
|
|
||||||
```
|
|
||||||
4. Enter your verify token (from `META_WEBHOOK_VERIFY_TOKEN`)
|
|
||||||
5. Click **"Verify and Save"**
|
|
||||||
|
|
||||||
### Step 3: Subscribe to Events
|
|
||||||
|
|
||||||
1. After verification, select subscriptions:
|
|
||||||
- **Page Events:** `feed`, `comments`
|
|
||||||
- **Instagram Events:** `comments`, `mentions`
|
|
||||||
|
|
||||||
### Step 4: Subscribe Individual Pages
|
|
||||||
|
|
||||||
For each Page, subscribe to webhook events:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Done automatically during account connection
|
|
||||||
from apps.social.services.meta import MetaService
|
|
||||||
|
|
||||||
MetaService.subscribe_webhook(page_id, page_access_token)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Webhook Verification Handler
|
|
||||||
|
|
||||||
```python
|
|
||||||
def verify_webhook(request):
|
|
||||||
mode = request.GET.get('hub.mode')
|
|
||||||
challenge = request.GET.get('hub.challenge')
|
|
||||||
verify_token = request.GET.get('hub.verify_token')
|
|
||||||
|
|
||||||
if mode == 'subscribe' and verify_token == settings.META_WEBHOOK_VERIFY_TOKEN:
|
|
||||||
return HttpResponse(challenge, status=200)
|
|
||||||
return HttpResponse(status=403)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development vs Production
|
|
||||||
|
|
||||||
### Development Setup
|
|
||||||
|
|
||||||
| Setting | Value |
|
|
||||||
|---------|-------|
|
|
||||||
| `META_REDIRECT_URI` | `http://127.0.0.1:8000/social/callback/META/` |
|
|
||||||
| Protocol | HTTP allowed for localhost |
|
|
||||||
| App Mode | Development |
|
|
||||||
| App Review | Not required for testing |
|
|
||||||
| Test Users | Add yourself and team members |
|
|
||||||
|
|
||||||
### Production Setup
|
|
||||||
|
|
||||||
| Setting | Value |
|
|
||||||
|---------|-------|
|
|
||||||
| `META_REDIRECT_URI` | `https://yourdomain.com/social/callback/META/` |
|
|
||||||
| Protocol | **HTTPS required** |
|
|
||||||
| App Mode | Live |
|
|
||||||
| App Review | Required for sensitive permissions |
|
|
||||||
| Rate Limits | Higher limits for approved apps |
|
|
||||||
|
|
||||||
### Using ngrok for Local Webhooks
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install ngrok
|
|
||||||
npm install -g ngrok
|
|
||||||
|
|
||||||
# Create tunnel
|
|
||||||
ngrok http 8000
|
|
||||||
|
|
||||||
# Use ngrok URL for webhook
|
|
||||||
# Example: https://abc123.ngrok.io/social/webhooks/meta/
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Error: "Error Validating Verification Code"
|
|
||||||
|
|
||||||
**Cause:** Redirect URI mismatch.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Check exact URL in Facebook Login → Settings → Valid OAuth Redirect URIs
|
|
||||||
2. Ensure trailing slashes match
|
|
||||||
3. Verify protocol (http vs https)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "Permission Error (Code 200)"
|
|
||||||
|
|
||||||
**Cause:** Missing permissions or user doesn't have page access.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Verify all required scopes are requested
|
|
||||||
2. Ensure user has Page Admin role
|
|
||||||
3. Check if permission needs App Review approval
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "Invalid OAuth Access Token (Code 190)"
|
|
||||||
|
|
||||||
**Cause:** Token expired or invalid.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Page tokens should be permanent, but user tokens expire
|
|
||||||
2. User may need to re-authenticate
|
|
||||||
3. Check if app is in Development mode and user is a test user
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "Unsupported Post Request (Code 100)"
|
|
||||||
|
|
||||||
**Cause:** Trying to reply to a reply on Instagram (nested replies not supported).
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
- Instagram only supports 1 level of comment nesting
|
|
||||||
- You can reply to a top-level comment, but cannot reply to a reply
|
|
||||||
- The application handles this gracefully
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "Non-Existent Field 'name' (Instagram)"
|
|
||||||
|
|
||||||
**Cause:** Instagram comments use `username`, not `name` for author.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
- The application dynamically selects fields based on platform
|
|
||||||
- This is handled automatically in the code
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "Rate Limit (Code 4, 17, 32)"
|
|
||||||
|
|
||||||
**Cause:** Too many API requests.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
- Application implements automatic retry with delay
|
|
||||||
- Wait for rate limit to reset (usually 1 hour)
|
|
||||||
- Reduce polling frequency
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "Page Not Found (Code 404)"
|
|
||||||
|
|
||||||
**Cause:** Page doesn't exist or user doesn't have access.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Verify page exists and is published
|
|
||||||
2. Ensure user has Page Admin/Editor role
|
|
||||||
3. Check page ID is correct
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Rate Limits
|
|
||||||
|
|
||||||
| Resource | Rate Limit |
|
|
||||||
|----------|------------|
|
|
||||||
| Graph API Calls | 200 calls/hour per user per app |
|
|
||||||
| Page-level Calls | Higher limits for page tokens |
|
|
||||||
| Webhook Events | Unlimited |
|
|
||||||
|
|
||||||
The application implements rate limiting to stay within bounds.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Facebook vs Instagram ID Detection
|
|
||||||
|
|
||||||
The application automatically detects platform based on ID format:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Instagram IDs typically start with 17 or 18
|
|
||||||
if str(comment_id).startswith('17') and str(comment_id).isdigit():
|
|
||||||
platform = 'IG'
|
|
||||||
elif '_' in str(comment_id):
|
|
||||||
platform = 'FB' # Facebook IDs often contain underscore
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Page Access Token Lifecycle
|
|
||||||
|
|
||||||
| Token Type | Lifetime | Notes |
|
|
||||||
|------------|----------|-------|
|
|
||||||
| User Access Token | ~60 days | Short-lived, can be exchanged |
|
|
||||||
| Page Access Token | **Permanent** | Doesn't expire if app remains active |
|
|
||||||
| Instagram Token | Same as Page | Uses Page token for access |
|
|
||||||
|
|
||||||
> ✅ **Good News:** Page tokens are permanent. Once a user connects their account, the integration continues working indefinitely.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
After setup, verify the integration:
|
|
||||||
|
|
||||||
### For Facebook:
|
|
||||||
1. Navigate to `/social/` in your application
|
|
||||||
2. Click "Connect Facebook/Instagram"
|
|
||||||
3. Authorize with Facebook
|
|
||||||
4. Select your Facebook Page
|
|
||||||
5. Verify posts are fetched
|
|
||||||
6. Test replying to a comment
|
|
||||||
|
|
||||||
### For Instagram:
|
|
||||||
1. After connecting Facebook, Instagram accounts are auto-discovered
|
|
||||||
2. Verify Instagram Business account appears in account list
|
|
||||||
3. Check if Instagram media is fetched
|
|
||||||
4. Test replying to an Instagram comment
|
|
||||||
|
|
||||||
### Testing in Django Shell
|
|
||||||
|
|
||||||
```python
|
|
||||||
from apps.social.services.meta import MetaService
|
|
||||||
from apps.social.models import SocialAccount
|
|
||||||
|
|
||||||
# Test Facebook
|
|
||||||
fb_account = SocialAccount.objects.filter(platform='FB').first()
|
|
||||||
posts = MetaService.fetch_posts(fb_account.platform_id, fb_account.access_token, 'FB')
|
|
||||||
print(f"Found {len(posts)} FB posts")
|
|
||||||
|
|
||||||
# Test Instagram
|
|
||||||
ig_account = SocialAccount.objects.filter(platform='IG').first()
|
|
||||||
if ig_account:
|
|
||||||
media = MetaService.fetch_posts(ig_account.platform_id, ig_account.access_token, 'IG')
|
|
||||||
print(f"Found {len(media)} IG media posts")
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Support Resources
|
|
||||||
|
|
||||||
- [Meta Graph API Documentation](https://developers.facebook.com/docs/graph-api/)
|
|
||||||
- [Facebook Login Guide](https://developers.facebook.com/docs/facebook-login/)
|
|
||||||
- [Instagram Graph API](https://developers.facebook.com/docs/instagram-api/)
|
|
||||||
- [Webhooks Documentation](https://developers.facebook.com/docs/graph-api/webhooks/)
|
|
||||||
- [Meta Bug Tracker](https://developers.facebook.com/support/bugs/)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Last Updated: February 2026*
|
|
||||||
*API Version: Meta Graph API v24.0*
|
|
||||||
@ -79,300 +79,3 @@ Once your app is created/approved:
|
|||||||
- **App Secret** → Click "View" to reveal → This is your `TIKTOK_CLIENT_SECRET`
|
- **App Secret** → Click "View" to reveal → This is your `TIKTOK_CLIENT_SECRET`
|
||||||
|
|
||||||
> ⚠️ **Important:** Store these credentials securely. The App Secret is only shown once.
|
> ⚠️ **Important:** Store these credentials securely. The App Secret is only shown once.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Environment Configuration
|
|
||||||
|
|
||||||
### Django Settings (settings.py)
|
|
||||||
|
|
||||||
```python
|
|
||||||
# TikTok Business API Configuration
|
|
||||||
TIKTOK_CLIENT_KEY = 'your_app_id_here'
|
|
||||||
TIKTOK_CLIENT_SECRET = 'your_app_secret_here'
|
|
||||||
TIKTOK_REDIRECT_URI = 'https://yourdomain.com/social/callback/TT/'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables (.env)
|
|
||||||
|
|
||||||
```env
|
|
||||||
TIKTOK_CLIENT_KEY=your_app_id_here
|
|
||||||
TIKTOK_CLIENT_SECRET=your_app_secret_here
|
|
||||||
TIKTOK_REDIRECT_URI=https://yourdomain.com/social/callback/TT/
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## OAuth Redirect URI Configuration
|
|
||||||
|
|
||||||
### Step 1: Configure Redirect URI in TikTok App
|
|
||||||
|
|
||||||
1. In your app settings, go to **App Settings** → **Login Kit** / **Redirect URI settings**
|
|
||||||
2. Add your callback URL:
|
|
||||||
|
|
||||||
**Development:**
|
|
||||||
```
|
|
||||||
http://127.0.0.1:8000/social/callback/TT/
|
|
||||||
http://localhost:8000/social/callback/TT/
|
|
||||||
```
|
|
||||||
|
|
||||||
> ⚠️ **Note:** TikTok often rejects `localhost` URLs. Use ngrok for local testing (see below).
|
|
||||||
|
|
||||||
**Production:**
|
|
||||||
```
|
|
||||||
https://yourdomain.com/social/callback/TT/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using ngrok for Local Development
|
|
||||||
|
|
||||||
TikTok may reject HTTP/localhost redirect URIs. Use ngrok:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install ngrok
|
|
||||||
npm install -g ngrok
|
|
||||||
|
|
||||||
# Create tunnel
|
|
||||||
ngrok http 8000
|
|
||||||
|
|
||||||
# Use the ngrok URL as your redirect URI
|
|
||||||
# Example: https://abc123.ngrok.io/social/callback/TT/
|
|
||||||
|
|
||||||
# Update settings.py
|
|
||||||
TIKTOK_REDIRECT_URI = 'https://abc123.ngrok.io/social/callback/TT/'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Permissions & Scopes
|
|
||||||
|
|
||||||
The application requests the following OAuth scopes:
|
|
||||||
|
|
||||||
| Scope | Description | Required |
|
|
||||||
|-------|-------------|----------|
|
|
||||||
| `user.info.basic` | Basic user information | ✅ Yes |
|
|
||||||
| `ad.read` | Read advertisement data | ✅ Yes |
|
|
||||||
| `comment.manage` | Manage ad comments | ✅ Yes |
|
|
||||||
|
|
||||||
### Code Reference
|
|
||||||
|
|
||||||
```python
|
|
||||||
# apps/social/utils/tiktok.py
|
|
||||||
class TikTokConstants:
|
|
||||||
BASE_URL = "https://business-api.tiktok.com/open_api/v1.3/"
|
|
||||||
|
|
||||||
SCOPES = "user.info.basic,ad.read,comment.manage"
|
|
||||||
|
|
||||||
ENDPOINTS = {
|
|
||||||
"AUTH": "https://business-api.tiktok.com/portal/auth",
|
|
||||||
"TOKEN": "oauth2/access_token/",
|
|
||||||
"USER_INFO": "user/info/",
|
|
||||||
"AD_LIST": "ad/get/",
|
|
||||||
"COMMENT_LIST": "comment/list/",
|
|
||||||
"COMMENT_REPLY": "comment/reply/",
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Requesting Permissions
|
|
||||||
|
|
||||||
1. In TikTok Business Center, go to **App Management** → **Permissions**
|
|
||||||
2. Request permission for:
|
|
||||||
- **Ads Management** (for `ad.read`)
|
|
||||||
- **Comments Management** (for `comment.manage`)
|
|
||||||
3. Submit a use case explaining:
|
|
||||||
> "We are building a Social Media Management Tool for managing ad comments. This allows advertisers to respond to user engagement on their TikTok advertisements from a centralized dashboard."
|
|
||||||
|
|
||||||
> ⚠️ **Note:** TikTok may reject generic requests. Be specific about your use case.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ad Account Access Requirements
|
|
||||||
|
|
||||||
### User Permissions
|
|
||||||
|
|
||||||
When connecting via OAuth, the authenticating user must have proper access to the Ad Account:
|
|
||||||
|
|
||||||
| Role | Can Sync Ads | Can Reply to Comments |
|
|
||||||
|------|--------------|----------------------|
|
|
||||||
| Admin | ✅ Yes | ✅ Yes |
|
|
||||||
| Analyst | ✅ Yes | ❌ No (read-only) |
|
|
||||||
| Operator | ✅ Yes | ✅ Yes |
|
|
||||||
|
|
||||||
### Granting Access
|
|
||||||
|
|
||||||
1. In TikTok Business Center, go to **Ad Accounts**
|
|
||||||
2. Select your ad account
|
|
||||||
3. Go to **User Permissions**
|
|
||||||
4. Add users with appropriate roles
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development vs Production
|
|
||||||
|
|
||||||
### Development Setup
|
|
||||||
|
|
||||||
| Setting | Value |
|
|
||||||
|---------|-------|
|
|
||||||
| `TIKTOK_REDIRECT_URI` | `https://xxx.ngrok.io/social/callback/TT/` (via ngrok) |
|
|
||||||
| Protocol | HTTPS recommended (ngrok) |
|
|
||||||
| App Status | Sandbox/Testing mode |
|
|
||||||
|
|
||||||
### Production Setup
|
|
||||||
|
|
||||||
| Setting | Value |
|
|
||||||
|---------|-------|
|
|
||||||
| `TIKTOK_REDIRECT_URI` | `https://yourdomain.com/social/callback/TT/` |
|
|
||||||
| Protocol | **HTTPS required** |
|
|
||||||
| App Status | Approved/Production mode |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Error: "Invalid Redirect URI"
|
|
||||||
|
|
||||||
**Cause:** The redirect URI doesn't match TikTok's configuration.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Verify exact URL in TikTok Business Center → App Settings → Redirect URI
|
|
||||||
2. Ensure HTTPS is used (or ngrok URL)
|
|
||||||
3. Check for trailing slashes
|
|
||||||
4. Wait a few minutes for changes to propagate
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "Permission Denied for Ad Account"
|
|
||||||
|
|
||||||
**Cause:** User doesn't have access to the ad account.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Verify user has Admin, Operator, or Analyst role
|
|
||||||
2. Check ad account permissions in Business Center
|
|
||||||
3. Ensure correct ad account is selected during OAuth flow
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "Scope Not Authorized"
|
|
||||||
|
|
||||||
**Cause:** App hasn't been approved for requested permissions.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Go to App Management → Permissions
|
|
||||||
2. Request required permissions with detailed use case
|
|
||||||
3. Wait for TikTok approval (can take several days)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "No Ads Found"
|
|
||||||
|
|
||||||
**Cause:** No active ads in the ad account, or ads don't have comments.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Verify ads exist and are active in TikTok Ads Manager
|
|
||||||
2. Ensure ads have received comments
|
|
||||||
3. Check that ad account is properly linked
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "API Rate Limit Exceeded"
|
|
||||||
|
|
||||||
**Cause:** Too many API requests.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
- TikTok Business API has rate limits (varies by endpoint)
|
|
||||||
- Implement exponential backoff
|
|
||||||
- Wait for limit to reset
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "Cannot Reply to Comment"
|
|
||||||
|
|
||||||
**Cause:** User has Analyst role (read-only) or comment is deleted.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Ensure user has Admin or Operator role
|
|
||||||
2. Verify the comment still exists
|
|
||||||
3. Check that the ad is still active
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Rate Limits
|
|
||||||
|
|
||||||
| Endpoint | Rate Limit |
|
|
||||||
|----------|------------|
|
|
||||||
| Ad List | 10 requests/second |
|
|
||||||
| Comment List | 10 requests/second |
|
|
||||||
| Comment Reply | 10 requests/second |
|
|
||||||
|
|
||||||
The application implements rate limiting to stay within these bounds.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Endpoints Used
|
|
||||||
|
|
||||||
| Endpoint | Purpose |
|
|
||||||
|----------|---------|
|
|
||||||
| `oauth2/access_token/` | Exchange auth code for access token |
|
|
||||||
| `user/info/` | Get authenticated user information |
|
|
||||||
| `ad/get/` | Fetch advertisements for an advertiser |
|
|
||||||
| `comment/list/` | List comments on an advertisement |
|
|
||||||
| `comment/reply/` | Reply to a comment |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
After setup, verify the integration:
|
|
||||||
|
|
||||||
1. Navigate to `/social/` in your application
|
|
||||||
2. Click "Connect TikTok"
|
|
||||||
3. Authorize with TikTok Business account
|
|
||||||
4. Select your advertiser account
|
|
||||||
5. Verify ads are fetched
|
|
||||||
6. Check if comments on ads are loaded
|
|
||||||
7. Test replying to an ad comment
|
|
||||||
|
|
||||||
### Testing in Django Shell
|
|
||||||
|
|
||||||
```python
|
|
||||||
from apps.social.services.tiktok import TikTokService
|
|
||||||
from apps.social.models import SocialAccount
|
|
||||||
|
|
||||||
account = SocialAccount.objects.filter(platform='TT').first()
|
|
||||||
|
|
||||||
# Test getting ads
|
|
||||||
ads = TikTokService.fetch_ads(account)
|
|
||||||
print(f"Found {len(ads)} ads")
|
|
||||||
|
|
||||||
# Test getting comments
|
|
||||||
if ads:
|
|
||||||
comments = TikTokService.fetch_comments(account, ads[0]['ad_id'])
|
|
||||||
print(f"Found {len(comments)} comments")
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Important Notes
|
|
||||||
|
|
||||||
1. **Organic Content Not Supported:** TikTok's Business API only supports ad management. You cannot manage comments on organic (non-ad) videos.
|
|
||||||
|
|
||||||
2. **Advertiser ID Required:** You need a valid TikTok Advertiser ID with active ads to use this integration.
|
|
||||||
|
|
||||||
3. **App Approval:** TikTok may take several days to approve your app and permission requests.
|
|
||||||
|
|
||||||
4. **HTTPS Required:** Production redirect URIs must use HTTPS.
|
|
||||||
|
|
||||||
5. **Regional Availability:** TikTok Business API may not be available in all regions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Support Resources
|
|
||||||
|
|
||||||
- [TikTok Business API Documentation](https://ads.tiktok.com/marketing_api/docs)
|
|
||||||
- [TikTok Business Center](https://business.tiktok.com/)
|
|
||||||
- [TikTok Marketing API Forum](https://community.tiktok.com/)
|
|
||||||
- [TikTok Ads Manager](https://ads.tiktok.com/)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Last Updated: February 2026*
|
|
||||||
*API Version: TikTok Business API v1.3*
|
|
||||||
@ -100,313 +100,3 @@ This guide provides step-by-step instructions for setting up X (formerly Twitter
|
|||||||
- Copy the **Client Secret** → This is your `X_CLIENT_SECRET`
|
- Copy the **Client Secret** → This is your `X_CLIENT_SECRET`
|
||||||
|
|
||||||
> ⚠️ **Important:** The Client Secret is only shown once. Store it securely!
|
> ⚠️ **Important:** The Client Secret is only shown once. Store it securely!
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Environment Configuration
|
|
||||||
|
|
||||||
### Django Settings (settings.py)
|
|
||||||
|
|
||||||
```python
|
|
||||||
# X (Twitter) API Configuration
|
|
||||||
X_CLIENT_ID = 'your_client_id_here'
|
|
||||||
X_CLIENT_SECRET = 'your_client_secret_here'
|
|
||||||
X_REDIRECT_URI = 'https://yourdomain.com/social/callback/X/'
|
|
||||||
|
|
||||||
# TIER CONFIGURATION
|
|
||||||
# Set to True if you have Enterprise Access (Full-Archive Search)
|
|
||||||
# Set to False for Free/Basic/Pro tiers (Recent Search)
|
|
||||||
X_USE_ENTERPRISE = False
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables (.env)
|
|
||||||
|
|
||||||
```env
|
|
||||||
X_CLIENT_ID=your_client_id_here
|
|
||||||
X_CLIENT_SECRET=your_client_secret_here
|
|
||||||
X_REDIRECT_URI=https://yourdomain.com/social/callback/X/
|
|
||||||
X_USE_ENTERPRISE=False
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## OAuth Redirect URI Configuration
|
|
||||||
|
|
||||||
The redirect URI must match exactly what's configured in the X Developer Portal.
|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
```
|
|
||||||
http://127.0.0.1:8000/social/callback/X/
|
|
||||||
http://localhost:8000/social/callback/X/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production
|
|
||||||
|
|
||||||
```
|
|
||||||
https://yourdomain.com/social/callback/X/
|
|
||||||
```
|
|
||||||
|
|
||||||
> ⚠️ **Note:** X requires HTTPS for production redirect URIs. Localhost is allowed for development.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Permissions & Scopes
|
|
||||||
|
|
||||||
The application requests the following OAuth scopes:
|
|
||||||
|
|
||||||
| Scope | Description | Required |
|
|
||||||
|-------|-------------|----------|
|
|
||||||
| `tweet.read` | Read tweets and timeline | ✅ Yes |
|
|
||||||
| `tweet.write` | Post and reply to tweets | ✅ Yes |
|
|
||||||
| `users.read` | Read user profile information | ✅ Yes |
|
|
||||||
| `offline.access` | Refresh tokens for long-term access | ✅ Yes |
|
|
||||||
|
|
||||||
### Code Reference
|
|
||||||
|
|
||||||
```python
|
|
||||||
# apps/social/utils/x.py
|
|
||||||
class XConfig:
|
|
||||||
BASE_URL = "https://api.twitter.com/2"
|
|
||||||
AUTH_URL = "https://twitter.com/i/oauth2/authorize"
|
|
||||||
TOKEN_URL = "https://api.twitter.com/2/oauth2/token"
|
|
||||||
|
|
||||||
SCOPES = [
|
|
||||||
"tweet.read",
|
|
||||||
"tweet.write",
|
|
||||||
"users.read",
|
|
||||||
"offline.access"
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Tiers & Limitations
|
|
||||||
|
|
||||||
X API has different access tiers with varying capabilities:
|
|
||||||
|
|
||||||
### Free Tier ($0/month)
|
|
||||||
|
|
||||||
| Feature | Limit |
|
|
||||||
|---------|-------|
|
|
||||||
| Post Tweets | 1,500/month |
|
|
||||||
| User Lookup | 500/month |
|
|
||||||
| **Search API** | ❌ **NOT AVAILABLE** |
|
|
||||||
|
|
||||||
> ⚠️ **Critical:** Free tier cannot use the search endpoint, which means **fetching tweet replies is not possible**. You will see a 403 Forbidden error when trying to search.
|
|
||||||
|
|
||||||
### Basic Tier ($100/month)
|
|
||||||
|
|
||||||
| Feature | Limit |
|
|
||||||
|---------|-------|
|
|
||||||
| Post Tweets | 3,000/month |
|
|
||||||
| Recent Search | 60 requests/15 min |
|
|
||||||
| User Lookup | 900/15 min |
|
|
||||||
| **Reply Fetching** | ✅ **SUPPORTED** |
|
|
||||||
|
|
||||||
> ✅ **Recommended:** Basic tier is the minimum required for full functionality.
|
|
||||||
|
|
||||||
### Pro Tier ($5,000/month)
|
|
||||||
|
|
||||||
| Feature | Limit |
|
|
||||||
|---------|-------|
|
|
||||||
| Post Tweets | 100,000/month |
|
|
||||||
| Recent Search | 300 requests/15 min |
|
|
||||||
| Full-Archive Search | Available |
|
|
||||||
|
|
||||||
### Enterprise Tier (Custom)
|
|
||||||
|
|
||||||
| Feature | Limit |
|
|
||||||
|---------|-------|
|
|
||||||
| Full-Archive Search | ✅ Available |
|
|
||||||
| Higher Rate Limits | Custom |
|
|
||||||
|
|
||||||
### Setting the Tier
|
|
||||||
|
|
||||||
```python
|
|
||||||
# For Basic/Pro tiers (Recent Search)
|
|
||||||
X_USE_ENTERPRISE = False # Uses tweets/search/recent
|
|
||||||
|
|
||||||
# For Enterprise tier (Full-Archive Search)
|
|
||||||
X_USE_ENTERPRISE = True # Uses tweets/search/all
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development vs Production
|
|
||||||
|
|
||||||
### Development Setup
|
|
||||||
|
|
||||||
| Setting | Value |
|
|
||||||
|---------|-------|
|
|
||||||
| `X_REDIRECT_URI` | `http://127.0.0.1:8000/social/callback/X/` |
|
|
||||||
| Protocol | HTTP allowed for localhost |
|
|
||||||
| `X_USE_ENTERPRISE` | `False` |
|
|
||||||
| Rate Limits | Lower limits per 15-min window |
|
|
||||||
|
|
||||||
### Production Setup
|
|
||||||
|
|
||||||
| Setting | Value |
|
|
||||||
|---------|-------|
|
|
||||||
| `X_REDIRECT_URI` | `https://yourdomain.com/social/callback/X/` |
|
|
||||||
| Protocol | **HTTPS required** |
|
|
||||||
| `X_USE_ENTERPRISE` | Based on your tier |
|
|
||||||
| Rate Limits | Higher limits for paid tiers |
|
|
||||||
|
|
||||||
### Using ngrok for Local Testing
|
|
||||||
|
|
||||||
If you need to test with HTTPS locally:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install ngrok
|
|
||||||
npm install -g ngrok
|
|
||||||
|
|
||||||
# Create tunnel to local server
|
|
||||||
ngrok http 8000
|
|
||||||
|
|
||||||
# Use the ngrok URL as your redirect URI
|
|
||||||
# Example: https://abc123.ngrok.io/social/callback/X/
|
|
||||||
|
|
||||||
# Update X Developer Portal with the ngrok URL
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Error: "403 Forbidden on Search"
|
|
||||||
|
|
||||||
**Cause:** You're on the Free tier which doesn't include Search API access.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Upgrade to at least **Basic tier** ($100/month)
|
|
||||||
2. Or, accept limited functionality (cannot fetch replies)
|
|
||||||
3. Check your tier in X Developer Portal → Products
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "Invalid Redirect URI"
|
|
||||||
|
|
||||||
**Cause:** The redirect URI doesn't match what's configured in X Developer Portal.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Go to X Developer Portal → Your App → Settings
|
|
||||||
2. Check "User authentication settings"
|
|
||||||
3. Ensure redirect URI matches exactly (including trailing slash)
|
|
||||||
4. Wait a few minutes for changes to propagate
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "Client Authentication Failed"
|
|
||||||
|
|
||||||
**Cause:** Invalid Client ID/Secret or incorrect auth header format.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Verify credentials in X Developer Portal → Keys and Tokens
|
|
||||||
2. Ensure you're using **Basic Auth** for token exchange:
|
|
||||||
```python
|
|
||||||
# Correct: Base64 encoded "client_id:client_secret"
|
|
||||||
Authorization: Basic base64(client_id:client_secret)
|
|
||||||
```
|
|
||||||
3. Regenerate credentials if needed
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "Rate Limit Exceeded (429)"
|
|
||||||
|
|
||||||
**Cause:** Too many API requests in the rate limit window.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
- Application handles rate limits with automatic retry
|
|
||||||
- Check `x-rate-limit-reset` header for reset time
|
|
||||||
- Wait for the window to reset (usually 15 minutes)
|
|
||||||
- Consider upgrading to a higher tier for more capacity
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "Token Expired"
|
|
||||||
|
|
||||||
**Cause:** Access token expired (typically after 2 hours).
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
- Application automatically refreshes tokens using `offline.access` scope
|
|
||||||
- If refresh fails, user needs to re-authenticate
|
|
||||||
- Check that refresh token is stored in database
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "PKCE Verification Failed"
|
|
||||||
|
|
||||||
**Cause:** Code verifier doesn't match the challenge used during authorization.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
- Application generates PKCE pair automatically
|
|
||||||
- Ensure `code_verifier` is stored in session during auth flow
|
|
||||||
- Verify S256 challenge method is used
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Rate Limits
|
|
||||||
|
|
||||||
### Free Tier
|
|
||||||
|
|
||||||
| Endpoint | Rate Limit |
|
|
||||||
|----------|------------|
|
|
||||||
| Post Tweet | 1,500/month |
|
|
||||||
| Get User | 500/month |
|
|
||||||
|
|
||||||
### Basic Tier
|
|
||||||
|
|
||||||
| Endpoint | Rate Limit |
|
|
||||||
|----------|------------|
|
|
||||||
| Post Tweet | 3,000/month |
|
|
||||||
| Recent Search | 60/15 min |
|
|
||||||
| Get User | 900/15 min |
|
|
||||||
|
|
||||||
### Pro Tier
|
|
||||||
|
|
||||||
| Endpoint | Rate Limit |
|
|
||||||
|----------|------------|
|
|
||||||
| Post Tweet | 100,000/month |
|
|
||||||
| Recent Search | 300/15 min |
|
|
||||||
| Get User | 900/15 min |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
After setup, verify the integration:
|
|
||||||
|
|
||||||
1. Navigate to `/social/` in your application
|
|
||||||
2. Click "Connect X (Twitter)"
|
|
||||||
3. Authorize with your X account
|
|
||||||
4. Verify your profile is fetched
|
|
||||||
5. Check if tweets are loaded
|
|
||||||
6. Test posting a reply to a tweet
|
|
||||||
|
|
||||||
### Testing Search (Basic+ Tier Required)
|
|
||||||
|
|
||||||
```python
|
|
||||||
# In Django shell
|
|
||||||
from apps.social.services.x import XService
|
|
||||||
from apps.social.models import SocialAccount
|
|
||||||
|
|
||||||
account = SocialAccount.objects.filter(platform='X').first()
|
|
||||||
tweets = XService.get_user_tweets(account)
|
|
||||||
print(f"Fetched {len(tweets)} tweets")
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Support Resources
|
|
||||||
|
|
||||||
- [X API Documentation](https://developer.twitter.com/en/docs/twitter-api)
|
|
||||||
- [X API v2 Reference](https://developer.twitter.com/en/docs/twitter-api/users/lookup/api-reference)
|
|
||||||
- [OAuth 2.0 PKCE Guide](https://developer.twitter.com/en/docs/authentication/oauth-2-0/authorization-code)
|
|
||||||
- [X Developer Forum](https://twittercommunity.com/)
|
|
||||||
- [X API Status](https://api.twitterstat.us/)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Last Updated: February 2026*
|
|
||||||
*API Version: X API v2*
|
|
||||||
@ -120,323 +120,4 @@ The JSON file structure:
|
|||||||
"redirect_uris": ["http://127.0.0.1:8000/social/callback/YT/"]
|
"redirect_uris": ["http://127.0.0.1:8000/social/callback/YT/"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Environment Configuration
|
|
||||||
|
|
||||||
### Django Settings (settings.py)
|
|
||||||
|
|
||||||
```python
|
|
||||||
# YouTube Data API Configuration
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
|
||||||
|
|
||||||
# YouTube API Configuration
|
|
||||||
YOUTUBE_CLIENT_SECRETS_FILE = BASE_DIR / 'secrets' / 'yt_client_secrets.json'
|
|
||||||
YOUTUBE_REDIRECT_URI = 'https://yourdomain.com/social/callback/YT/'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables (.env)
|
|
||||||
|
|
||||||
```env
|
|
||||||
YOUTUBE_REDIRECT_URI=https://yourdomain.com/social/callback/YT/
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## OAuth Redirect URI Configuration
|
|
||||||
|
|
||||||
The redirect URI must match exactly what's configured in Google Cloud Console.
|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
```
|
|
||||||
http://127.0.0.1:8000/social/callback/YT/
|
|
||||||
http://localhost:8000/social/callback/YT/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production
|
|
||||||
|
|
||||||
```
|
|
||||||
https://yourdomain.com/social/callback/YT/
|
|
||||||
```
|
|
||||||
|
|
||||||
> ⚠️ **Note:** Google accepts both HTTP and HTTPS for `localhost`/`127.0.0.1`, but production must use HTTPS.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Permissions & Scopes
|
|
||||||
|
|
||||||
The application requests the following OAuth scopes:
|
|
||||||
|
|
||||||
| Scope | Description | Required |
|
|
||||||
|-------|-------------|----------|
|
|
||||||
| `https://www.googleapis.com/auth/youtube.readonly` | Read channel, videos, comments | ✅ Yes |
|
|
||||||
| `https://www.googleapis.com/auth/youtube.force-ssl` | Post replies to comments | ✅ Yes |
|
|
||||||
|
|
||||||
### Code Reference
|
|
||||||
|
|
||||||
```python
|
|
||||||
# apps/social/utils/youtube.py
|
|
||||||
YOUTUBE_SCOPES = [
|
|
||||||
'https://www.googleapis.com/auth/youtube.readonly',
|
|
||||||
'https://www.googleapis.com/auth/youtube.force-ssl'
|
|
||||||
]
|
|
||||||
|
|
||||||
YOUTUBE_API_SERVICE_NAME = 'youtube'
|
|
||||||
YOUTUBE_API_VERSION = 'v3'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development vs Production
|
|
||||||
|
|
||||||
### Development Setup
|
|
||||||
|
|
||||||
| Setting | Value |
|
|
||||||
|---------|-------|
|
|
||||||
| `YOUTUBE_REDIRECT_URI` | `http://127.0.0.1:8000/social/callback/YT/` |
|
|
||||||
| Protocol | HTTP allowed for localhost |
|
|
||||||
| App Verification | Not required for testing |
|
|
||||||
| User Access | Only added test users |
|
|
||||||
|
|
||||||
### Production Setup
|
|
||||||
|
|
||||||
| Setting | Value |
|
|
||||||
|---------|-------|
|
|
||||||
| `YOUTUBE_REDIRECT_URI` | `https://yourdomain.com/social/callback/YT/` |
|
|
||||||
| Protocol | **HTTPS required** |
|
|
||||||
| App Verification | Required for sensitive scopes |
|
|
||||||
| User Access | Any Google account |
|
|
||||||
|
|
||||||
### Google App Verification
|
|
||||||
|
|
||||||
For production, if your app requests sensitive scopes (like YouTube), you may need verification:
|
|
||||||
|
|
||||||
1. Submit your app for verification in Google Cloud Console
|
|
||||||
2. Provide a demo video showing the integration
|
|
||||||
3. Wait for Google's review (can take several days)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## YouTube API Quotas
|
|
||||||
|
|
||||||
YouTube Data API has strict quota limits:
|
|
||||||
|
|
||||||
### Default Quota
|
|
||||||
|
|
||||||
| Resource | Daily Quota |
|
|
||||||
|----------|-------------|
|
|
||||||
| Total API Units | 10,000 units/day |
|
|
||||||
| Search | 100 units/request |
|
|
||||||
| Videos List | 1 unit/request |
|
|
||||||
| Comments List | 1 unit/request |
|
|
||||||
| Comments Insert | 50 units/request |
|
|
||||||
|
|
||||||
### Quota Calculation Example
|
|
||||||
|
|
||||||
```
|
|
||||||
Daily sync of 50 videos:
|
|
||||||
- 50 video list requests = 50 units
|
|
||||||
- Comments for each video (avg 5 pages) = 250 units
|
|
||||||
- Total: ~300 units/day (well within limits)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Requesting Higher Quota
|
|
||||||
|
|
||||||
If you need more quota:
|
|
||||||
|
|
||||||
1. Go to Google Cloud Console → **"IAM & Admin"** → **"Quotas"**
|
|
||||||
2. Filter by "YouTube Data API"
|
|
||||||
3. Click **"Edit Quotas"**
|
|
||||||
4. Submit a request explaining your use case
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Error: "Access Denied - Requested client not authorized"
|
|
||||||
|
|
||||||
**Cause:** OAuth consent screen not configured or app not verified.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Ensure OAuth consent screen is properly configured
|
|
||||||
2. Add user as a test user if app is in testing mode
|
|
||||||
3. Submit app for verification if needed for production
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "Invalid Grant"
|
|
||||||
|
|
||||||
**Cause:** Authorization code expired or already used.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
- Authorization codes are single-use and expire quickly
|
|
||||||
- Ensure your code handles the callback immediately
|
|
||||||
- Check that redirect URI matches exactly
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "Quota Exceeded"
|
|
||||||
|
|
||||||
**Cause:** Daily API quota exceeded.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Check quota usage in Google Cloud Console
|
|
||||||
2. Optimize API calls to use fewer units
|
|
||||||
3. Request quota increase if needed
|
|
||||||
4. Wait for quota reset (daily at midnight Pacific Time)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "Comments Disabled for This Video"
|
|
||||||
|
|
||||||
**Cause:** Video owner has disabled comments.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
- This is expected behavior for some videos
|
|
||||||
- Application handles this gracefully
|
|
||||||
- Skip videos with disabled comments
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "No Uploads Playlist ID Found"
|
|
||||||
|
|
||||||
**Cause:** Channel has no uploaded videos or credentials incomplete.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Ensure the YouTube channel has uploaded videos
|
|
||||||
2. Check that the uploads playlist ID is stored in credentials
|
|
||||||
3. Re-authenticate if needed
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "Token Refresh Failed"
|
|
||||||
|
|
||||||
**Cause:** Refresh token expired or revoked.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
- Google OAuth tokens expire after 6 months of inactivity
|
|
||||||
- User must re-authenticate
|
|
||||||
- Ensure `offline` access type is requested
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Common Error: "Comment Thread Not Found"
|
|
||||||
|
|
||||||
**Cause:** Comment was deleted or video was removed.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
- Application handles 404 errors gracefully
|
|
||||||
- Skip deleted comments during sync
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Rate Limits & Best Practices
|
|
||||||
|
|
||||||
### Rate Limits
|
|
||||||
|
|
||||||
| Resource | Limit |
|
|
||||||
|----------|-------|
|
|
||||||
| Requests per second | Varies by endpoint |
|
|
||||||
| Daily quota units | 10,000 (default) |
|
|
||||||
|
|
||||||
### Best Practices
|
|
||||||
|
|
||||||
1. **Batch Requests:** Minimize API calls by fetching multiple items
|
|
||||||
2. **Pagination:** Use page tokens for large result sets
|
|
||||||
3. **Caching:** Cache video/comment data locally
|
|
||||||
4. **Delta Sync:** Only fetch new comments since last sync
|
|
||||||
5. **Error Handling:** Gracefully handle quota and rate limit errors
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Token Lifecycle
|
|
||||||
|
|
||||||
| Token Type | Lifetime | Notes |
|
|
||||||
|------------|----------|-------|
|
|
||||||
| Access Token | ~1 hour | Short-lived |
|
|
||||||
| Refresh Token | 6 months | Expires with inactivity |
|
|
||||||
| Offline Access | Indefinite | Requires `offline` access type |
|
|
||||||
|
|
||||||
> ⚠️ **Note:** If a refresh token expires, the user must re-authenticate.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
After setup, verify the integration:
|
|
||||||
|
|
||||||
1. Ensure `yt_client_secrets.json` is in place
|
|
||||||
2. Navigate to `/social/` in your application
|
|
||||||
3. Click "Connect YouTube"
|
|
||||||
4. Authorize with your Google account
|
|
||||||
5. Verify your channel is detected
|
|
||||||
6. Check if videos are fetched
|
|
||||||
7. Test replying to a comment
|
|
||||||
|
|
||||||
### Testing in Django Shell
|
|
||||||
|
|
||||||
```python
|
|
||||||
from apps.social.services.youtube import YouTubeService
|
|
||||||
from apps.social.models import SocialAccount
|
|
||||||
|
|
||||||
account = SocialAccount.objects.filter(platform='YT').first()
|
|
||||||
|
|
||||||
# Test getting videos
|
|
||||||
videos = YouTubeService.fetch_user_videos(account)
|
|
||||||
print(f"Found {len(videos)} videos")
|
|
||||||
|
|
||||||
# Test getting comments
|
|
||||||
if videos:
|
|
||||||
video_id = videos[0]['id']
|
|
||||||
comments = YouTubeService.fetch_video_comments(account, video_id)
|
|
||||||
print(f"Found {len(comments)} comments for video {video_id}")
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sharing Credentials with Google Business
|
|
||||||
|
|
||||||
If you're already using Google Business API, you can use the same Google Cloud project:
|
|
||||||
|
|
||||||
1. Both APIs use OAuth 2.0 with similar configuration
|
|
||||||
2. You can create separate OAuth clients or use the same one
|
|
||||||
3. The `secrets/` directory can contain multiple credential files:
|
|
||||||
- `gmb_client_secrets.json` (Google Business)
|
|
||||||
- `yt_client_secrets.json` (YouTube)
|
|
||||||
|
|
||||||
### Single OAuth Client for Both
|
|
||||||
|
|
||||||
You can use a single OAuth client for both services:
|
|
||||||
|
|
||||||
1. Enable both APIs in Google Cloud Console
|
|
||||||
2. Request scopes for both services during OAuth
|
|
||||||
3. Use the same credentials file
|
|
||||||
4. Update settings to point to the same file:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# If using single OAuth client
|
|
||||||
GOOGLE_CLIENT_SECRETS_FILE = BASE_DIR / 'secrets' / 'google_client_secrets.json'
|
|
||||||
GMB_CLIENT_SECRETS_FILE = GOOGLE_CLIENT_SECRETS_FILE
|
|
||||||
YOUTUBE_CLIENT_SECRETS_FILE = GOOGLE_CLIENT_SECRETS_FILE
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Support Resources
|
|
||||||
|
|
||||||
- [YouTube Data API Documentation](https://developers.google.com/youtube/v3)
|
|
||||||
- [YouTube API Code Samples](https://developers.google.com/youtube/v3/code_samples)
|
|
||||||
- [OAuth 2.0 for Web Server Applications](https://developers.google.com/identity/protocols/oauth2/web-server)
|
|
||||||
- [YouTube API Quota Calculator](https://developers.google.com/youtube/v3/determine_quota_cost)
|
|
||||||
- [Google Cloud Console Support](https://support.google.com/cloud/)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Last Updated: February 2026*
|
|
||||||
*API Version: YouTube Data API v3*
|
|
||||||
BIN
apps/social/social_setup_documents.zip
Normal file
BIN
apps/social/social_setup_documents.zip
Normal file
Binary file not shown.
@ -4,7 +4,7 @@ from apps.organizations.models import Hospital
|
|||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = 'Update/Create survey templates with satisfaction questions and bilingual support'
|
help = "Update/Create survey templates with satisfaction questions and bilingual support"
|
||||||
|
|
||||||
# Satisfaction scale options
|
# Satisfaction scale options
|
||||||
SATISFACTION_CHOICES = [
|
SATISFACTION_CHOICES = [
|
||||||
@ -12,178 +12,163 @@ class Command(BaseCommand):
|
|||||||
{"value": "2", "label": "Poor", "label_ar": "ضعيف"},
|
{"value": "2", "label": "Poor", "label_ar": "ضعيف"},
|
||||||
{"value": "3", "label": "Neutral", "label_ar": "محايد"},
|
{"value": "3", "label": "Neutral", "label_ar": "محايد"},
|
||||||
{"value": "4", "label": "Good", "label_ar": "جيد"},
|
{"value": "4", "label": "Good", "label_ar": "جيد"},
|
||||||
{"value": "5", "label": "Very Satisfied", "label_ar": "راضٍ جداً"}
|
{"value": "5", "label": "Very Satisfied", "label_ar": "راضٍ جداً"},
|
||||||
]
|
]
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
self.stdout.write(self.style.SUCCESS('Updating Survey Templates with Satisfaction Questions...'))
|
self.stdout.write(self.style.SUCCESS("Updating Survey Templates with Satisfaction Questions..."))
|
||||||
|
|
||||||
# Get active hospital
|
# Get active hospital
|
||||||
hospital = Hospital.objects.filter(status='active').first()
|
hospital = Hospital.objects.filter(status="active").first()
|
||||||
if not hospital:
|
if not hospital:
|
||||||
self.stdout.write(self.style.ERROR('No active hospital found!'))
|
self.stdout.write(self.style.ERROR("No active hospital found!"))
|
||||||
return
|
return
|
||||||
|
|
||||||
self.stdout.write(f'Using hospital: {hospital.name}')
|
self.stdout.write(f"Using hospital: {hospital.name}")
|
||||||
|
|
||||||
# Define survey templates with bilingual questions
|
# Define survey templates with bilingual questions
|
||||||
survey_templates = [
|
survey_templates = [
|
||||||
{
|
{
|
||||||
'name': 'Appointment Satisfaction Survey',
|
"name": "Appointment Satisfaction Survey",
|
||||||
'name_ar': 'استبيان رضا المواعيد',
|
"name_ar": "استبيان رضا المواعيد",
|
||||||
'survey_type': 'stage',
|
"survey_type": "stage",
|
||||||
'questions': [
|
"questions": [
|
||||||
{
|
{
|
||||||
'text': 'Did the Appointment Section\'s service exceed your expectations?',
|
"text": "Did the Appointment Section's service exceed your expectations?",
|
||||||
'text_ar': 'هل تجاوزت خدمة قسم المواعيد توقعاتك؟'
|
"text_ar": "هل تجاوزت خدمة قسم المواعيد توقعاتك؟",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'text': 'Did the doctor explain everything about your case?',
|
"text": "Did the doctor explain everything about your case?",
|
||||||
'text_ar': 'هل شرح الطبيب كل شيء عن حالتك؟'
|
"text_ar": "هل شرح الطبيب كل شيء عن حالتك؟",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'text': 'Did the pharmacist explain to you the medication clearly?',
|
"text": "Did the pharmacist explain to you the medication clearly?",
|
||||||
'text_ar': 'هل شرح الصيدلي لك الدواء بشكل واضح؟'
|
"text_ar": "هل شرح الصيدلي لك الدواء بشكل واضح؟",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'text': 'Did the staff attend your needs in an understandable language?',
|
"text": "Did the staff attend your needs in an understandable language?",
|
||||||
'text_ar': 'هل قام الموظفون بتلبية احتياجاتك بلغة مفهومة؟'
|
"text_ar": "هل قام الموظفون بتلبية احتياجاتك بلغة مفهومة؟",
|
||||||
|
},
|
||||||
|
{"text": "Was it easy to get an appointment?", "text_ar": "هل كان من السهل الحصول على موعد؟"},
|
||||||
|
{
|
||||||
|
"text": "Were you satisfied with your interaction with the doctor?",
|
||||||
|
"text_ar": "هل كنت راضٍ عن تفاعلك مع الطبيب؟",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'text': 'Was it easy to get an appointment?',
|
"text": "Were you served by Laboratory Receptionists as required?",
|
||||||
'text_ar': 'هل كان من السهل الحصول على موعد؟'
|
"text_ar": "هل قدمت لك خدمة استقبال المختبر كما هو مطلوب؟",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'text': 'Were you satisfied with your interaction with the doctor?',
|
"text": "Were you served by Radiology Receptionists as required?",
|
||||||
'text_ar': 'هل كنت راضٍ عن تفاعلك مع الطبيب؟'
|
"text_ar": "هل قدمت لك خدمة استقبال الأشعة كما هو مطلوب؟",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'text': 'Were you served by Laboratory Receptionists as required?',
|
"text": "Were you served by Receptionists as required?",
|
||||||
'text_ar': 'هل قدمت لك خدمة استقبال المختبر كما هو مطلوب؟'
|
"text_ar": "هل قدمت لك خدمة الاستقبال كما هو مطلوب؟",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'text': 'Were you served by Radiology Receptionists as required?',
|
"text": "Would you recommend the hospital to your friends and family?",
|
||||||
'text_ar': 'هل قدمت لك خدمة استقبال الأشعة كما هو مطلوب؟'
|
"text_ar": "هل توصي بالمستشفى لأصدقائك وعائلتك؟",
|
||||||
},
|
},
|
||||||
{
|
],
|
||||||
'text': 'Were you served by Receptionists as required?',
|
|
||||||
'text_ar': 'هل قدمت لك خدمة الاستقبال كما هو مطلوب؟'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'text': 'Would you recommend the hospital to your friends and family?',
|
|
||||||
'text_ar': 'هل توصي بالمستشفى لأصدقائك وعائلتك؟'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'Inpatient Satisfaction Survey',
|
"name": "Inpatient Satisfaction Survey",
|
||||||
'name_ar': 'استبيان رضا المرضى المقيمين',
|
"name_ar": "استبيان رضا المرضى المقيمين",
|
||||||
'survey_type': 'stage',
|
"survey_type": "stage",
|
||||||
'questions': [
|
"questions": [
|
||||||
{
|
{
|
||||||
'text': 'Are the Patient Relations Coordinators/ Social Workers approachable and accessible?',
|
"text": "Are the Patient Relations Staff/ Social Workers approachable and accessible?",
|
||||||
'text_ar': 'هل منسقو علاقات المرضى / الأخصائيون الاجتماعيون متاحون وسهل الوصول إليهم؟'
|
"text_ar": "هل منسقو علاقات المرضى / الأخصائيون الاجتماعيون متاحون وسهل الوصول إليهم؟",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'text': 'Did the physician give you clear information about your medications?',
|
"text": "Did the physician give you clear information about your medications?",
|
||||||
'text_ar': 'هل قدم الطبيب لك معلومات واضحة عن أدويتك؟'
|
"text_ar": "هل قدم الطبيب لك معلومات واضحة عن أدويتك؟",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'text': 'Did your physician exerted efforts to include you in making the decisions about your treatment?',
|
"text": "Did your physician exerted efforts to include you in making the decisions about your treatment?",
|
||||||
'text_ar': 'هل بذل طبيبك جهداً لإشراكك في اتخاذ القرارات حول علاجك؟'
|
"text_ar": "هل بذل طبيبك جهداً لإشراكك في اتخاذ القرارات حول علاجك؟",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'text': 'Is the cleanliness level of the hospital exceeding your expectations?',
|
"text": "Is the cleanliness level of the hospital exceeding your expectations?",
|
||||||
'text_ar': 'هل مستوى نظافة المستشفى يتجاوز توقعاتك؟'
|
"text_ar": "هل مستوى نظافة المستشفى يتجاوز توقعاتك؟",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'text': 'Was there a clear explanation given to you regarding your financial coverage and payment responsibility?',
|
"text": "Was there a clear explanation given to you regarding your financial coverage and payment responsibility?",
|
||||||
'text_ar': 'هل تم تقديم شرح واضح لك بخصوص التغطية المالية ومسؤولية الدفع؟'
|
"text_ar": "هل تم تقديم شرح واضح لك بخصوص التغطية المالية ومسؤولية الدفع؟",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'text': 'Were you satisfied with our admission time and process?',
|
"text": "Were you satisfied with our admission time and process?",
|
||||||
'text_ar': 'هل كنت راضٍ عن وقت وعمليات القبول لدينا؟'
|
"text_ar": "هل كنت راضٍ عن وقت وعمليات القبول لدينا؟",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'text': 'Were you satisfied with our discharge time and process?',
|
"text": "Were you satisfied with our discharge time and process?",
|
||||||
'text_ar': 'هل كنت راضٍ عن وقت وعمليات الخروج لدينا؟'
|
"text_ar": "هل كنت راضٍ عن وقت وعمليات الخروج لدينا؟",
|
||||||
},
|
},
|
||||||
|
{"text": "Were you satisfied with the doctor's care?", "text_ar": "هل كنت راضٍ عن رعاية الطبيب؟"},
|
||||||
|
{"text": "Were you satisfied with the food services?", "text_ar": "هل كنت راضٍ عن خدمات الطعام؟"},
|
||||||
{
|
{
|
||||||
'text': 'Were you satisfied with the doctor\'s care?',
|
"text": "Were you satisfied with the level of safety at the hospital?",
|
||||||
'text_ar': 'هل كنت راضٍ عن رعاية الطبيب؟'
|
"text_ar": "هل كنت راضٍ عن مستوى السلامة في المستشفى؟",
|
||||||
},
|
},
|
||||||
|
{"text": "Were you satisfied with the nurses' care?", "text_ar": "هل كنت راضٍ عن رعاية التمريض؟"},
|
||||||
{
|
{
|
||||||
'text': 'Were you satisfied with the food services?',
|
"text": "Would you recommend the hospital to your friends and family?",
|
||||||
'text_ar': 'هل كنت راضٍ عن خدمات الطعام؟'
|
"text_ar": "هل توصي بالمستشفى لأصدقائك وعائلتك؟",
|
||||||
},
|
},
|
||||||
{
|
],
|
||||||
'text': 'Were you satisfied with the level of safety at the hospital?',
|
|
||||||
'text_ar': 'هل كنت راضٍ عن مستوى السلامة في المستشفى؟'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'text': 'Were you satisfied with the nurses\' care?',
|
|
||||||
'text_ar': 'هل كنت راضٍ عن رعاية التمريض؟'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'text': 'Would you recommend the hospital to your friends and family?',
|
|
||||||
'text_ar': 'هل توصي بالمستشفى لأصدقائك وعائلتك؟'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'Outpatient Satisfaction Survey',
|
"name": "Outpatient Satisfaction Survey",
|
||||||
'name_ar': 'استبيان رضا العيادات الخارجية',
|
"name_ar": "استبيان رضا العيادات الخارجية",
|
||||||
'survey_type': 'stage',
|
"survey_type": "stage",
|
||||||
'questions': [
|
"questions": [
|
||||||
{
|
{
|
||||||
'text': 'Did the doctor explained everything about your case?',
|
"text": "Did the doctor explained everything about your case?",
|
||||||
'text_ar': 'هل شرح الطبيب كل شيء عن حالتك؟'
|
"text_ar": "هل شرح الطبيب كل شيء عن حالتك؟",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'text': 'Did the pharmacist explained to you the medication clearly?',
|
"text": "Did the pharmacist explained to you the medication clearly?",
|
||||||
'text_ar': 'هل شرح الصيدلي لك الدواء بشكل واضح؟'
|
"text_ar": "هل شرح الصيدلي لك الدواء بشكل واضح؟",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'text': 'Did the staff attended your needs in an understandable language?',
|
"text": "Did the staff attended your needs in an understandable language?",
|
||||||
'text_ar': 'هل قام الموظفون بتلبية احتياجاتك بلغة مفهومة؟'
|
"text_ar": "هل قام الموظفون بتلبية احتياجاتك بلغة مفهومة؟",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'text': 'Were you satisfied with your interaction with the doctor?',
|
"text": "Were you satisfied with your interaction with the doctor?",
|
||||||
'text_ar': 'هل كنت راضٍ عن تفاعلك مع الطبيب؟'
|
"text_ar": "هل كنت راضٍ عن تفاعلك مع الطبيب؟",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'text': 'Were you served by Laboratory Receptionists as required?',
|
"text": "Were you served by Laboratory Receptionists as required?",
|
||||||
'text_ar': 'هل قدمت لك خدمة استقبال المختبر كما هو مطلوب؟'
|
"text_ar": "هل قدمت لك خدمة استقبال المختبر كما هو مطلوب؟",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'text': 'Were you served by Radiology Receptionists as required?',
|
"text": "Were you served by Radiology Receptionists as required?",
|
||||||
'text_ar': 'هل قدمت لك خدمة استقبال الأشعة كما هو مطلوب؟'
|
"text_ar": "هل قدمت لك خدمة استقبال الأشعة كما هو مطلوب؟",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'text': 'Were you served by Receptionists as required?',
|
"text": "Were you served by Receptionists as required?",
|
||||||
'text_ar': 'هل قدمت لك خدمة الاستقبال كما هو مطلوب؟'
|
"text_ar": "هل قدمت لك خدمة الاستقبال كما هو مطلوب؟",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'text': 'Would you recommend the hospital to your friends and family?',
|
"text": "Would you recommend the hospital to your friends and family?",
|
||||||
'text_ar': 'هل توصي بالمستشفى لأصدقائك وعائلتك؟'
|
"text_ar": "هل توصي بالمستشفى لأصدقائك وعائلتك؟",
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
created_count = 0
|
created_count = 0
|
||||||
updated_count = 0
|
updated_count = 0
|
||||||
total_questions = 0
|
total_questions = 0
|
||||||
|
|
||||||
for template_data in survey_templates:
|
for template_data in survey_templates:
|
||||||
# Check if template already exists
|
# Check if template already exists
|
||||||
existing = SurveyTemplate.objects.filter(
|
existing = SurveyTemplate.objects.filter(name=template_data["name"], hospital=hospital).first()
|
||||||
name=template_data['name'],
|
|
||||||
hospital=hospital
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
self.stdout.write(f'✓ Updating existing template: {template_data["name"]}')
|
self.stdout.write(f"✓ Updating existing template: {template_data['name']}")
|
||||||
template = existing
|
template = existing
|
||||||
updated_count += 1
|
updated_count += 1
|
||||||
# Delete existing questions to avoid duplicates
|
# Delete existing questions to avoid duplicates
|
||||||
@ -191,60 +176,62 @@ class Command(BaseCommand):
|
|||||||
else:
|
else:
|
||||||
# Create new template
|
# Create new template
|
||||||
template = SurveyTemplate.objects.create(
|
template = SurveyTemplate.objects.create(
|
||||||
name=template_data['name'],
|
name=template_data["name"],
|
||||||
name_ar=template_data['name_ar'],
|
name_ar=template_data["name_ar"],
|
||||||
hospital=hospital,
|
hospital=hospital,
|
||||||
survey_type=template_data['survey_type'],
|
survey_type=template_data["survey_type"],
|
||||||
scoring_method='average',
|
scoring_method="average",
|
||||||
negative_threshold=3.0,
|
negative_threshold=3.0,
|
||||||
is_active=True
|
is_active=True,
|
||||||
)
|
)
|
||||||
self.stdout.write(f'✓ Created template: {template_data["name"]}')
|
self.stdout.write(f"✓ Created template: {template_data['name']}")
|
||||||
created_count += 1
|
created_count += 1
|
||||||
|
|
||||||
# Create questions
|
# Create questions
|
||||||
for order, question_data in enumerate(template_data['questions'], start=1):
|
for order, question_data in enumerate(template_data["questions"], start=1):
|
||||||
question = SurveyQuestion.objects.create(
|
question = SurveyQuestion.objects.create(
|
||||||
survey_template=template,
|
survey_template=template,
|
||||||
text=question_data['text'],
|
text=question_data["text"],
|
||||||
text_ar=question_data['text_ar'],
|
text_ar=question_data["text_ar"],
|
||||||
question_type=QuestionType.MULTIPLE_CHOICE,
|
question_type=QuestionType.MULTIPLE_CHOICE,
|
||||||
order=order,
|
order=order,
|
||||||
is_required=True,
|
is_required=True,
|
||||||
choices_json=self.SATISFACTION_CHOICES
|
choices_json=self.SATISFACTION_CHOICES,
|
||||||
)
|
)
|
||||||
total_questions += 1
|
total_questions += 1
|
||||||
self.stdout.write(f' ✓ Added question {order}: {question_data["text"][:50]}...')
|
self.stdout.write(f" ✓ Added question {order}: {question_data['text'][:50]}...")
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS(
|
self.stdout.write(
|
||||||
f'\n✓ Survey Templates created/updated successfully!\n'
|
self.style.SUCCESS(
|
||||||
f' Created: {created_count}\n'
|
f"\n✓ Survey Templates created/updated successfully!\n"
|
||||||
f' Updated: {updated_count}\n'
|
f" Created: {created_count}\n"
|
||||||
f' Total Questions: {total_questions}'
|
f" Updated: {updated_count}\n"
|
||||||
))
|
f" Total Questions: {total_questions}"
|
||||||
|
|
||||||
# List all templates
|
|
||||||
self.stdout.write('\n' + '='*70)
|
|
||||||
self.stdout.write('SURVEY TEMPLATES SUMMARY:')
|
|
||||||
self.stdout.write('='*70)
|
|
||||||
for template in SurveyTemplate.objects.filter(is_active=True).order_by('name'):
|
|
||||||
self.stdout.write(
|
|
||||||
f'\n {template.name} ({template.name_ar})\n'
|
|
||||||
f' Type: {template.survey_type}\n'
|
|
||||||
f' Questions: {template.get_question_count()}'
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
self.stdout.write('\n' + '='*70)
|
|
||||||
self.stdout.write(self.style.SUCCESS('SATISFACTION SCALE OPTIONS:'))
|
# List all templates
|
||||||
self.stdout.write('='*70)
|
self.stdout.write("\n" + "=" * 70)
|
||||||
|
self.stdout.write("SURVEY TEMPLATES SUMMARY:")
|
||||||
|
self.stdout.write("=" * 70)
|
||||||
|
for template in SurveyTemplate.objects.filter(is_active=True).order_by("name"):
|
||||||
|
self.stdout.write(
|
||||||
|
f"\n {template.name} ({template.name_ar})\n"
|
||||||
|
f" Type: {template.survey_type}\n"
|
||||||
|
f" Questions: {template.get_question_count()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write("\n" + "=" * 70)
|
||||||
|
self.stdout.write(self.style.SUCCESS("SATISFACTION SCALE OPTIONS:"))
|
||||||
|
self.stdout.write("=" * 70)
|
||||||
for choice in self.SATISFACTION_CHOICES:
|
for choice in self.SATISFACTION_CHOICES:
|
||||||
self.stdout.write(f' {choice["value"]}. {choice["label"]} / {choice["label_ar"]}')
|
self.stdout.write(f" {choice['value']}. {choice['label']} / {choice['label_ar']}")
|
||||||
|
|
||||||
self.stdout.write('\n' + '='*70)
|
self.stdout.write("\n" + "=" * 70)
|
||||||
self.stdout.write(self.style.SUCCESS('NEXT STEPS:'))
|
self.stdout.write(self.style.SUCCESS("NEXT STEPS:"))
|
||||||
self.stdout.write('='*70)
|
self.stdout.write("=" * 70)
|
||||||
self.stdout.write('1. Review the survey templates in Django Admin')
|
self.stdout.write("1. Review the survey templates in Django Admin")
|
||||||
self.stdout.write('2. Test the surveys by creating survey instances')
|
self.stdout.write("2. Test the surveys by creating survey instances")
|
||||||
self.stdout.write('3. Verify the satisfaction options appear correctly')
|
self.stdout.write("3. Verify the satisfaction options appear correctly")
|
||||||
self.stdout.write('4. Check that both English and Arabic versions work')
|
self.stdout.write("4. Check that both English and Arabic versions work")
|
||||||
self.stdout.write('='*70)
|
self.stdout.write("=" * 70)
|
||||||
|
|||||||
@ -39,7 +39,12 @@ class SurveyDeliveryService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def generate_sms_message(
|
def generate_sms_message(
|
||||||
recipient_name: str, survey_url: str, hospital_name: str = None, is_staff: bool = False, language: str = "en"
|
recipient_name: str,
|
||||||
|
survey_url: str,
|
||||||
|
hospital_name: str = None,
|
||||||
|
hospital_name_ar: str = None,
|
||||||
|
is_staff: bool = False,
|
||||||
|
language: str = "en",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Generate SMS message with survey link.
|
Generate SMS message with survey link.
|
||||||
@ -47,7 +52,8 @@ class SurveyDeliveryService:
|
|||||||
Args:
|
Args:
|
||||||
recipient_name: Recipient's first name (patient or staff) - not used in new format
|
recipient_name: Recipient's first name (patient or staff) - not used in new format
|
||||||
survey_url: Survey link
|
survey_url: Survey link
|
||||||
hospital_name: Optional hospital name
|
hospital_name: Hospital name (English)
|
||||||
|
hospital_name_ar: Hospital name (Arabic)
|
||||||
is_staff: Whether recipient is staff member - not used, same format for all
|
is_staff: Whether recipient is staff member - not used, same format for all
|
||||||
language: Language code ('en' or 'ar') - not used, bilingual format for all
|
language: Language code ('en' or 'ar') - not used, bilingual format for all
|
||||||
|
|
||||||
@ -55,9 +61,9 @@ class SurveyDeliveryService:
|
|||||||
SMS message text
|
SMS message text
|
||||||
"""
|
"""
|
||||||
facility = hospital_name or "our facility"
|
facility = hospital_name or "our facility"
|
||||||
|
facility_ar = hospital_name_ar or facility
|
||||||
|
|
||||||
# Bilingual format - Arabic first, then English
|
message = f"شكراً على زيارتك {facility_ar} والحمد لله على سلامتك\n\n"
|
||||||
message = f"شكراً على زيارتك {facility} والحمد لله على سلامتك\n\n"
|
|
||||||
message += f"نتطلع لسماع رأيك حول تجربتك الأخيرة عبر الرابط التالي لنسهم في تحسين خدماتنا\n"
|
message += f"نتطلع لسماع رأيك حول تجربتك الأخيرة عبر الرابط التالي لنسهم في تحسين خدماتنا\n"
|
||||||
message += f"{survey_url}\n\n"
|
message += f"{survey_url}\n\n"
|
||||||
message += f"دمتم بصحة\n\n"
|
message += f"دمتم بصحة\n\n"
|
||||||
@ -137,11 +143,12 @@ class SurveyDeliveryService:
|
|||||||
# Generate survey URL and message
|
# Generate survey URL and message
|
||||||
survey_url = SurveyDeliveryService.generate_survey_url(survey_instance)
|
survey_url = SurveyDeliveryService.generate_survey_url(survey_instance)
|
||||||
full_name = survey_instance.get_recipient_name()
|
full_name = survey_instance.get_recipient_name()
|
||||||
recipient_name = full_name.split()[0] if full_name and full_name.strip() else "there" # First name only
|
recipient_name = full_name.split()[0] if full_name and full_name.strip() else "there"
|
||||||
is_staff = survey_instance.staff is not None
|
is_staff = survey_instance.staff is not None
|
||||||
hospital_name = survey_instance.hospital.name if survey_instance.hospital else None
|
hospital_name = survey_instance.hospital.get_display_name() if survey_instance.hospital else None
|
||||||
|
hospital_name_ar = survey_instance.hospital.get_display_name_ar() if survey_instance.hospital else None
|
||||||
message = SurveyDeliveryService.generate_sms_message(
|
message = SurveyDeliveryService.generate_sms_message(
|
||||||
recipient_name, survey_url, hospital_name, is_staff, language
|
recipient_name, survey_url, hospital_name, hospital_name_ar, is_staff, language
|
||||||
)
|
)
|
||||||
|
|
||||||
# Use NotificationService for delivery (supports API backend)
|
# Use NotificationService for delivery (supports API backend)
|
||||||
@ -210,7 +217,7 @@ class SurveyDeliveryService:
|
|||||||
full_name = survey_instance.get_recipient_name()
|
full_name = survey_instance.get_recipient_name()
|
||||||
recipient_name = full_name.split()[0] if full_name and full_name.strip() else "there" # First name only
|
recipient_name = full_name.split()[0] if full_name and full_name.strip() else "there" # First name only
|
||||||
is_staff = survey_instance.staff is not None
|
is_staff = survey_instance.staff is not None
|
||||||
hospital_name = survey_instance.hospital.name if survey_instance.hospital else None
|
hospital_name = survey_instance.hospital.get_display_name() if survey_instance.hospital else None
|
||||||
message = SurveyDeliveryService.generate_email_message(recipient_name, survey_url, hospital_name, is_staff)
|
message = SurveyDeliveryService.generate_email_message(recipient_name, survey_url, hospital_name, is_staff)
|
||||||
|
|
||||||
# Use NotificationService for delivery (supports API backend)
|
# Use NotificationService for delivery (supports API backend)
|
||||||
@ -229,10 +236,11 @@ class SurveyDeliveryService:
|
|||||||
metadata["staff_id"] = str(survey_instance.staff.id)
|
metadata["staff_id"] = str(survey_instance.staff.id)
|
||||||
|
|
||||||
# Set email subject
|
# Set email subject
|
||||||
|
hospital_display = survey_instance.hospital.get_display_name() if survey_instance.hospital else ""
|
||||||
if is_staff:
|
if is_staff:
|
||||||
subject = f"Staff Experience Survey - {survey_instance.hospital.name}"
|
subject = f"Staff Experience Survey - {hospital_display}"
|
||||||
else:
|
else:
|
||||||
subject = f"Patient Experience Survey - {survey_instance.hospital.name}"
|
subject = f"Patient Experience Survey - {hospital_display}"
|
||||||
|
|
||||||
# Try API first, fallback to regular email
|
# Try API first, fallback to regular email
|
||||||
notification_log = NotificationService.send_email_via_api(
|
notification_log = NotificationService.send_email_via_api(
|
||||||
@ -346,13 +354,14 @@ class SurveyDeliveryService:
|
|||||||
full_name = survey_instance.get_recipient_name()
|
full_name = survey_instance.get_recipient_name()
|
||||||
recipient_name = full_name.split()[0] if full_name and full_name.strip() else "there"
|
recipient_name = full_name.split()[0] if full_name and full_name.strip() else "there"
|
||||||
is_staff = survey_instance.staff is not None
|
is_staff = survey_instance.staff is not None
|
||||||
hospital_name = survey_instance.hospital.name if survey_instance.hospital else None
|
hospital_name = survey_instance.hospital.get_display_name() if survey_instance.hospital else None
|
||||||
|
hospital_name_ar = survey_instance.hospital.get_display_name_ar() if survey_instance.hospital else None
|
||||||
|
|
||||||
# Generate WhatsApp-specific message
|
|
||||||
if language == "ar":
|
if language == "ar":
|
||||||
|
facility = hospital_name_ar or hospital_name or "our facility"
|
||||||
message = f"مرحباً {recipient_name}،\n\n"
|
message = f"مرحباً {recipient_name}،\n\n"
|
||||||
if hospital_name:
|
if facility:
|
||||||
message += f"شكراً لكونك جزءاً من {hospital_name}. "
|
message += f"شكراً لكونك جزءاً من {facility}. "
|
||||||
if is_staff:
|
if is_staff:
|
||||||
message += "نقدر ملاحظاتك! يرجى إكمال استبيان تجربة الموظفين:\n\n"
|
message += "نقدر ملاحظاتك! يرجى إكمال استبيان تجربة الموظفين:\n\n"
|
||||||
else:
|
else:
|
||||||
@ -360,9 +369,10 @@ class SurveyDeliveryService:
|
|||||||
message += f"{survey_url}\n\n"
|
message += f"{survey_url}\n\n"
|
||||||
message += "سيستغرق هذا الاستبيان حوالي 2-3 دقائق."
|
message += "سيستغرق هذا الاستبيان حوالي 2-3 دقائق."
|
||||||
else:
|
else:
|
||||||
|
facility = hospital_name or "our facility"
|
||||||
message = f"Hello {recipient_name},\n\n"
|
message = f"Hello {recipient_name},\n\n"
|
||||||
if hospital_name:
|
if facility:
|
||||||
message += f"Thank you for being part of {hospital_name}. "
|
message += f"Thank you for being part of {facility}. "
|
||||||
if is_staff:
|
if is_staff:
|
||||||
message += "We value your feedback! Please complete our staff experience survey:\n\n"
|
message += "We value your feedback! Please complete our staff experience survey:\n\n"
|
||||||
else:
|
else:
|
||||||
|
|||||||
@ -25,6 +25,8 @@ from .forms import (
|
|||||||
BulkCSVSurveySendForm,
|
BulkCSVSurveySendForm,
|
||||||
QuestionRoutingRuleFormSet,
|
QuestionRoutingRuleFormSet,
|
||||||
)
|
)
|
||||||
|
from apps.integrations.models import HISEventType
|
||||||
|
|
||||||
from .services import SurveyDeliveryService
|
from .services import SurveyDeliveryService
|
||||||
from .models import SurveyInstance, SurveyTemplate, SurveyQuestion
|
from .models import SurveyInstance, SurveyTemplate, SurveyQuestion
|
||||||
from .tasks import send_satisfaction_feedback
|
from .tasks import send_satisfaction_feedback
|
||||||
@ -262,6 +264,17 @@ def survey_template_list(request):
|
|||||||
return render(request, "surveys/template_list.html", context)
|
return render(request, "surveys/template_list.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_visit_timeline_events():
|
||||||
|
"""Build grouped HIS event types for survey question helper."""
|
||||||
|
groups = {"IP": [], "OP": [], "ED": []}
|
||||||
|
for event in HISEventType.objects.all().order_by("-event_count"):
|
||||||
|
entry = {"event_type": event.event_type, "count": event.event_count}
|
||||||
|
for pt in event.patient_types or []:
|
||||||
|
if pt in groups:
|
||||||
|
groups[pt].append(entry)
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
@block_source_user
|
@block_source_user
|
||||||
@login_required
|
@login_required
|
||||||
def survey_template_create(request):
|
def survey_template_create(request):
|
||||||
@ -305,6 +318,7 @@ def survey_template_create(request):
|
|||||||
"form": form,
|
"form": form,
|
||||||
"formset": formset,
|
"formset": formset,
|
||||||
"routing_formset": routing_formset,
|
"routing_formset": routing_formset,
|
||||||
|
"visit_timeline_events": _get_visit_timeline_events(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, "surveys/template_form.html", context)
|
return render(request, "surveys/template_form.html", context)
|
||||||
@ -394,6 +408,7 @@ def survey_template_edit(request, pk):
|
|||||||
for q in template.questions.all().order_by("order")
|
for q in template.questions.all().order_by("order")
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
"visit_timeline_events": _get_visit_timeline_events(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, "surveys/template_form.html", context)
|
return render(request, "surveys/template_form.html", context)
|
||||||
|
|||||||
47
build-and-push.sh
Executable file
47
build-and-push.sh
Executable file
@ -0,0 +1,47 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
REGISTRY="gitea.tenhal.sa/marwan/hh"
|
||||||
|
TAG=${1:-staging}
|
||||||
|
|
||||||
|
echo "========================================"
|
||||||
|
echo " PX360 Build & Push"
|
||||||
|
echo " Tag: $REGISTRY:$TAG"
|
||||||
|
echo " $(date '+%Y-%m-%d %H:%M:%S')"
|
||||||
|
echo "========================================"
|
||||||
|
|
||||||
|
if ! docker login "$REGISTRY" 2>/dev/null; then
|
||||||
|
echo ""
|
||||||
|
echo "Login required. Enter your Gitea credentials:"
|
||||||
|
echo " Username: your Gitea username"
|
||||||
|
echo " Password: your Gitea token (Settings > Applications > Access Tokens)"
|
||||||
|
echo ""
|
||||||
|
docker login "$REGISTRY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "[1/3] Building image..."
|
||||||
|
docker build -t "$REGISTRY:$TAG" .
|
||||||
|
|
||||||
|
SHORT_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||||
|
if [ "$SHORT_SHA" != "unknown" ]; then
|
||||||
|
docker tag "$REGISTRY:$TAG" "$REGISTRY:$SHORT_SHA"
|
||||||
|
echo " Also tagged: $REGISTRY:$SHORT_SHA"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "[2/3] Pushing to registry..."
|
||||||
|
docker push "$REGISTRY:$TAG"
|
||||||
|
if [ "$SHORT_SHA" != "unknown" ]; then
|
||||||
|
docker push "$REGISTRY:$SHORT_SHA"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "[3/3] Done!"
|
||||||
|
echo ""
|
||||||
|
echo "Images pushed:"
|
||||||
|
echo " $REGISTRY:$TAG"
|
||||||
|
[ "$SHORT_SHA" != "unknown" ] && echo " $REGISTRY:$SHORT_SHA"
|
||||||
|
echo ""
|
||||||
|
echo "Deploy to staging: ./deploy.staging.sh $TAG"
|
||||||
|
echo "Deploy to prod: ./deploy.prod.sh $TAG"
|
||||||
234280
complaints_export.json
Normal file
234280
complaints_export.json
Normal file
File diff suppressed because one or more lines are too long
@ -43,10 +43,10 @@ app.conf.beat_schedule = {
|
|||||||
# 'schedule': crontab(minute='*/5'), # Every 5 minutes
|
# 'schedule': crontab(minute='*/5'), # Every 5 minutes
|
||||||
# },
|
# },
|
||||||
# Send pending scheduled surveys every 10 minutes
|
# Send pending scheduled surveys every 10 minutes
|
||||||
"send-pending-scheduled-surveys": {
|
# "send-pending-scheduled-surveys": {
|
||||||
"task": "apps.surveys.tasks.send_pending_scheduled_surveys",
|
# "task": "apps.surveys.tasks.send_pending_scheduled_surveys",
|
||||||
"schedule": crontab(minute="*/10"), # Every 10 minutes
|
# "schedule": crontab(minute="*/10"), # Every 10 minutes
|
||||||
},
|
# },
|
||||||
# Check for overdue complaints every 15 minutes
|
# Check for overdue complaints every 15 minutes
|
||||||
"check-overdue-complaints": {
|
"check-overdue-complaints": {
|
||||||
"task": "apps.complaints.tasks.check_overdue_complaints",
|
"task": "apps.complaints.tasks.check_overdue_complaints",
|
||||||
@ -117,10 +117,10 @@ app.conf.beat_schedule = {
|
|||||||
"task": "apps.physicians.tasks.fetch_his_doctor_ratings_daily",
|
"task": "apps.physicians.tasks.fetch_his_doctor_ratings_daily",
|
||||||
"schedule": crontab(hour=1, minute=30),
|
"schedule": crontab(hour=1, minute=30),
|
||||||
},
|
},
|
||||||
# Calculate physician monthly ratings on the 1st of each month at 2 AM
|
# Auto-aggregate physician individual ratings into monthly ratings daily at 2 AM
|
||||||
"calculate-physician-ratings": {
|
"auto-aggregate-doctor-ratings-daily": {
|
||||||
"task": "apps.physicians.tasks.calculate_monthly_ratings",
|
"task": "apps.physicians.tasks.auto_aggregate_daily",
|
||||||
"schedule": crontab(hour=2, minute=0, day_of_month=1),
|
"schedule": crontab(hour=2, minute=0),
|
||||||
},
|
},
|
||||||
# Scraping schedules
|
# Scraping schedules
|
||||||
"scrape-youtube-hourly": {
|
"scrape-youtube-hourly": {
|
||||||
@ -188,12 +188,17 @@ app.conf.beat_schedule = {
|
|||||||
"task": "apps.analytics.tasks.precompute_dashboard_cache_task",
|
"task": "apps.analytics.tasks.precompute_dashboard_cache_task",
|
||||||
"schedule": crontab(hour=3, minute=0),
|
"schedule": crontab(hour=3, minute=0),
|
||||||
},
|
},
|
||||||
# Generate AI executive summary daily at 6 AM
|
# Pre-compute ALL AI analytics every hour (replaces slow synchronous page loads)
|
||||||
|
"precompute-all-ai-analytics-hourly": {
|
||||||
|
"task": "apps.analytics.tasks.precompute_all_ai_analytics_task",
|
||||||
|
"schedule": crontab(minute=0), # Every hour
|
||||||
|
},
|
||||||
|
# Generate AI executive summary daily at 6 AM (kept for backward compat)
|
||||||
"generate-daily-executive-summary": {
|
"generate-daily-executive-summary": {
|
||||||
"task": "apps.analytics.tasks.generate_executive_summary_task",
|
"task": "apps.analytics.tasks.generate_executive_summary_task",
|
||||||
"schedule": crontab(hour=6, minute=0),
|
"schedule": crontab(hour=6, minute=0),
|
||||||
},
|
},
|
||||||
# Generate AI action recommendations daily at 6:30 AM
|
# Generate AI action recommendations daily at 6:30 AM (kept for backward compat)
|
||||||
"generate-daily-action-recommendations": {
|
"generate-daily-action-recommendations": {
|
||||||
"task": "apps.analytics.tasks.generate_action_recommendations_task",
|
"task": "apps.analytics.tasks.generate_action_recommendations_task",
|
||||||
"schedule": crontab(hour=6, minute=30),
|
"schedule": crontab(hour=6, minute=30),
|
||||||
@ -208,6 +213,37 @@ app.conf.beat_schedule = {
|
|||||||
"task": "apps.analytics.tasks_digest.send_monthly_digest_task",
|
"task": "apps.analytics.tasks_digest.send_monthly_digest_task",
|
||||||
"schedule": crontab(hour=8, minute=0, day_of_month=1),
|
"schedule": crontab(hour=8, minute=0, day_of_month=1),
|
||||||
},
|
},
|
||||||
|
# Executive Summary Tasks
|
||||||
|
# Calculate daily metrics at 1 AM
|
||||||
|
"calculate-daily-executive-metrics": {
|
||||||
|
"task": "apps.executive_summary.tasks.calculate_daily_metrics",
|
||||||
|
"schedule": crontab(hour=1, minute=0),
|
||||||
|
},
|
||||||
|
# Generate predictive insights every 6 hours
|
||||||
|
"generate-predictive-insights": {
|
||||||
|
"task": "apps.executive_summary.tasks.generate_predictive_insights",
|
||||||
|
"schedule": crontab(hour="*/6"), # Every 6 hours
|
||||||
|
},
|
||||||
|
# Generate AI recommendations daily at 3 AM
|
||||||
|
"generate-ai-recommendations": {
|
||||||
|
"task": "apps.executive_summary.tasks.generate_ai_recommendations",
|
||||||
|
"schedule": crontab(hour=3, minute=0),
|
||||||
|
},
|
||||||
|
# Generate weekly executive summary every Monday at 6 AM
|
||||||
|
"generate-weekly-executive-summary": {
|
||||||
|
"task": "apps.executive_summary.tasks.generate_weekly_executive_summary",
|
||||||
|
"schedule": crontab(hour=6, minute=0, day_of_week=1), # Monday
|
||||||
|
},
|
||||||
|
# Generate monthly executive summary on 1st at 7 AM
|
||||||
|
"generate-monthly-executive-summary": {
|
||||||
|
"task": "apps.executive_summary.tasks.generate_monthly_executive_summary",
|
||||||
|
"schedule": crontab(hour=7, minute=0, day_of_month=1),
|
||||||
|
},
|
||||||
|
# Send executive PDF reports every Monday at 9 AM
|
||||||
|
"send-executive-pdf-report": {
|
||||||
|
"task": "apps.executive_summary.tasks.send_executive_pdf_report",
|
||||||
|
"schedule": crontab(hour=9, minute=0, day_of_week=1), # Monday
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -73,6 +73,7 @@ LOCAL_APPS = [
|
|||||||
"apps.simulator",
|
"apps.simulator",
|
||||||
"apps.reports",
|
"apps.reports",
|
||||||
"apps.rca",
|
"apps.rca",
|
||||||
|
"apps.executive_summary",
|
||||||
]
|
]
|
||||||
|
|
||||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||||
@ -123,12 +124,17 @@ WSGI_APPLICATION = "config.wsgi.application"
|
|||||||
# }
|
# }
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": {
|
"default": env.db(
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
"DATABASE_URL",
|
||||||
"NAME": BASE_DIR / "db.sqlite3",
|
default="postgresql://px360:px360@localhost:5433/px360",
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
# DATABASES = {
|
||||||
|
# "default": {
|
||||||
|
# "ENGINE": "django.db.backends.sqlite3",
|
||||||
|
# "NAME": BASE_DIR / "db.sqlite3",
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
|
||||||
# Password validation
|
# Password validation
|
||||||
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
|
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
|
||||||
|
|||||||
@ -23,12 +23,14 @@ CSRF_TRUSTED_ORIGINS = [
|
|||||||
"https://micha-nonparabolic-lovie.ngrok-free.dev",
|
"https://micha-nonparabolic-lovie.ngrok-free.dev",
|
||||||
]
|
]
|
||||||
|
|
||||||
DATABASES = {
|
# Database inherits from base.py (PostgreSQL)
|
||||||
"default": {
|
# To temporarily switch back to SQLite for testing, uncomment:
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
# DATABASES = {
|
||||||
"NAME": BASE_DIR / "db.sqlite3",
|
# "default": {
|
||||||
}
|
# "ENGINE": "django.db.backends.sqlite3",
|
||||||
}
|
# "NAME": BASE_DIR / "db.sqlite3",
|
||||||
|
# }
|
||||||
|
# }
|
||||||
# Email backend for development
|
# Email backend for development
|
||||||
# Use simulator API for email (configured in .env with EMAIL_API_ENABLED=true)
|
# Use simulator API for email (configured in .env with EMAIL_API_ENABLED=true)
|
||||||
# Emails will be sent to http://localhost:8000/api/simulator/send-email
|
# Emails will be sent to http://localhost:8000/api/simulator/send-email
|
||||||
|
|||||||
@ -5,34 +5,28 @@ from .base import * # noqa
|
|||||||
|
|
||||||
DEBUG = False
|
DEBUG = False
|
||||||
|
|
||||||
# Security settings for production
|
# Caddy handles SSL termination, so trust the X-Forwarded-Proto header
|
||||||
SECURE_SSL_REDIRECT = True
|
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||||
|
SECURE_SSL_REDIRECT = env.bool('SECURE_SSL_REDIRECT', default=False)
|
||||||
SESSION_COOKIE_SECURE = True
|
SESSION_COOKIE_SECURE = True
|
||||||
CSRF_COOKIE_SECURE = True
|
CSRF_COOKIE_SECURE = True
|
||||||
SECURE_HSTS_SECONDS = 31536000 # 1 year
|
SECURE_HSTS_SECONDS = 31536000 # 1 year
|
||||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||||
SECURE_HSTS_PRELOAD = True
|
SECURE_HSTS_PRELOAD = True
|
||||||
|
|
||||||
# Allowed hosts must be set via environment variable
|
|
||||||
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS')
|
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS')
|
||||||
|
|
||||||
# Database - Must be set via environment variable
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': env.db('DATABASE_URL')
|
'default': env.db('DATABASE_URL')
|
||||||
}
|
}
|
||||||
|
|
||||||
# Celery - Production settings
|
|
||||||
CELERY_TASK_ALWAYS_EAGER = False
|
CELERY_TASK_ALWAYS_EAGER = False
|
||||||
|
|
||||||
# Logging - Production level
|
|
||||||
LOGGING['loggers']['django']['level'] = 'INFO' # noqa
|
LOGGING['loggers']['django']['level'] = 'INFO' # noqa
|
||||||
LOGGING['loggers']['apps']['level'] = 'INFO' # noqa
|
LOGGING['loggers']['apps']['level'] = 'INFO' # noqa
|
||||||
|
|
||||||
# Email backend for production
|
|
||||||
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
||||||
|
|
||||||
# Static files - Ensure WhiteNoise is properly configured
|
|
||||||
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
|
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
|
||||||
|
|
||||||
# Admin URL should be changed in production
|
|
||||||
ADMIN_URL = env('ADMIN_URL', default='admin/')
|
ADMIN_URL = env('ADMIN_URL', default='admin/')
|
||||||
|
|||||||
@ -27,6 +27,7 @@ urlpatterns = [
|
|||||||
path("physicians/", include("apps.physicians.urls", namespace="physicians")),
|
path("physicians/", include("apps.physicians.urls", namespace="physicians")),
|
||||||
path("feedback/", include("apps.feedback.urls")),
|
path("feedback/", include("apps.feedback.urls")),
|
||||||
path("actions/", include("apps.px_action_center.urls")),
|
path("actions/", include("apps.px_action_center.urls")),
|
||||||
|
path("executive/", include("apps.executive_summary.urls")),
|
||||||
path("accounts/", include("apps.accounts.urls", namespace="accounts")),
|
path("accounts/", include("apps.accounts.urls", namespace="accounts")),
|
||||||
path("journeys/", include("apps.journeys.urls")),
|
path("journeys/", include("apps.journeys.urls")),
|
||||||
path("surveys/", include("apps.surveys.urls")),
|
path("surveys/", include("apps.surveys.urls")),
|
||||||
|
|||||||
Binary file not shown.
BIN
data/Complaints Report - 2024_cleaned.xlsx
Normal file
BIN
data/Complaints Report - 2024_cleaned.xlsx
Normal file
Binary file not shown.
57
deploy.prod.sh
Executable file
57
deploy.prod.sh
Executable file
@ -0,0 +1,57 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
VERSION=${1:?Usage: ./deploy.prod.sh <version>}
|
||||||
|
|
||||||
|
IMAGE="gitea.tenhal.sa/marwan/hh:${VERSION}"
|
||||||
|
|
||||||
|
echo "========================================"
|
||||||
|
echo " PX360 Production Deploy"
|
||||||
|
echo " Image: $IMAGE"
|
||||||
|
echo " $(date '+%Y-%m-%d %H:%M:%S')"
|
||||||
|
echo "========================================"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "[1/6] Pulling new image..."
|
||||||
|
docker pull "$IMAGE" || {
|
||||||
|
echo "ERROR: Failed to pull image $IMAGE"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "[2/6] Running pre-flight checks..."
|
||||||
|
docker run --rm --env-file .env.production "$IMAGE" python manage.py check --deploy || {
|
||||||
|
echo "ERROR: Pre-flight checks failed. Aborting."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "[3/6] Running migrations..."
|
||||||
|
docker run --rm --env-file .env.production --network px360_net "$IMAGE" python manage.py migrate --noinput || {
|
||||||
|
echo "ERROR: Migrations failed. Aborting deploy."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "[4/6] Collecting static files..."
|
||||||
|
docker compose -f docker-compose.prod.yml run --rm --no-deps web python manage.py collectstatic --noinput
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "[5/6] Restarting all services with new image..."
|
||||||
|
export PX360_IMAGE="$IMAGE"
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --remove-orphans --force-recreate
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "[6/6] Cleaning up old images..."
|
||||||
|
docker image prune -f
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========================================"
|
||||||
|
echo " Production deploy complete!"
|
||||||
|
echo " Version: $VERSION"
|
||||||
|
echo "========================================"
|
||||||
|
echo ""
|
||||||
|
docker compose -f docker-compose.prod.yml ps
|
||||||
|
echo ""
|
||||||
|
echo "Logs: docker compose -f docker-compose.prod.yml logs -f"
|
||||||
|
echo "Rollback: ./deploy.prod.sh <previous-version>"
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user