clean up version

This commit is contained in:
ismail 2026-04-19 10:53:12 +03:00
parent bcb9c86541
commit e119312a9c
996 changed files with 417237 additions and 3478 deletions

View File

@ -1,64 +1,56 @@
# Python
.git
.gitignore
.opencode/
.qwen/
.ruff_cache/
.venv/
node_modules/
e2e/
playwright.config.ts
tsconfig.e2e.json
__pycache__
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
.venv/
ENV/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.pyo
*.egg-info/
.installed.cfg
*.egg
# Django
*.log
db.sqlite3
db.sqlite3-journal
media/
staticfiles/
media/
logs/
.env
.env.local
.env.staging
.env.staging.example
.env.production
.env.production.example
.pytest_cache/
.coverage
htmlcov/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Docker
Dockerfile
docker-compose.yml
.dockerignore
# Documentation
README.md
docs/
*.md
!README.md
# Tests
.pytest_cache/
.coverage
htmlcov/
# Environment
.env.example
docker-compose.yml
docker-compose.staging.yml
docker-compose.prod.yml
Dockerfile
.dockerignore
Caddyfile
Caddyfile.prod
deploy.staging.sh
deploy.prod.sh
build-and-push.sh

100
.env.production.example Normal file
View 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
View 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
View File

@ -39,6 +39,8 @@ logs/
# Environment variables
.env
.env.local
.env.staging
.env.production
# IDE
.idea/

115
.opencode/package-lock.json generated Normal file
View 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
View 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
View 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"
}
}

View File

@ -1,42 +1,59 @@
# Use Python 3.12 slim image
FROM python:3.12-slim
FROM python:3.12-slim AS builder
# Set environment variables
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# Set work directory
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
postgresql-client \
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
python3-dev \
musl-dev \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements
COPY pyproject.toml ./
# Install Python dependencies
RUN pip install --upgrade pip setuptools wheel && \
pip install -e ".[dev]"
pip install -e "."
# Copy project
COPY . .
# Create necessary directories
RUN mkdir -p logs media staticfiles
# Collect static files
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
# Default command
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]
ENTRYPOINT ["/app/entrypoint.prod.sh"]
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "120"]

View File

@ -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
GMB_CLIENT_SECRETS_FILE = BASE_DIR / "secrets" / "gmb_client_secrets.json"
GMB_REDIRECT_URI = "http://127.0.0.1:8000/social/callback/GO/"
m
# Data upload settings
# Increased limit to support bulk patient imports from HIS

View File

@ -256,7 +256,7 @@ celery -A config beat -l info
- PX Admin: Full system access
- Hospital Admin: Hospital-level access
- Department Manager: Department-level access
- PX Coordinator: Action management
- PX Staff: Action management
- Physician/Nurse/Staff: Limited access
- Viewer: Read-only access

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

View File

@ -1,6 +1,7 @@
"""
Management command to create default roles and groups for PX360.
"""
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from django.core.management.base import BaseCommand
@ -9,65 +10,71 @@ from apps.accounts.models import Role
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):
"""Create default roles"""
roles_config = [
{
'name': 'px_admin',
'display_name': 'PX Admin',
'description': 'Full system access. Can manage all hospitals, departments, and configurations.',
'level': 100,
"name": "px_admin",
"display_name": "PX Admin",
"description": "Full system access. Can manage all hospitals, departments, and configurations.",
"level": 100,
},
{
'name': 'hospital_admin',
'display_name': 'Hospital Admin',
'description': 'Hospital-level access. Can manage their hospital and its departments.',
'level': 80,
"name": "hospital_admin",
"display_name": "Hospital Admin",
"description": "Hospital-level access. Can manage their hospital and its departments.",
"level": 80,
},
{
'name': 'department_manager',
'display_name': 'Department Manager',
'description': 'Department-level access. Can manage their department.',
'level': 60,
"name": "department_manager",
"display_name": "Department Manager",
"description": "Department-level access. Can manage their department.",
"level": 60,
},
{
'name': 'px_coordinator',
'display_name': 'PX Coordinator',
'description': 'Can manage PX actions, complaints, and surveys.',
'level': 50,
"name": "px_staff",
"display_name": "PX Staff",
"description": "Can manage PX actions, complaints, and surveys.",
"level": 50,
},
{
'name': 'physician',
'display_name': 'Physician',
'description': 'Can view patient feedback and their own ratings.',
'level': 40,
"name": "physician",
"display_name": "Physician",
"description": "Can view patient feedback and their own ratings.",
"level": 40,
},
{
'name': 'nurse',
'display_name': 'Nurse',
'description': 'Can view department feedback.',
'level': 30,
"name": "nurse",
"display_name": "Nurse",
"description": "Can view department feedback.",
"level": 30,
},
{
'name': 'staff',
'display_name': 'Staff',
'description': 'Basic staff access.',
'level': 20,
"name": "staff",
"display_name": "Staff",
"description": "Basic staff access.",
"level": 20,
},
{
'name': 'viewer',
'display_name': 'Viewer',
'description': 'Read-only access to reports and dashboards.',
'level': 10,
"name": "viewer",
"display_name": "Viewer",
"description": "Read-only access to reports and dashboards.",
"level": 10,
},
{
'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,
"name": "executive",
"display_name": "Executive",
"description": "C-Suite and top management. System-wide analytics, executive summaries, and predictive insights access.",
"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,
},
]
@ -76,56 +83,42 @@ class Command(BaseCommand):
for role_data in roles_config:
# Get or create group
group, group_created = Group.objects.get_or_create(
name=role_data['display_name']
)
group, group_created = Group.objects.get_or_create(name=role_data["display_name"])
if group_created:
self.stdout.write(
self.style.SUCCESS(f"Created group: {group.name}")
)
self.stdout.write(self.style.SUCCESS(f"Created group: {group.name}"))
# Get or create role
role, role_created = Role.objects.get_or_create(
name=role_data['name'],
name=role_data["name"],
defaults={
'display_name': role_data['display_name'],
'description': role_data['description'],
'group': group,
'level': role_data['level'],
}
"display_name": role_data["display_name"],
"description": role_data["description"],
"group": group,
"level": role_data["level"],
},
)
if role_created:
created_count += 1
self.stdout.write(
self.style.SUCCESS(f"✓ Created role: {role.display_name} (level {role.level})")
)
self.stdout.write(self.style.SUCCESS(f"✓ Created role: {role.display_name} (level {role.level})"))
else:
# Update existing role
role.display_name = role_data['display_name']
role.description = role_data['description']
role.level = role_data['level']
role.display_name = role_data["display_name"]
role.description = role_data["description"]
role.level = role_data["level"]
role.group = group
role.save()
updated_count += 1
self.stdout.write(
self.style.WARNING(f"↻ Updated role: {role.display_name}")
)
self.stdout.write(self.style.WARNING(f"↻ Updated role: {role.display_name}"))
# Assign permissions based on role level
self._assign_permissions(role, group)
self.stdout.write(
self.style.SUCCESS(
f"\n✓ Roles setup complete: {created_count} created, {updated_count} updated"
)
)
self.stdout.write(
self.style.SUCCESS(
f"Total roles: {Role.objects.count()}"
)
self.style.SUCCESS(f"\n✓ Roles setup complete: {created_count} created, {updated_count} updated")
)
self.stdout.write(self.style.SUCCESS(f"Total roles: {Role.objects.count()}"))
def _assign_permissions(self, role, group):
"""
@ -139,48 +132,56 @@ class Command(BaseCommand):
all_permissions = Permission.objects.all()
# PX Admin gets all permissions
if role.name == 'px_admin':
if role.name == "px_admin":
group.permissions.set(all_permissions)
return
# Hospital Admin gets most permissions except user management
if role.name == 'hospital_admin':
if role.name == "hospital_admin":
permissions = Permission.objects.exclude(
content_type__app_label='auth',
codename__in=['add_user', 'delete_user', 'change_user']
content_type__app_label="auth", codename__in=["add_user", "delete_user", "change_user"]
)
group.permissions.set(permissions)
return
# Department Manager gets department-level permissions
if role.name == 'department_manager':
if role.name == "department_manager":
# Add view permissions for most models
view_permissions = Permission.objects.filter(
codename__startswith='view_'
)
view_permissions = Permission.objects.filter(codename__startswith="view_")
group.permissions.set(view_permissions)
return
# PX Coordinator gets complaint and action permissions
if role.name == 'px_coordinator':
coordinator_permissions = Permission.objects.filter(
content_type__app_label__in=['complaints', 'px_action_center', 'surveys']
# PX Staff gets complaint and action permissions
if role.name == "px_staff":
staff_permissions = Permission.objects.filter(
content_type__app_label__in=["complaints", "px_action_center", "surveys"]
)
group.permissions.set(coordinator_permissions)
group.permissions.set(staff_permissions)
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
if role.name == 'px_source_user':
if role.name == "px_source_user":
source_user_perms = Permission.objects.filter(
content_type__app_label__in=['complaints'],
codename__in=['add_complaint', 'view_complaint', 'change_complaint',
'add_inquiry', 'view_inquiry', 'change_inquiry']
content_type__app_label__in=["complaints"],
codename__in=[
"add_complaint",
"view_complaint",
"change_complaint",
"add_inquiry",
"view_inquiry",
"change_inquiry",
],
)
group.permissions.set(source_user_perms)
return
# Others get basic view permissions
view_permissions = Permission.objects.filter(
codename__startswith='view_'
)
view_permissions = Permission.objects.filter(codename__startswith="view_")
group.permissions.set(view_permissions)

View File

@ -153,14 +153,18 @@ class User(AbstractUser, TimeStampedModel):
"""Check if user is Department Manager"""
return self.has_role("Department Manager")
def is_px_coordinator(self):
"""Check if user is PX Coordinator"""
return self.has_role("PX Coordinator")
def is_px_staff(self):
"""Check if user is PX Staff"""
return self.has_role("PX Staff")
def is_source_user(self):
"""Check if user is a 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):
"""Get active source user profile if exists"""
if hasattr(self, "source_user_profile"):
@ -451,11 +455,12 @@ class Role(models.Model):
("px_admin", _("PX Admin")),
("hospital_admin", _("Hospital Admin")),
("department_manager", _("Department Manager")),
("px_coordinator", _("PX Coordinator")),
("px_staff", _("PX Staff")),
("physician", _("Physician")),
("nurse", _("Nurse")),
("staff", _("Staff")),
("viewer", _("Viewer")),
("executive", _("Executive")),
]
name = models.CharField(max_length=50, unique=True, choices=ROLE_CHOICES)

View File

@ -37,6 +37,17 @@ class IsDepartmentManager(permissions.BasePermission):
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):
"""
Permission class for PX Admin or Hospital Admin.

View File

@ -214,7 +214,7 @@ def kpi_report_generate(request):
hospitals = Hospital.objects.none()
current_year = datetime.now().year
years = list(range(current_year, current_year - 3, -1))
years = list(range(current_year, 2021, -1))
context = {
"hospitals": hospitals,

View File

@ -86,6 +86,10 @@ class ExecutiveSummaryGenerator:
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
def _gather_data(user, hospital_id=None, department_id=None, period="30d") -> Dict[str, Any]:
"""Collect all data needed for the summary."""
@ -225,7 +229,7 @@ class ExecutiveSummaryGenerator:
@classmethod
def generate(cls, user, hospital_id=None, department_id=None, period="30d", force_refresh=False) -> Dict[str, Any]:
"""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:
cached = cache.get(cache_key)
if cached:
@ -332,6 +336,10 @@ class EarlyWarningSystem:
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 = {
"complaint_volume_spike": 25,
"survey_score_decline": 25,
@ -343,7 +351,7 @@ class EarlyWarningSystem:
@classmethod
def detect(cls, user, hospital_id=None, limit=10) -> List[Dict[str, Any]]:
"""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)
if 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)():
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:
signals = cls._evaluate_department(dept, current_start, prev_start, now)
risk_score = signals["risk_score"]
if risk_score > 20: # threshold — show anything above 20%
did = dept.id
signals = {}
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_name"] = dept.name_en if hasattr(dept, "name_en") else str(dept)
results.append(signals)
@ -375,158 +579,6 @@ class EarlyWarningSystem:
cache.set(cache_key, results, cls.CACHE_TIMEOUT)
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
@ -541,9 +593,13 @@ class ComplaintVolumeForecaster:
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
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)
if cached:
return cached
@ -693,10 +749,14 @@ class SLABreachPredictor:
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
def predict(cls, user, hospital_id=None, limit=20) -> List[Dict[str, Any]]:
"""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)
if cached:
return cached
@ -713,10 +773,63 @@ class SLABreachPredictor:
qs = qs.filter(hospital=user.hospital)
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 = []
for complaint in qs[: limit * 2]: # fetch extra to filter/sort
prediction = cls._predict_complaint_breach(complaint, now)
for complaint in complaints:
prediction = cls._predict_complaint_breach(complaint, now, workload_map, resolution_map)
if prediction["breach_probability"] > 30: # only show > 30% risk
prediction["complaint_id"] = str(complaint.id)
prediction["title"] = complaint.title
@ -744,8 +857,8 @@ class SLABreachPredictor:
return results
@classmethod
def _predict_complaint_breach(cls, complaint, now) -> Dict[str, Any]:
"""Calculate breach probability for a single complaint."""
def _predict_complaint_breach(cls, complaint, now, workload_map, resolution_map) -> Dict[str, Any]:
"""Calculate breach probability for a single complaint using pre-computed bulk data."""
probability = 0
factors = []
@ -791,11 +904,7 @@ class SLABreachPredictor:
probability += 15
factors.append("Unassigned — no owner yet")
else:
# Check assignee workload
workload = Complaint.objects.filter(
assigned_to=complaint.assigned_to,
status__in=["open", "in_progress"],
).count()
workload = workload_map.get(complaint.assigned_to_id, 0)
if workload >= 10:
probability += 12
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")
# Factor 4: Historical resolution time for similar complaints (0-20 points)
similar = Complaint.objects.filter(
status__in=["resolved", "closed"],
severity=complaint.severity,
)
if complaint.department:
similar = similar.filter(department=complaint.department)
avg_hrs = None
if complaint.department_id:
avg_hrs = resolution_map.get((complaint.severity, complaint.department_id))
if avg_hrs is None:
avg_hrs = resolution_map.get((complaint.severity, None))
if similar.exists():
avg_resolve_hours = (
similar.filter(resolved_at__isnull=False, created_at__isnull=False)
.annotate(rt=F("resolved_at") - F("created_at"))
.aggregate(avg=Avg("rt"))["avg"]
if avg_hrs is not None:
total_sla_hrs = (
(complaint.due_at - complaint.created_at).total_seconds() / 3600
if complaint.due_at and complaint.created_at
else 24
)
if avg_resolve_hours:
avg_hrs = avg_resolve_hours.total_seconds() / 3600
total_sla_hrs = (
(complaint.due_at - complaint.created_at).total_seconds() / 3600
if complaint.due_at and complaint.created_at
else 24
)
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")
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)
if complaint.created_at:
@ -876,10 +976,14 @@ class ActionRecommendationEngine:
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
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."""
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)
if cached:
return cached

View File

@ -363,14 +363,20 @@ class UnifiedAnalyticsService:
@staticmethod
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 = []
current_date = start_date
while current_date <= end_date:
next_date = current_date + timedelta(days=1)
count = queryset.filter(created_at__gte=current_date, created_at__lt=next_date).count()
data.append({"date": current_date.strftime("%Y-%m-%d"), "count": count})
current_date = next_date
current_date = start_date.date() if hasattr(start_date, "date") else start_date
end = end_date.date() if hasattr(end_date, "date") else end_date
while current_date <= end:
data.append({"date": current_date.strftime("%Y-%m-%d"), "count": date_map.get(str(current_date), 0)})
current_date += timedelta(days=1)
return {
"type": "line",
@ -404,19 +410,23 @@ class UnifiedAnalyticsService:
@staticmethod
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 = []
current_date = start_date
while current_date <= end_date:
next_date = current_date + timedelta(days=1)
avg_score = (
queryset.filter(completed_at__gte=current_date, completed_at__lt=next_date).aggregate(
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
current_date = start_date.date() if hasattr(start_date, "date") else start_date
end = end_date.date() if hasattr(end_date, "date") else end_date
while current_date <= end:
data.append({"date": current_date.strftime("%Y-%m-%d"), "score": date_map.get(str(current_date), 0)})
current_date += timedelta(days=1)
return {
"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 {
"staff_metrics": staff_metrics,
"totals": totals,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
"date_range": date_range,
@ -552,26 +583,44 @@ class UnifiedAnalyticsService:
},
}
# Source breakdown
internal_count = complaints_qs.filter(source__name_en="staff").count()
# Source breakdown — single query
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
# 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 = {
"open": complaints_qs.filter(status="open").count(),
"in_progress": complaints_qs.filter(status="in_progress").count(),
"resolved": complaints_qs.filter(status="resolved").count(),
"closed": complaints_qs.filter(status="closed").count(),
"open": status_map.get("open", 0),
"in_progress": status_map.get("in_progress", 0),
"resolved": status_map.get("resolved", 0),
"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_more_than_2h = 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:
if complaint.assigned_at:
activation_time = (complaint.assigned_at - complaint.created_at).total_seconds()
for assigned_at, created_at, first_update_at in values:
if assigned_at:
activation_time = (assigned_at - created_at).total_seconds()
if activation_time <= 7200: # 2 hours
activation_within_2h += 1
else:
@ -579,17 +628,8 @@ class UnifiedAnalyticsService:
else:
not_assigned += 1
# Response time (time to first update)
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:
first_update = complaint.updates.first()
if first_update:
response_time = (first_update.created_at - complaint.created_at).total_seconds()
if first_update_at:
response_time = (first_update_at - created_at).total_seconds()
if response_time <= 86400: # 24 hours
response_within_24h += 1
elif response_time <= 172800: # 48 hours

View File

@ -141,3 +141,122 @@ def precompute_dashboard_cache_task(self):
logger.info(f"Precomputed analytics for admin={admin.id}, hospital={hospital.id}")
except Exception as 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")

View File

@ -102,16 +102,21 @@ def analytics_dashboard(request):
feedback_queryset = feedback_queryset.filter(hospital=hospital)
# ============ COMPLAINTS KPIs ============
total_complaints = complaints_queryset.count()
open_complaints = complaints_queryset.filter(status="open").count()
in_progress_complaints = complaints_queryset.filter(status="in_progress").count()
resolved_complaints = complaints_queryset.filter(status="resolved").count()
closed_complaints = complaints_queryset.filter(status="closed").count()
# Single query for all status counts
status_counts_qs = complaints_queryset.values("status").annotate(count=Count("id"))
status_map = {item["status"]: item["count"] for item in status_counts_qs}
total_complaints = sum(status_map.values())
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()
# Complaint source types (internal vs external)
internal_complaints = complaints_queryset.filter(complaint_source_type="internal").count()
external_complaints = complaints_queryset.filter(complaint_source_type="external").count()
# Complaint source types (internal vs external) — single query
source_type_counts = complaints_queryset.values("complaint_source_type").annotate(count=Count("id"))
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 = (
@ -137,28 +142,30 @@ def analytics_dashboard(request):
.order_by("-count")[:5]
)
# Complaint severity - build explicit counts for template
severity_counts = complaints_queryset.values("severity").annotate(count=Count("id"))
severity_map = {item["severity"]: item["count"] for item in severity_counts}
# Complaint severity — single query
severity_counts_qs = complaints_queryset.values("severity").annotate(count=Count("id"))
severity_map = {item["severity"]: item["count"] for item in severity_counts_qs}
critical_complaints = severity_map.get("critical", 0)
high_complaints = severity_map.get("high", 0)
medium_complaints = severity_map.get("medium", 0)
low_complaints = severity_map.get("low", 0)
# 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 = complaints_queryset.values("status").annotate(count=Count("id")).order_by("-count")
status_breakdown = status_counts_qs.order_by("-count")
# ============ ACTIONS KPIs ============
total_actions = actions_queryset.count()
open_actions = actions_queryset.filter(status="open").count()
in_progress_actions = actions_queryset.filter(status="in_progress").count()
approved_actions = actions_queryset.filter(status="approved").count()
closed_actions = actions_queryset.filter(status="closed").count()
action_status_counts = actions_queryset.values("status").annotate(count=Count("id"))
action_status_map = {item["status"]: item["count"] for item in action_status_counts}
total_actions = sum(action_status_map.values())
open_actions = action_status_map.get("open", 0)
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()
pending_actions = actions_queryset.filter(status="pending_approval").count()
# 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
negative_surveys = surveys_queryset.filter(is_negative=True).count()
# Survey completion rate
# Survey completion rate — single query
all_surveys = SurveyInstance.objects.all()
if hospital:
all_surveys = all_surveys.filter(survey_template__hospital=hospital)
total_sent = all_surveys.count()
completed_surveys = all_surveys.filter(status="completed").count()
survey_status_counts = all_surveys.values("status").annotate(count=Count("id"))
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
# Survey types
survey_types = all_surveys.values("survey_template__survey_type").annotate(count=Count("id")).order_by("-count")[:5]
# ============ FEEDBACK KPIs ============
total_feedback = feedback_queryset.count()
compliments = feedback_queryset.filter(feedback_type="compliment").count()
suggestions = feedback_queryset.filter(feedback_type="suggestion").count()
feedback_type_counts = feedback_queryset.values("feedback_type").annotate(count=Count("id"))
feedback_type_map = {item["feedback_type"]: item["count"] for item in feedback_type_counts}
total_feedback = sum(feedback_type_map.values())
compliments = feedback_type_map.get("compliment", 0)
suggestions = feedback_type_map.get("suggestion", 0)
# Sentiment analysis
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)
# ============ DEPARTMENT RANKINGS ============
dept_base_qs = Department.objects.filter(status="active")
if hospital:
dept_base_qs = dept_base_qs.filter(hospital=hospital)
department_rankings = (
Department.objects.filter(status="active")
.annotate(
dept_base_qs.annotate(
avg_score=Avg(
"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")),
complaint_count=Count("complaints"),
resolved_count=Count("complaints", filter=Q(complaints__status__in=["resolved", "closed"])),
action_count=Count("px_actions"),
)
.filter(survey_count__gt=0)
.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 = []
for dept in department_rankings:
dept_complaints = (
complaints_queryset.filter(department=dept).count()
if hospital
else Complaint.objects.filter(department=dept).count()
resolution_rate = (
round((dept.resolved_count / dept.complaint_count * 100), 1) if dept.complaint_count > 0 else 0
)
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(
{
"name_en": dept.name_en if hasattr(dept, "name_en") else str(dept),
"name_ar": dept.name_ar
if hasattr(dept, "name_ar")
else (dept.name_en if hasattr(dept, "name_en") else str(dept)),
"complaints": dept_complaints,
"actions": dept_actions,
"complaints": dept.complaint_count,
"actions": dept.action_count,
"survey_avg": round(dept.avg_score, 2) if dept.avg_score else 0,
"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_action_recommendations_task.delay(user_id=str(user.id), hospital_id=hospital_id)
# 1. Executive Summary — read from cache (populated by Celery or fallback)
exec_summary = ExecutiveSummaryGenerator.generate(user, hospital_id=hospital_id, period="30d")
# Read AI analytics from cache ONLY (populated hourly by Celery beat).
# 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 = EarlyWarningSystem.detect(user, hospital_id=hospital_id, limit=5)
early_warnings = cache.get(EarlyWarningSystem._cache_key(hospital_id, 5))
if early_warnings is None:
early_warnings = []
# 3. Complaint Volume Forecast
complaint_forecast = ComplaintVolumeForecaster.forecast(user, hospital_id=hospital_id, forecast_days=30)
complaint_forecast = cache.get(ComplaintVolumeForecaster._cache_key(hospital_id, 30))
if not complaint_forecast:
complaint_forecast = ComplaintVolumeForecaster._insufficient_data_response(30)
# 4. SLA Breach Predictions
sla_breach_predictions = SLABreachPredictor.predict(user, hospital_id=hospital_id, limit=10)
sla_breach_predictions = cache.get(SLABreachPredictor._cache_key(hospital_id, 10))
if sla_breach_predictions is None:
sla_breach_predictions = []
# 5. Action Recommendations — read from cache
action_recommendations = ActionRecommendationEngine.generate_recommendations(user, hospital_id=hospital_id, limit=5)
action_recommendations = cache.get(ActionRecommendationEngine._cache_key(hospital_id, None, 5))
if not action_recommendations:
action_recommendations = ActionRecommendationEngine._no_data_response()
context = {
"kpis": kpis,
@ -447,8 +464,8 @@ def analytics_dashboard(request):
"action_recommendations": action_recommendations,
}
# Clear old cache (the new data isn't in the old cache entries)
cache.delete(cache_key)
# Cache the full dashboard context for 5 minutes so next load is instant
cache.set(cache_key, context, 300)
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)
# Also clear caches so next page load triggers fresh computation
cache.delete(f"exec_summary_{user.id}_{hospital_id}_30d")
cache.delete(f"action_recommendations_{user.id}_{hospital_id}_5")
from apps.analytics.services.ai_analytics import (
ExecutiveSummaryGenerator,
ActionRecommendationEngine,
)
cache.delete(ExecutiveSummaryGenerator._cache_key(hospital_id, None, "30d"))
cache.delete(ActionRecommendationEngine._cache_key(hospital_id, None, 5))
return JsonResponse(
{"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
summary_cached = cache.get(f"exec_summary_{user.id}_{hospital_id}_30d")
recommendations_cached = cache.get(f"action_recommendations_{user.id}_{hospital_id}_5")
from apps.analytics.services.ai_analytics import (
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(
{
@ -526,11 +553,13 @@ def refresh_dashboard_cache(request):
if hasattr(request, "tenant_hospital") and request.tenant_hospital:
cache.delete(f"analytics_dashboard_{user.id}_{request.tenant_hospital.id}")
return JsonResponse({
"status": "triggered",
"message": "Dashboard cache refresh queued. Please reload the page in a few seconds.",
"task_id": str(task.id),
})
return JsonResponse(
{
"status": "triggered",
"message": "Dashboard cache refresh queued. Please reload the page in a few seconds.",
"task_id": str(task.id),
}
)
@block_source_user
@ -647,19 +676,45 @@ def command_center(request):
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 = {
"filters": filters,
"departments": departments,
"kpis": kpis,
"exec_summary": ExecutiveSummaryGenerator.generate(
user, hospital_id=hospital_id, department_id=department_id, period=filters["date_range"]
),
"early_warnings": EarlyWarningSystem.detect(user, hospital_id=hospital_id, limit=5),
"complaint_forecast": ComplaintVolumeForecaster.forecast(user, hospital_id=hospital_id, forecast_days=30),
"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
),
"exec_summary": exec_summary,
"early_warnings": early_warnings,
"complaint_forecast": complaint_forecast,
"sla_breach_predictions": sla_breach_predictions,
"action_recommendations": action_recommendations,
}
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
)
# 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 = {
"executive_summary": ExecutiveSummaryGenerator.generate(
user, hospital_id=hospital_id, department_id=department_id, period=date_range
),
"early_warnings": EarlyWarningSystem.detect(user, hospital_id=hospital_id, limit=5),
"complaint_forecast": ComplaintVolumeForecaster.forecast(user, hospital_id=hospital_id, forecast_days=30),
"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
),
"executive_summary": exec_summary,
"early_warnings": early_warnings,
"complaint_forecast": complaint_forecast,
"sla_breach_predictions": sla_breach_predictions,
"action_recommendations": action_recommendations,
}
return JsonResponse({"kpis": kpis, "charts": charts, "tables": tables, "ai": ai_data})

View File

@ -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

View File

@ -1,17 +1,16 @@
"""
Import 2025 complaints from Excel with basic fields (no AI, skip missing columns).
2025 has different structure than 2022-2024:
- No 4-level taxonomy (skip)
- No Staff ID column (use staff_name text only)
- No Rightful Side column (skip)
Dynamically detects header row and column positions per sheet because 2025
monthly sheets have inconsistent layouts.
Usage:
python manage.py import_2025_complaints_basic "Complaints Report - 2025.xlsx" --sheet="JAN"
"""
import logging
import re
from datetime import datetime
from datetime import datetime, timedelta
from typing import Dict, Optional
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.organizations.models import Hospital, Location, MainSection, SubSection
from .complaint_source_mapping import resolve_px_source
logger = logging.getLogger(__name__)
DEFAULT_HOSPITAL_CODE = "NUZHA-DEV"
DEFAULT_HOSPITAL_CODE = "NUZHA"
# 2025 Column mapping (different from 2022-2024)
COLUMN_MAPPING = {
"complaint_num": 3, # رقم الشكوى
"mrn": 4, # رقم الملف
"source": 5, # جهة الشكوى
"location_name": 6, # الموقع
"main_dept_name": 7, # القسم الرئيس
"sub_dept_name": 8, # القسم الفرعي
"date_received": 9, # تاريخ إستلام الشكوى
"data_entry_person": 10, # المدخل
"response_date": 48, # تاريخ الرد (was Staff ID in 2022-2024)
"staff_name": 51, # اسم الشخص المشتكى عليه (was col 49)
# Skip cols 52-53 (Complain Classification, Main Subject)
"description_ar": 54, # محتوى الشكوى (عربي)
"description_en": 55, # محتوى الشكوى (English)
"satisfaction": 56, # توثيق تذكيرات للقسم المشتكى عليه
"reminder_date": 57, # تاريخ التذكير
# Header aliases: list of possible names in Excel for each field
HEADER_ALIASES = {
"complaint_num": ["رقم الشكوى"],
"mrn": ["رقم الملف"],
"source": ["جهة الشكوى"],
"location_name": ["الموقع"],
"main_dept_name": ["القسم الرئيس"],
"sub_dept_name": ["القسم الفرعي"],
"date_received": ["تاريخ إستلام الشكوى"],
"data_entry_person": ["المدخل"],
"response_date": ["تاريخ الرد"],
"staff_name": ["اسم الشخص المشتكى عليه - ان وجد", "اسم الشخص المشتكى عليه"],
"description_ar": ["الشكوى باختصار (عربي)", "محتوى الشكوى (عربي)"],
"description_en": ["الشكوى باختصار English", "محتوى الشكوى (English)"],
"satisfaction": ["توثيق تذكيرات للقسم المشتكى عليه"],
"reminder_date": ["تاريخ التذكير"],
}
# Month mapping for 2025 sheet names (3-letter abbreviations)
MONTH_MAP = {
"JAN": "01", "FEB": "02", "MAR": "03", "APR": "04",
"MAY": "05", "JUN": "06", "JUL": "07", "AUG": "08",
"SEP": "09", "OCT": "10", "NOV": "11", "DEC": "12",
"JAN": "01",
"FEB": "02",
"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("--sheet", type=str, default="JAN")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--start-row", type=int, default=3)
def handle(self, *args, **options):
self.excel_file = options["excel_file"]
self.sheet_name = options["sheet"]
self.dry_run = options["dry_run"]
self.start_row = options["start_row"]
# Load hospital
self.hospital = self._load_hospital()
if not self.hospital:
raise CommandError(f'Hospital "{DEFAULT_HOSPITAL_CODE}" not found')
self.stdout.write(f"Using hospital: {self.hospital.name}")
# Load Excel
try:
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:
raise CommandError("openpyxl required: pip install openpyxl")
@ -89,11 +94,17 @@ class Command(BaseCommand):
self.ws = self.wb[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.errors = []
self.used_refs = set()
# Process
self._process_sheet()
self._print_report()
@ -103,12 +114,34 @@ class Command(BaseCommand):
except Hospital.DoesNotExist:
return None
def _process_sheet(self):
row_num = self.start_row
def _detect_columns(self) -> Dict[str, int]:
"""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:
row_data = self._extract_row_data(row_num)
row_data = self._extract_row_data_from_values(row)
if not row_data.get("complaint_num"):
row_num += 1
@ -116,29 +149,27 @@ class Command(BaseCommand):
self.stats["processed"] += 1
# Build reference number
ref_num = self._build_reference_number(row_data["complaint_num"])
# Check for duplicate
if Complaint.objects.filter(reference_number=ref_num).exists():
try:
ref_num = self._get_unique_reference_number(row_data["complaint_num"])
except (ValueError, TypeError):
row_num += 1
continue
# Parse dates
px_source = resolve_px_source(row_data.get("source"))
date_received = self._parse_datetime(row_data.get("date_received"))
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"))
reminder_date = self._parse_datetime(row_data.get("reminder_date"))
# Resolve location/departments
location = self._resolve_location(row_data.get("location_name"))
main_section = self._resolve_section(row_data.get("main_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"))
# Determine status
status = "open"
if response_date:
status = "resolved"
@ -156,9 +187,8 @@ class Command(BaseCommand):
patient_name="Unknown",
national_id="",
relation_to_patient="patient",
staff=None, # No staff linking for 2025
staff=None,
staff_name=row_data.get("staff_name") or "",
# No taxonomy fields for 2025
domain=None,
category=None,
subcategory_obj=None,
@ -166,18 +196,19 @@ class Command(BaseCommand):
status=status,
assigned_to=assigned_to_user,
resolved_by=assigned_to_user if response_date else None,
# Timeline
created_at=created_at,
due_at=created_at + timedelta(hours=48),
explanation_requested=bool(date_received),
explanation_requested_at=date_received,
explanation_received_at=response_date,
reminder_sent_at=reminder_date,
source=px_source,
metadata={
"import_source": "2025_excel_basic",
"original_sheet": self.sheet_name,
"complaint_num": row_data.get("complaint_num"),
},
)
Complaint.objects.filter(pk=complaint.pk).update(created_at=created_at)
self.stats["success"] += 1
@ -190,11 +221,18 @@ class Command(BaseCommand):
def _extract_row_data(self, row_num: int) -> Dict:
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
data[field] = cell_value
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:
sheet_parts = self.sheet_name.strip().split()
year = "2025"
@ -202,6 +240,27 @@ class Command(BaseCommand):
month_code = MONTH_MAP.get(month_part, "00")
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]:
if not value:
return None
@ -245,7 +304,7 @@ class Command(BaseCommand):
if len(parts) >= 2:
first, last = parts[0], parts[-1]
else:
first, last = arabic_name, "coordinator"
first, last = arabic_name, "staff"
username_first = re.sub(r"[^a-z0-9]", "", unidecode(first).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:
username_first = "user"
if not username_last:
username_last = "coordinator"
username_last = "staff"
username = f"{username_first}.{username_last}"

View File

@ -1,23 +1,17 @@
"""
Import historical complaints from Excel (Aug-Dec 2022).
Import historical complaints from Excel (2022-2024).
Usage:
# Test import (AUG 2022 only, dry run)
python manage.py import_historical_complaints "Complaints Report - 2022.xlsx" --sheet="AUG 2022 " --dry-run
# Test import (dry run)
python manage.py import_historical_complaints "Complaints Report - 2024.xlsx" --sheet="January 2024" --dry-run
# Actual import (AUG 2022)
python manage.py import_historical_complaints "Complaints Report - 2022.xlsx" --sheet="AUG 2022 "
# 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"
# Actual import for a single sheet
python manage.py import_historical_complaints "Complaints Report - 2024.xlsx" --sheet="January 2024"
"""
import logging
import re
from datetime import datetime
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
from django.core.management.base import BaseCommand, CommandError
@ -36,11 +30,12 @@ from .complaint_taxonomy_mapping import (
get_mapped_category,
is_taxonomy_mapped,
)
from .complaint_source_mapping import resolve_px_source
logger = logging.getLogger(__name__)
# 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 = {
@ -97,7 +92,7 @@ MONTH_MAP = {
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):
parser.add_argument("excel_file", type=str, help="Path to the Excel file")
@ -124,7 +119,7 @@ class Command(BaseCommand):
try:
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:
raise CommandError("openpyxl is required. Install with: pip install openpyxl")
except Exception as e:
@ -137,7 +132,6 @@ class Command(BaseCommand):
self.ws = self.wb[self.sheet_name]
self.stdout.write(f"Processing sheet: {self.sheet_name}")
self.stdout.write(f"Total rows: {self.ws.max_row}")
# Statistics tracking
self.stats = {
@ -152,6 +146,9 @@ class Command(BaseCommand):
self.unmatched_locations = set()
self.unmatched_departments = set()
# Cache for used reference numbers to avoid DB queries
self.used_refs = set()
# Process rows
self._process_sheet()
@ -166,13 +163,13 @@ class Command(BaseCommand):
return None
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
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:
# Extract row data
row_data = self._extract_row_data(row_num)
row_data = self._extract_row_data_from_values(row)
# Skip empty rows
if not row_data.get("complaint_num"):
@ -181,14 +178,14 @@ class Command(BaseCommand):
self.stats["processed"] += 1
# Check for duplicate
ref_num = self._build_reference_number(row_data["complaint_num"])
if Complaint.objects.filter(reference_number=ref_num).exists():
self.stats["skipped_duplicate"] += 1
# Validate complaint number and build reference
try:
ref_num = self._get_unique_reference_number(row_data["complaint_num"])
except (ValueError, TypeError):
row_num += 1
continue
# Resolve taxonomy - skip if unmapped
# Resolve taxonomy - allow unmapped (will be backfilled later via AI)
taxonomy = self._resolve_taxonomy(
row_data.get("domain"),
row_data.get("category"),
@ -202,10 +199,10 @@ class Command(BaseCommand):
row_data.get("subcategory"),
row_data.get("classification"),
):
self.stats["skipped_unmapped_taxonomy"] += 1
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
location = self._resolve_location(row_data.get("location_name"))
@ -230,6 +227,9 @@ class Command(BaseCommand):
elif isinstance(date_received, datetime):
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
data_entry_person = row_data.get("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)
# 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 = ""
if rightful_side in ["patient", "hospital", "other"]:
resolution_outcome = rightful_side
@ -293,6 +293,8 @@ class Command(BaseCommand):
explanation_requested=explanation_requested,
explanation_requested_at=explanation_requested_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),
)
@ -315,7 +317,7 @@ class Command(BaseCommand):
row_num += 1
def _extract_row_data(self, row_num: int) -> Dict:
"""Extract data from Excel row."""
"""Extract data from Excel row (kept for compatibility)."""
data = {}
for field, col in COLUMN_MAPPING.items():
cell_value = self.ws.cell(row_num, col).value
@ -325,6 +327,18 @@ class Command(BaseCommand):
data[field] = cell_value
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:
"""Build reference number: CMP-YYYY-MM-NNNN."""
# 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")
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:
"""Resolve taxonomy to ComplaintCategory objects."""
return {
@ -404,7 +443,7 @@ class Command(BaseCommand):
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.
Stores full Arabic name in first_name field.
@ -431,7 +470,7 @@ class Command(BaseCommand):
last_name = parts[-1]
else:
first_name = arabic_name
last_name = "coordinator"
last_name = "staff"
# Transliterate to Latin for username
username_first = unidecode(first_name).lower().strip()
@ -444,7 +483,7 @@ class Command(BaseCommand):
if not username_first:
username_first = "user"
if not username_last:
username_last = "coordinator"
username_last = "staff"
username = f"{username_first}.{username_last}"
@ -470,7 +509,7 @@ class Command(BaseCommand):
is_active=True,
)
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
except Exception as e:
logger.error(f"Error creating user {username}: {e}")
@ -486,7 +525,7 @@ class Command(BaseCommand):
is_active=True,
)
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
except Exception as 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(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 (unmapped taxonomy): {self.stats['skipped_unmapped_taxonomy']}"))
self.stdout.write(self.style.ERROR(f"Failed: {self.stats['failed']}"))
if self.unmapped_taxonomy:

View File

@ -404,12 +404,10 @@ class Command(BaseCommand):
if not hospitals:
return
px_coordinators = User.objects.filter(groups__name="PX Coordinator", is_active=True)
if not px_coordinators.exists():
self.stdout.write(
self.style.WARNING("No PX Coordinator users found. Unassigned items will have no assignee.")
)
px_coordinators = User.objects.filter(groups__name="Hospital Admin", is_active=True)
px_staff = User.objects.filter(groups__name="PX Staff", is_active=True)
if not px_staff.exists():
self.stdout.write(self.style.WARNING("No PX Staff users found. Unassigned items will have no assignee."))
px_staff = User.objects.filter(groups__name="Hospital Admin", is_active=True)
all_staff = Staff.objects.filter(status="active")
complaint_categories = ComplaintCategory.objects.filter(is_active=True)
@ -437,12 +435,10 @@ class Command(BaseCommand):
self._clear_sample_data()
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(
options["observations"], hospitals, px_coordinators, obs_categories
)
inquiries_created = self._seed_inquiries(options["inquiries"], hospitals, px_coordinators, sources)
observations_created = self._seed_observations(options["observations"], hospitals, px_staff, obs_categories)
inquiries_created = self._seed_inquiries(options["inquiries"], hospitals, px_staff, sources)
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))
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 ---")
complaint_statuses = [
@ -569,11 +565,11 @@ class Command(BaseCommand):
complaint.staff = staff_member
if status not 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_at = created_at + timedelta(minutes=random.randint(5, 60))
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.activated_at = created_at + timedelta(minutes=random.randint(30, 120))
@ -630,7 +626,7 @@ class Command(BaseCommand):
ComplaintUpdate.objects.create(
complaint=complaint,
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",
new_status="in_progress",
created_by=complaint.assigned_to,
@ -668,7 +664,7 @@ class Command(BaseCommand):
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 ---")
obs_statuses = [
@ -723,7 +719,7 @@ class Command(BaseCommand):
)
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"):
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,
)
def _seed_inquiries(self, count, hospitals, px_coordinators, sources):
def _seed_inquiries(self, count, hospitals, px_staff, sources):
self.stdout.write("\n--- Inquiries ---")
inquiry_statuses = [
@ -852,7 +848,7 @@ class Command(BaseCommand):
)
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))
if status in ("resolved", "closed"):
@ -894,7 +890,7 @@ class Command(BaseCommand):
InquiryUpdate.objects.create(
inquiry=inquiry,
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_at=inquiry.assigned_at if inquiry.assigned_at else created_at + timedelta(minutes=5),
)

View File

@ -87,7 +87,7 @@ class Command(BaseCommand):
if not test_data:
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
scenarios = {
@ -249,11 +249,11 @@ class Command(BaseCommand):
self.stdout.write(" Please create a hospital admin user first")
return None
# Get PX Coordinator group
px_coordinator_group = Group.objects.filter(name="PX Coordinator").first()
# Get PX Staff group
px_staff_group = Group.objects.filter(name="PX Staff").first()
# Get PX Coordinator
px_coordinator = User.objects.filter(hospital=hospital, groups=px_coordinator_group, is_active=True).first()
# Get PX Staff
px_staff = User.objects.filter(hospital=hospital, groups=px_staff_group, is_active=True).first()
# Get or create department with manager
dept_with_manager = (
@ -314,8 +314,8 @@ class Command(BaseCommand):
self.stdout.write(f"\n👥 Test Data:")
self.stdout.write(f" Admin User: {admin_user.get_full_name()} ({admin_user.email})")
if px_coordinator:
self.stdout.write(f" PX Coordinator: {px_coordinator.get_full_name()} ({px_coordinator.email})")
if px_staff:
self.stdout.write(f" PX Staff: {px_staff.get_full_name()} ({px_staff.email})")
if dept_with_manager.manager:
self.stdout.write(
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:
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):
"""Create a test complaint with backdated created_at"""
@ -386,12 +386,12 @@ class Command(BaseCommand):
if department and department.manager:
return f"{department.manager.get_full_name()} ({department.manager.email}) - Department Manager"
# Fallback to hospital admins and coordinators
from apps.complaints.tasks import get_hospital_admins_and_coordinators
# Fallback to hospital admins and staff
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:
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"

View File

@ -110,7 +110,7 @@ class ComplaintCategory(UUIDModel, TimeStampedModel):
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_ar = models.CharField(max_length=200, blank=True)
@ -2096,7 +2096,7 @@ class ComplaintInvolvedStaff(UUIDModel, TimeStampedModel):
RESPONSIBLE = "responsible", _("Responsible for Resolution")
INVESTIGATOR = "investigator", _("Investigator")
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")

View File

@ -22,10 +22,10 @@ class ComplaintService:
For explanation escalation (staff provided):
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):
complaint.department.manager -> hospital admins & PX coordinators
complaint.department.manager -> hospital admins & PX staff
Args:
complaint: Complaint instance
@ -36,7 +36,7 @@ class ComplaintService:
(or None if no target found) and fallback_path describes
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.report_to and staff.report_to.user and staff.report_to.user.is_active:
@ -52,9 +52,9 @@ class ComplaintService:
hospital = complaint.hospital
if hospital:
fallback = get_hospital_admins_and_coordinators(hospital).first()
fallback = get_hospital_admins_and_staff(hospital).first()
if fallback:
return fallback, "hospital_admins_coordinators"
return fallback, "hospital_admins_staff"
return None, "no_target_found"
@ -64,7 +64,7 @@ class ComplaintService:
return True
if user.is_hospital_admin() and user.hospital == complaint.hospital:
return True
if user.is_px_coordinator() and user.hospital == complaint.hospital:
if user.is_px_staff() and user.hospital == complaint.hospital:
return True
if user.is_department_manager() and user.department == complaint.department:
return True
@ -79,7 +79,7 @@ class ComplaintService:
return (
user.is_px_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 complaint.hospital == user.hospital
)

View File

@ -1727,9 +1727,9 @@ def send_complaint_notification(complaint_id, event_type):
if complaint.department and 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:
fallback = get_hospital_admins_and_coordinators(complaint.hospital)
fallback = get_hospital_admins_and_staff(complaint.hospital)
recipients = list(fallback[:3])
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}
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.
Args:
@ -2232,7 +2232,7 @@ def get_hospital_admins_and_coordinators(hospital):
from django.db.models import Q
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()
@ -2311,17 +2311,17 @@ def send_sla_reminders():
if not recipient and complaint.department and 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
fallback_recipients = []
if not recipient:
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():
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}"
)
skipped_count += 1
@ -2405,7 +2405,7 @@ def send_sla_reminders():
# Create timeline entry
if is_unassigned:
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"Recipients: {', '.join(recipient_names)}"
)
@ -2446,7 +2446,7 @@ def send_sla_reminders():
if is_unassigned:
logger.info(
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)"
)
else:
@ -2479,17 +2479,17 @@ def send_sla_reminders():
if not recipient and complaint.department and 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
fallback_recipients = []
if not recipient:
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():
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}"
)
skipped_count += 1
@ -2573,7 +2573,7 @@ def send_sla_reminders():
# Create timeline entry
if is_unassigned:
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"This is the FINAL reminder. Recipients: {', '.join(recipient_names)}"
)
@ -2615,7 +2615,7 @@ def send_sla_reminders():
if is_unassigned:
logger.info(
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)"
)
else:

View File

@ -1062,6 +1062,87 @@ def complaint_export_excel(request):
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
def complaint_export_monthly_calculations(request):
"""
@ -3357,7 +3438,7 @@ def involved_staff_add(request, complaint_pk):
- Responsible: Staff responsible for resolution
- Investigator: Staff investigating the complaint
- Support: Support staff
- Coordinator: Coordination role
- PX Staff: Coordination role
"""
complaint = get_object_or_404(Complaint, pk=complaint_pk)

View File

@ -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)
available_admins = (
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,
)
.exclude(id__in=existing_admin_ids)
@ -344,7 +344,7 @@ def oncall_admin_add(request, schedule_pk):
"schedule": schedule,
"available_admins": available_admins,
"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"),
"title": _("Add On-Call Admin"),
}

View File

@ -49,6 +49,11 @@ urlpatterns = [
# Export Views
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/historical-excel/",
ui_views.complaint_export_historical_excel,
name="complaint_export_historical_excel",
),
path(
"export/monthly-calculations/",
ui_views.complaint_export_monthly_calculations,

View File

@ -1839,3 +1839,342 @@ def export_observations_report(queryset, year, month):
response["Content-Disposition"] = f'attachment; filename="observations_{month_label}.xlsx"'
wb.save(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

View File

@ -13,5 +13,6 @@ urlpatterns = [
path("routing/", config_views.routing_rules_list, name="routing_rules_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>/toggle-active/", config_views.toggle_user_active, name="toggle_user_active"),
path("test/", config_views.test, name="test"),
]

View File

@ -260,6 +260,38 @@ def reset_user_password(request, user_id):
from django.views.decorators.csrf import csrf_exempt
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

View File

@ -98,7 +98,11 @@ def hospital_context(request):
if request.user.is_px_admin():
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
is_source_user = request.user.is_source_user()

View File

@ -5,9 +5,10 @@ Provides decorators to restrict views based on user roles:
- PX Admin
- Hospital Admin
- Department Manager
- PX Coordinator
- PX Staff
- Source User
"""
from functools import wraps
from django.core.exceptions import PermissionDenied
from django.contrib.auth.decorators import login_required
@ -25,15 +26,13 @@ def px_admin_required(view_func):
def system_settings(request):
# Only PX Admins can access this
"""
@wraps(view_func)
@login_required
def _wrapped_view(request, *args, **kwargs):
if not request.user.is_px_admin():
messages.error(
request,
_("Access denied. PX Admin privileges required.")
)
return redirect('analytics:command_center')
messages.error(request, _("Access denied. PX Admin privileges required."))
return redirect("analytics:command_center")
return view_func(request, *args, **kwargs)
@ -49,15 +48,13 @@ def hospital_admin_required(view_func):
def hospital_settings(request):
# Only Hospital Admins and PX Admins can access this
"""
@wraps(view_func)
@login_required
def _wrapped_view(request, *args, **kwargs):
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
messages.error(
request,
_("Access denied. Hospital Admin privileges required.")
)
return redirect('analytics:command_center')
messages.error(request, _("Access denied. Hospital Admin privileges required."))
return redirect("analytics:command_center")
return view_func(request, *args, **kwargs)
@ -73,47 +70,40 @@ def admin_required(view_func):
def management_view(request):
# Any admin can access this
"""
@wraps(view_func)
@login_required
def _wrapped_view(request, *args, **kwargs):
if not (request.user.is_px_admin() or
request.user.is_hospital_admin() or
request.user.is_department_manager()):
messages.error(
request,
_("Access denied. Admin privileges required.")
)
return redirect('analytics:command_center')
if not (request.user.is_px_admin() or request.user.is_hospital_admin() or request.user.is_department_manager()):
messages.error(request, _("Access denied. Admin privileges required."))
return redirect("analytics:command_center")
return view_func(request, *args, **kwargs)
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:
@px_coordinator_required
@px_staff_required
def complaint_management(request):
# Coordinators and admins can access this
# Staff and admins can access this
"""
@wraps(view_func)
@login_required
def _wrapped_view(request, *args, **kwargs):
user = request.user
if not (user.is_px_admin() or
user.is_hospital_admin() or
user.is_department_manager() or
user.has_role('PX Coordinator')):
messages.error(
request,
_("Access denied. PX Coordinator privileges required.")
)
return redirect('analytics:command_center')
if not (
user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager() or user.has_role("PX Staff")
):
messages.error(request, _("Access denied. PX Staff privileges required."))
return redirect("analytics:command_center")
return view_func(request, *args, **kwargs)
@ -131,15 +121,13 @@ def staff_required(view_func):
def internal_tool(request):
# Any hospital staff can access, but not source users
"""
@wraps(view_func)
@login_required
def _wrapped_view(request, *args, **kwargs):
if request.user.is_source_user():
messages.error(
request,
_("Access denied. This page is not available for source users.")
)
return redirect('px_sources:source_user_dashboard')
messages.error(request, _("Access denied. This page is not available for source users."))
return redirect("px_sources:source_user_dashboard")
return view_func(request, *args, **kwargs)
@ -155,22 +143,18 @@ def source_user_required(view_func):
def source_user_dashboard(request):
# Only source users can access this
"""
@wraps(view_func)
@login_required
def _wrapped_view(request, *args, **kwargs):
if not request.user.is_source_user():
raise PermissionDenied(
_("Access denied. Source user privileges required.")
)
raise PermissionDenied(_("Access denied. Source user privileges required."))
# Get source user profile
profile = request.user.get_source_user_profile_active()
if not profile:
messages.error(
request,
_("Your source user account is inactive. Please contact your administrator.")
)
return redirect('accounts:login')
messages.error(request, _("Your source user account is inactive. Please contact your administrator."))
return redirect("accounts:login")
# Store in request for easy access
request.source_user = profile
@ -191,12 +175,13 @@ def block_source_user(view_func):
def staff_management(request):
# Source users CANNOT access this
"""
@wraps(view_func)
@login_required
def _wrapped_view(request, *args, **kwargs):
if request.user.is_source_user():
# 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)
@ -212,15 +197,14 @@ def source_user_or_admin(view_func):
def complaint_detail(request, pk):
# Both source users and admins can view
"""
@wraps(view_func)
@login_required
def _wrapped_view(request, *args, **kwargs):
user = request.user
# Allow admins
if (user.is_px_admin() or
user.is_hospital_admin() or
user.is_department_manager()):
if user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager():
return view_func(request, *args, **kwargs)
# Allow active source users

View File

@ -75,10 +75,10 @@ class Command(BaseCommand):
"is_staff": False,
},
{
"email": "e2e-px-coordinator@px360.test",
"role": "PX Coordinator",
"email": "e2e-px-staff@px360.test",
"role": "PX Staff",
"first_name": "E2E",
"last_name": "PX Coordinator",
"last_name": "PX Staff",
"hospital": hospital,
"is_staff": False,
},

View File

@ -698,7 +698,7 @@ class Command(BaseCommand):
{
"id": "184",
"name_ar": "منسقة مراجعي مستشفى قوى الأمن",
"name_en": "SFH Patient Coordinator",
"name_en": "SFH Patient Staff",
"location_id": "48",
"main_section_id": "4",
},
@ -1437,8 +1437,8 @@ class Command(BaseCommand):
"level": 60,
},
{
"name": "px_coordinator",
"display_name": "PX Coordinator",
"name": "px_staff",
"display_name": "PX Staff",
"description": "Can manage PX actions",
"level": 50,
},
@ -2131,10 +2131,10 @@ class Command(BaseCommand):
"hospital_specific": True,
},
{
"role_name": "PX Coordinator",
"role_name": "PX Staff",
"suffix": "pxcoord",
"first_name": "PX",
"last_name": "Coordinator",
"last_name": "Staff",
"hospital_specific": True,
},
{

View File

@ -1,11 +1,24 @@
"""
Template filters for hospital-related functionality
"""
from django import template
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
def replace(value, arg):
"""
@ -16,7 +29,7 @@ def replace(value, arg):
"""
if isinstance(value, str) and isinstance(arg, str):
try:
old, new = arg.split(':', 1)
old, new = arg.split(":", 1)
return value.replace(old, new)
except ValueError:
return value
@ -74,4 +87,4 @@ def get_all_hospitals():
"""
from apps.organizations.models import Hospital
return Hospital.objects.all().order_by('name', 'city')
return Hospital.objects.all().order_by("name", "city")

View File

@ -112,6 +112,8 @@ def switch_hospital(request):
"hospital": {
"id": str(hospital.id),
"name": hospital.name,
"display_name": hospital.get_display_name(),
"display_name_ar": hospital.get_display_name_ar(),
"code": hospital.code,
},
}

View File

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

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

View 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}"

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

File diff suppressed because it is too large Load Diff

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

View 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 %}

View 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 %}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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 %}

View 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 }} &nbsp;|&nbsp; <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>

View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View 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",
),
]

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

View File

@ -182,6 +182,7 @@ class NotificationService:
}
response = requests.get(url, params=params, timeout=30)
logger.info(f"Mshastra API response for {phone}: {response.text}")
response_text = response.text.strip()
log.provider_response = {"status_code": response.status_code, "response": response_text}

View File

@ -441,7 +441,9 @@ def notification_settings_api(request, hospital_id=None):
if field.name not in ["id", "uuid", "created_at", "updated_at", "hospital"]:
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

View File

@ -30,13 +30,14 @@ class OrganizationAdmin(admin.ModelAdmin):
class HospitalAdmin(admin.ModelAdmin):
"""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"]
search_fields = ["name", "name_ar", "code", "license_number"]
search_fields = ["name", "name_ar", "display_name", "display_name_ar", "code", "license_number"]
ordering = ["name"]
fieldsets = (
(None, {"fields": ("organization", "name", "name_ar", "code")}),
("Display", {"fields": ("display_name", "display_name_ar")}),
("Contact Information", {"fields": ("address", "city", "phone", "email")}),
("Executive Leadership", {"fields": ("ceo", "medical_director", "coo", "cfo")}),
("Details", {"fields": ("license_number", "capacity", "status")}),

View File

@ -62,6 +62,17 @@ class Hospital(UUIDModel, TimeStampedModel):
)
name = models.CharField(max_length=200)
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)
# Contact information
@ -121,14 +132,20 @@ class Hospital(UUIDModel, TimeStampedModel):
verbose_name_plural = "Hospitals"
def __str__(self):
return self.name
return self.get_display_name()
def get_localized_name(self):
from django.utils.translation import get_language
if get_language() == "ar" and self.name_ar:
return self.name_ar
return self.name
if get_language() == "ar":
return self.get_display_name_ar()
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):
@ -413,7 +430,7 @@ class Patient(UUIDModel, TimeStampedModel):
# Basic information
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="")
first_name = models.CharField(max_length=100)

View File

@ -52,6 +52,8 @@ class HospitalSerializer(serializers.ModelSerializer):
"organization_name",
"name",
"name_ar",
"display_name",
"display_name_ar",
"code",
"address",
"city",

View File

@ -576,7 +576,7 @@ class DoctorRatingAdapter:
"""
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
queryset = PhysicianIndividualRating.objects.filter(
@ -586,6 +586,14 @@ class DoctorRatingAdapter:
if 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
staff_ratings = queryset.values("staff").annotate(
avg_rating=Avg("rating"),

View File

@ -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."))

View File

@ -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.
"""
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:
raise self.retry(exc=exc)

View 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

View File

@ -150,7 +150,7 @@ class QIProjectTaskForm(forms.ModelForm):
class Meta:
model = QIProjectTask
fields = ["title", "description", "assigned_to", "status", "due_date", "order"]
fields = ["title", "description", "assigned_to", "status", "due_date", "order", "pdca_phase"]
widgets = {
"title": forms.TextInput(
attrs={
@ -187,15 +187,31 @@ class QIProjectTaskForm(forms.ModelForm):
"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):
self.project = kwargs.pop("project", None)
self.current_phase = kwargs.pop("current_phase", None)
super().__init__(*args, **kwargs)
# Make order field not required (has default value of 0)
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
if self.project and self.project.hospital:
self.fields["assigned_to"].queryset = User.objects.filter(

View File

@ -4,15 +4,24 @@ Projects models - Quality Improvement (QI) projects tracking
This module implements QI project management:
- Project tracking
- Task management
- PDCA cycle phases
- Milestone tracking
- Outcome measurement
"""
from django.db import models
from django.utils.translation import gettext_lazy as _
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):
"""
Quality Improvement Project.
@ -95,6 +104,13 @@ class QIProjectTask(UUIDModel, TimeStampedModel):
"""
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)
description = models.TextField(blank=True)
@ -119,3 +135,41 @@ class QIProjectTask(UUIDModel, TimeStampedModel):
def __str__(self):
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()}"

View File

@ -13,11 +13,12 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.utils.translation import gettext_lazy as _
from apps.core.decorators import block_source_user
from apps.core.models import StatusChoices
from apps.organizations.models import Hospital
from apps.px_action_center.models import PXAction
from .forms import ConvertToProjectForm, QIProjectForm, QIProjectTaskForm, QIProjectTemplateForm, TaskTemplateFormSet
from .models import QIProject, QIProjectTask
from .models import QIProject, QIProjectTask, PDCAPhase, PDCAPhaseChoices
@block_source_user
@ -100,7 +101,7 @@ def project_detail(request, pk):
project = get_object_or_404(
QIProject.objects.filter(is_template=False)
.select_related("hospital", "department", "project_lead")
.prefetch_related("team_members", "related_actions", "tasks"),
.prefetch_related("team_members", "related_actions", "tasks", "pdca_phases"),
pk=pk,
)
@ -110,16 +111,17 @@ def project_detail(request, pk):
messages.error(request, _("You don't have permission to view this project."))
return redirect("projects:project_list")
# Get tasks
tasks = project.tasks.all().order_by("order", "created_at")
# Get related actions
related_actions = project.related_actions.all()
# Get PDCA phases
pdca_phases = {phase.phase: phase for phase in project.pdca_phases.all()}
context = {
"project": project,
"tasks": tasks,
"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,
}
@ -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
@login_required
def task_create(request, project_pk):
"""Add a new task to a project"""
def task_create(request, project_pk, phase=None):
"""Add a new task to a project (optionally within a PDCA phase)"""
user = request.user
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
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)
if request.method == "POST":
form = QIProjectTaskForm(request.POST, project=project)
form = QIProjectTaskForm(request.POST, project=project, current_phase=current_phase)
if form.is_valid():
task = form.save(commit=False)
task.project = project
task.save()
messages.success(request, _("Task added successfully."))
return redirect("projects:project_detail", pk=project.pk)
return _task_redirect(project, task)
else:
form = QIProjectTaskForm(project=project)
form = QIProjectTaskForm(project=project, current_phase=current_phase)
context = {
"form": form,
"project": project,
"pdca_phase": current_phase,
"is_create": True,
}
@ -396,11 +409,14 @@ def task_create(request, project_pk):
@block_source_user
@login_required
def task_edit(request, project_pk, task_pk):
def task_edit(request, project_pk, task_pk, phase=None):
"""Edit an existing task"""
user = request.user
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
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
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)
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():
task = form.save()
# 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.save()
messages.success(request, _("Task updated successfully."))
return redirect("projects:project_detail", pk=project.pk)
return _task_redirect(project, task)
else:
form = QIProjectTaskForm(instance=task, project=project)
form = QIProjectTaskForm(instance=task, project=project, current_phase=current_phase)
context = {
"form": form,
"project": project,
"task": task,
"pdca_phase": current_phase,
"is_create": False,
}
@ -434,7 +451,7 @@ def task_edit(request, project_pk, task_pk):
@block_source_user
@login_required
def task_delete(request, project_pk, task_pk):
def task_delete(request, project_pk, task_pk, phase=None):
"""Delete a task"""
user = request.user
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."))
return redirect("projects:project_detail", pk=project.pk)
redirect_target = _task_redirect(project, task)
if request.method == "POST":
task.delete()
messages.success(request, _("Task deleted successfully."))
return redirect("projects:project_detail", pk=project.pk)
return redirect_target
context = {
"project": project,
"task": task,
"pdca_phase": task.pdca_phase,
}
return render(request, "projects/task_delete_confirm.html", context)
@ -460,7 +480,7 @@ def task_delete(request, project_pk, task_pk):
@block_source_user
@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"""
user = request.user
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()
messages.success(request, _("Task status updated."))
return redirect("projects:project_detail", pk=project.pk)
return _task_redirect(project, task)
# =============================================================================
@ -583,7 +603,7 @@ def template_create(request):
template.save()
# 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():
formset.save()
@ -591,10 +611,10 @@ def template_create(request):
return redirect("projects:template_list")
else:
# Form is invalid, show formset with errors
formset = TaskTemplateFormSet(request.POST, prefix='tasktemplate_set')
formset = TaskTemplateFormSet(request.POST, prefix="tasktemplate_set")
else:
form = QIProjectTemplateForm(request=request)
formset = TaskTemplateFormSet(prefix='tasktemplate_set')
formset = TaskTemplateFormSet(prefix="tasktemplate_set")
context = {
"form": form,
@ -620,7 +640,7 @@ def template_edit(request, pk):
if request.method == "POST":
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():
form.save()
formset.save()
@ -628,7 +648,7 @@ def template_edit(request, pk):
return redirect("projects:template_list")
else:
form = QIProjectTemplateForm(instance=template, request=request)
formset = TaskTemplateFormSet(instance=template, prefix='tasktemplate_set')
formset = TaskTemplateFormSet(instance=template, prefix="tasktemplate_set")
context = {
"form": form,
@ -746,3 +766,136 @@ def convert_action_to_project(request, action_pk):
}
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

View File

@ -2,33 +2,46 @@ from django.urls import path
from . import ui_views
app_name = 'projects'
app_name = "projects"
urlpatterns = [
# QI Project Views
path('', ui_views.project_list, name='project_list'),
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('<uuid:pk>/', ui_views.project_detail, name='project_detail'),
path('<uuid:pk>/edit/', ui_views.project_edit, name='project_edit'),
path('<uuid:pk>/delete/', ui_views.project_delete, name='project_delete'),
# Task Management
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>/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("", ui_views.project_list, name="project_list"),
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("<uuid:pk>/", ui_views.project_detail, name="project_detail"),
path("<uuid:pk>/edit/", ui_views.project_edit, name="project_edit"),
path("<uuid:pk>/delete/", ui_views.project_delete, name="project_delete"),
# Task Management (legacy project-level + new phase-level)
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>/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"),
# 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
path('templates/', ui_views.template_list, name='template_list'),
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>/edit/', ui_views.template_edit, name='template_edit'),
path('templates/<uuid:pk>/delete/', ui_views.template_delete, name='template_delete'),
path("templates/", ui_views.template_list, name="template_list"),
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>/edit/", ui_views.template_edit, name="template_edit"),
path("templates/<uuid:pk>/delete/", ui_views.template_delete, name="template_delete"),
# 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
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"),
]

View File

@ -326,7 +326,7 @@ class RoutingRule(UUIDModel, TimeStampedModel):
)
# 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(
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="routing_rules"
)

View File

@ -553,7 +553,7 @@ def action_create_from_ai(request, complaint_id):
# Check permission
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)
# 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)
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)
action_text = request.POST.get("action", "")

View File

@ -28,7 +28,7 @@ class PXActionViewSet(viewsets.ModelViewSet):
Permissions:
- 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()

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

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

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

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

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

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

View File

@ -122,242 +122,3 @@ The JSON file structure should look like:
}
}
```
---
## 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*

View File

@ -71,238 +71,3 @@ This guide provides step-by-step instructions for setting up LinkedIn API integr
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)*

View File

@ -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`
> ⚠️ **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*

View File

@ -79,300 +79,3 @@ Once your app is created/approved:
- **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*

View File

@ -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`
> ⚠️ **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*

View File

@ -121,322 +121,3 @@ The JSON file structure:
}
}
```
---
## 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*

Binary file not shown.

View File

@ -4,7 +4,7 @@ from apps.organizations.models import Hospital
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_CHOICES = [
@ -12,163 +12,151 @@ class Command(BaseCommand):
{"value": "2", "label": "Poor", "label_ar": "ضعيف"},
{"value": "3", "label": "Neutral", "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):
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
hospital = Hospital.objects.filter(status='active').first()
hospital = Hospital.objects.filter(status="active").first()
if not hospital:
self.stdout.write(self.style.ERROR('No active hospital found!'))
self.stdout.write(self.style.ERROR("No active hospital found!"))
return
self.stdout.write(f'Using hospital: {hospital.name}')
self.stdout.write(f"Using hospital: {hospital.name}")
# Define survey templates with bilingual questions
survey_templates = [
{
'name': 'Appointment Satisfaction Survey',
'name_ar': 'استبيان رضا المواعيد',
'survey_type': 'stage',
'questions': [
"name": "Appointment Satisfaction Survey",
"name_ar": "استبيان رضا المواعيد",
"survey_type": "stage",
"questions": [
{
'text': 'Did the Appointment Section\'s service exceed your expectations?',
'text_ar': 'هل تجاوزت خدمة قسم المواعيد توقعاتك؟'
"text": "Did the Appointment Section's service exceed your expectations?",
"text_ar": "هل تجاوزت خدمة قسم المواعيد توقعاتك؟",
},
{
'text': 'Did the doctor explain everything about your case?',
'text_ar': 'هل شرح الطبيب كل شيء عن حالتك؟'
"text": "Did the doctor explain everything about your case?",
"text_ar": "هل شرح الطبيب كل شيء عن حالتك؟",
},
{
'text': 'Did the pharmacist explain to you the medication clearly?',
'text_ar': 'هل شرح الصيدلي لك الدواء بشكل واضح؟'
"text": "Did the pharmacist explain to you the medication clearly?",
"text_ar": "هل شرح الصيدلي لك الدواء بشكل واضح؟",
},
{
'text': 'Did the staff attend your needs in an understandable language?',
'text_ar': 'هل قام الموظفون بتلبية احتياجاتك بلغة مفهومة؟'
"text": "Did the staff attend your needs in an understandable language?",
"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_ar': 'هل كان من السهل الحصول على موعد؟'
"text": "Were you served by Laboratory Receptionists as required?",
"text_ar": "هل قدمت لك خدمة استقبال المختبر كما هو مطلوب؟",
},
{
'text': 'Were you satisfied with your interaction with the doctor?',
'text_ar': 'هل كنت راضٍ عن تفاعلك مع الطبيب؟'
"text": "Were you served by Radiology Receptionists as required?",
"text_ar": "هل قدمت لك خدمة استقبال الأشعة كما هو مطلوب؟",
},
{
'text': 'Were you served by Laboratory Receptionists as required?',
'text_ar': 'هل قدمت لك خدمة استقبال المختبر كما هو مطلوب؟'
"text": "Were you served by Receptionists as required?",
"text_ar": "هل قدمت لك خدمة الاستقبال كما هو مطلوب؟",
},
{
'text': 'Were you served by Radiology Receptionists as required?',
'text_ar': 'هل قدمت لك خدمة استقبال الأشعة كما هو مطلوب؟'
"text": "Would you recommend the hospital to your friends and family?",
"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_ar': 'استبيان رضا المرضى المقيمين',
'survey_type': 'stage',
'questions': [
"name": "Inpatient Satisfaction Survey",
"name_ar": "استبيان رضا المرضى المقيمين",
"survey_type": "stage",
"questions": [
{
'text': 'Are the Patient Relations Coordinators/ Social Workers approachable and accessible?',
'text_ar': 'هل منسقو علاقات المرضى / الأخصائيون الاجتماعيون متاحون وسهل الوصول إليهم؟'
"text": "Are the Patient Relations Staff/ Social Workers approachable and accessible?",
"text_ar": "هل منسقو علاقات المرضى / الأخصائيون الاجتماعيون متاحون وسهل الوصول إليهم؟",
},
{
'text': 'Did the physician give you clear information about your medications?',
'text_ar': 'هل قدم الطبيب لك معلومات واضحة عن أدويتك؟'
"text": "Did the physician give you clear information about your medications?",
"text_ar": "هل قدم الطبيب لك معلومات واضحة عن أدويتك؟",
},
{
'text': 'Did your physician exerted efforts to include you in making the decisions about your treatment?',
'text_ar': 'هل بذل طبيبك جهداً لإشراكك في اتخاذ القرارات حول علاجك؟'
"text": "Did your physician exerted efforts to include you in making the decisions about your treatment?",
"text_ar": "هل بذل طبيبك جهداً لإشراكك في اتخاذ القرارات حول علاجك؟",
},
{
'text': 'Is the cleanliness level of the hospital exceeding your expectations?',
'text_ar': 'هل مستوى نظافة المستشفى يتجاوز توقعاتك؟'
"text": "Is the cleanliness level of the hospital exceeding your expectations?",
"text_ar": "هل مستوى نظافة المستشفى يتجاوز توقعاتك؟",
},
{
'text': 'Was there a clear explanation given to you regarding your financial coverage and payment responsibility?',
'text_ar': 'هل تم تقديم شرح واضح لك بخصوص التغطية المالية ومسؤولية الدفع؟'
"text": "Was there a clear explanation given to you regarding your financial coverage and payment responsibility?",
"text_ar": "هل تم تقديم شرح واضح لك بخصوص التغطية المالية ومسؤولية الدفع؟",
},
{
'text': 'Were you satisfied with our admission time and process?',
'text_ar': 'هل كنت راضٍ عن وقت وعمليات القبول لدينا؟'
"text": "Were you satisfied with our admission time and process?",
"text_ar": "هل كنت راضٍ عن وقت وعمليات القبول لدينا؟",
},
{
'text': 'Were you satisfied with our discharge time and process?',
'text_ar': 'هل كنت راضٍ عن وقت وعمليات الخروج لدينا؟'
"text": "Were you satisfied with our discharge time and process?",
"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_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': 'Were you satisfied with the food services?',
'text_ar': 'هل كنت راضٍ عن خدمات الطعام؟'
"text": "Would you recommend the hospital to your friends and family?",
"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_ar': 'استبيان رضا العيادات الخارجية',
'survey_type': 'stage',
'questions': [
"name": "Outpatient Satisfaction Survey",
"name_ar": "استبيان رضا العيادات الخارجية",
"survey_type": "stage",
"questions": [
{
'text': 'Did the doctor explained everything about your case?',
'text_ar': 'هل شرح الطبيب كل شيء عن حالتك؟'
"text": "Did the doctor explained everything about your case?",
"text_ar": "هل شرح الطبيب كل شيء عن حالتك؟",
},
{
'text': 'Did the pharmacist explained to you the medication clearly?',
'text_ar': 'هل شرح الصيدلي لك الدواء بشكل واضح؟'
"text": "Did the pharmacist explained to you the medication clearly?",
"text_ar": "هل شرح الصيدلي لك الدواء بشكل واضح؟",
},
{
'text': 'Did the staff attended your needs in an understandable language?',
'text_ar': 'هل قام الموظفون بتلبية احتياجاتك بلغة مفهومة؟'
"text": "Did the staff attended your needs in an understandable language?",
"text_ar": "هل قام الموظفون بتلبية احتياجاتك بلغة مفهومة؟",
},
{
'text': 'Were you satisfied with your interaction with the doctor?',
'text_ar': 'هل كنت راضٍ عن تفاعلك مع الطبيب؟'
"text": "Were you satisfied with your interaction with the doctor?",
"text_ar": "هل كنت راضٍ عن تفاعلك مع الطبيب؟",
},
{
'text': 'Were you served by Laboratory Receptionists as required?',
'text_ar': 'هل قدمت لك خدمة استقبال المختبر كما هو مطلوب؟'
"text": "Were you served by Laboratory Receptionists as required?",
"text_ar": "هل قدمت لك خدمة استقبال المختبر كما هو مطلوب؟",
},
{
'text': 'Were you served by Radiology Receptionists as required?',
'text_ar': 'هل قدمت لك خدمة استقبال الأشعة كما هو مطلوب؟'
"text": "Were you served by Radiology Receptionists as required?",
"text_ar": "هل قدمت لك خدمة استقبال الأشعة كما هو مطلوب؟",
},
{
'text': 'Were you served by Receptionists as required?',
'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': 'هل توصي بالمستشفى لأصدقائك وعائلتك؟'
}
]
}
"text": "Would you recommend the hospital to your friends and family?",
"text_ar": "هل توصي بالمستشفى لأصدقائك وعائلتك؟",
},
],
},
]
created_count = 0
@ -177,13 +165,10 @@ class Command(BaseCommand):
for template_data in survey_templates:
# Check if template already exists
existing = SurveyTemplate.objects.filter(
name=template_data['name'],
hospital=hospital
).first()
existing = SurveyTemplate.objects.filter(name=template_data["name"], hospital=hospital).first()
if existing:
self.stdout.write(f'✓ Updating existing template: {template_data["name"]}')
self.stdout.write(f"✓ Updating existing template: {template_data['name']}")
template = existing
updated_count += 1
# Delete existing questions to avoid duplicates
@ -191,60 +176,62 @@ class Command(BaseCommand):
else:
# Create new template
template = SurveyTemplate.objects.create(
name=template_data['name'],
name_ar=template_data['name_ar'],
name=template_data["name"],
name_ar=template_data["name_ar"],
hospital=hospital,
survey_type=template_data['survey_type'],
scoring_method='average',
survey_type=template_data["survey_type"],
scoring_method="average",
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
# 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(
survey_template=template,
text=question_data['text'],
text_ar=question_data['text_ar'],
text=question_data["text"],
text_ar=question_data["text_ar"],
question_type=QuestionType.MULTIPLE_CHOICE,
order=order,
is_required=True,
choices_json=self.SATISFACTION_CHOICES
choices_json=self.SATISFACTION_CHOICES,
)
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(
f'\n✓ Survey Templates created/updated successfully!\n'
f' Created: {created_count}\n'
f' Updated: {updated_count}\n'
f' Total Questions: {total_questions}'
))
self.stdout.write(
self.style.SUCCESS(
f"\n✓ Survey Templates created/updated successfully!\n"
f" Created: {created_count}\n"
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("\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()}'
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)
self.stdout.write("\n" + "=" * 70)
self.stdout.write(self.style.SUCCESS("SATISFACTION SCALE OPTIONS:"))
self.stdout.write("=" * 70)
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(self.style.SUCCESS('NEXT STEPS:'))
self.stdout.write('='*70)
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('3. Verify the satisfaction options appear correctly')
self.stdout.write('4. Check that both English and Arabic versions work')
self.stdout.write('='*70)
self.stdout.write("\n" + "=" * 70)
self.stdout.write(self.style.SUCCESS("NEXT STEPS:"))
self.stdout.write("=" * 70)
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("3. Verify the satisfaction options appear correctly")
self.stdout.write("4. Check that both English and Arabic versions work")
self.stdout.write("=" * 70)

View File

@ -39,7 +39,12 @@ class SurveyDeliveryService:
@staticmethod
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:
"""
Generate SMS message with survey link.
@ -47,7 +52,8 @@ class SurveyDeliveryService:
Args:
recipient_name: Recipient's first name (patient or staff) - not used in new format
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
language: Language code ('en' or 'ar') - not used, bilingual format for all
@ -55,9 +61,9 @@ class SurveyDeliveryService:
SMS message text
"""
facility = hospital_name or "our facility"
facility_ar = hospital_name_ar or facility
# Bilingual format - Arabic first, then English
message = f"شكراً على زيارتك {facility} والحمد لله على سلامتك\n\n"
message = f"شكراً على زيارتك {facility_ar} والحمد لله على سلامتك\n\n"
message += f"نتطلع لسماع رأيك حول تجربتك الأخيرة عبر الرابط التالي لنسهم في تحسين خدماتنا\n"
message += f"{survey_url}\n\n"
message += f"دمتم بصحة\n\n"
@ -137,11 +143,12 @@ class SurveyDeliveryService:
# Generate survey URL and message
survey_url = SurveyDeliveryService.generate_survey_url(survey_instance)
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
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(
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)
@ -210,7 +217,7 @@ class SurveyDeliveryService:
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
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)
# Use NotificationService for delivery (supports API backend)
@ -229,10 +236,11 @@ class SurveyDeliveryService:
metadata["staff_id"] = str(survey_instance.staff.id)
# Set email subject
hospital_display = survey_instance.hospital.get_display_name() if survey_instance.hospital else ""
if is_staff:
subject = f"Staff Experience Survey - {survey_instance.hospital.name}"
subject = f"Staff Experience Survey - {hospital_display}"
else:
subject = f"Patient Experience Survey - {survey_instance.hospital.name}"
subject = f"Patient Experience Survey - {hospital_display}"
# Try API first, fallback to regular email
notification_log = NotificationService.send_email_via_api(
@ -346,13 +354,14 @@ class SurveyDeliveryService:
full_name = survey_instance.get_recipient_name()
recipient_name = full_name.split()[0] if full_name and full_name.strip() else "there"
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":
facility = hospital_name_ar or hospital_name or "our facility"
message = f"مرحباً {recipient_name}،\n\n"
if hospital_name:
message += f"شكراً لكونك جزءاً من {hospital_name}. "
if facility:
message += f"شكراً لكونك جزءاً من {facility}. "
if is_staff:
message += "نقدر ملاحظاتك! يرجى إكمال استبيان تجربة الموظفين:\n\n"
else:
@ -360,9 +369,10 @@ class SurveyDeliveryService:
message += f"{survey_url}\n\n"
message += "سيستغرق هذا الاستبيان حوالي 2-3 دقائق."
else:
facility = hospital_name or "our facility"
message = f"Hello {recipient_name},\n\n"
if hospital_name:
message += f"Thank you for being part of {hospital_name}. "
if facility:
message += f"Thank you for being part of {facility}. "
if is_staff:
message += "We value your feedback! Please complete our staff experience survey:\n\n"
else:

View File

@ -25,6 +25,8 @@ from .forms import (
BulkCSVSurveySendForm,
QuestionRoutingRuleFormSet,
)
from apps.integrations.models import HISEventType
from .services import SurveyDeliveryService
from .models import SurveyInstance, SurveyTemplate, SurveyQuestion
from .tasks import send_satisfaction_feedback
@ -262,6 +264,17 @@ def survey_template_list(request):
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
@login_required
def survey_template_create(request):
@ -305,6 +318,7 @@ def survey_template_create(request):
"form": form,
"formset": formset,
"routing_formset": routing_formset,
"visit_timeline_events": _get_visit_timeline_events(),
}
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")
]
),
"visit_timeline_events": _get_visit_timeline_events(),
}
return render(request, "surveys/template_form.html", context)

47
build-and-push.sh Executable file
View 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

File diff suppressed because one or more lines are too long

View File

@ -43,10 +43,10 @@ app.conf.beat_schedule = {
# 'schedule': crontab(minute='*/5'), # Every 5 minutes
# },
# Send pending scheduled surveys every 10 minutes
"send-pending-scheduled-surveys": {
"task": "apps.surveys.tasks.send_pending_scheduled_surveys",
"schedule": crontab(minute="*/10"), # Every 10 minutes
},
# "send-pending-scheduled-surveys": {
# "task": "apps.surveys.tasks.send_pending_scheduled_surveys",
# "schedule": crontab(minute="*/10"), # Every 10 minutes
# },
# Check for overdue complaints every 15 minutes
"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",
"schedule": crontab(hour=1, minute=30),
},
# Calculate physician monthly ratings on the 1st of each month at 2 AM
"calculate-physician-ratings": {
"task": "apps.physicians.tasks.calculate_monthly_ratings",
"schedule": crontab(hour=2, minute=0, day_of_month=1),
# Auto-aggregate physician individual ratings into monthly ratings daily at 2 AM
"auto-aggregate-doctor-ratings-daily": {
"task": "apps.physicians.tasks.auto_aggregate_daily",
"schedule": crontab(hour=2, minute=0),
},
# Scraping schedules
"scrape-youtube-hourly": {
@ -188,12 +188,17 @@ app.conf.beat_schedule = {
"task": "apps.analytics.tasks.precompute_dashboard_cache_task",
"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": {
"task": "apps.analytics.tasks.generate_executive_summary_task",
"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": {
"task": "apps.analytics.tasks.generate_action_recommendations_task",
"schedule": crontab(hour=6, minute=30),
@ -208,6 +213,37 @@ app.conf.beat_schedule = {
"task": "apps.analytics.tasks_digest.send_monthly_digest_task",
"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
},
}

View File

@ -73,6 +73,7 @@ LOCAL_APPS = [
"apps.simulator",
"apps.reports",
"apps.rca",
"apps.executive_summary",
]
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
@ -123,12 +124,17 @@ WSGI_APPLICATION = "config.wsgi.application"
# }
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
"default": env.db(
"DATABASE_URL",
default="postgresql://px360:px360@localhost:5433/px360",
)
}
# DATABASES = {
# "default": {
# "ENGINE": "django.db.backends.sqlite3",
# "NAME": BASE_DIR / "db.sqlite3",
# }
# }
# Password validation
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators

View File

@ -23,12 +23,14 @@ CSRF_TRUSTED_ORIGINS = [
"https://micha-nonparabolic-lovie.ngrok-free.dev",
]
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
# Database inherits from base.py (PostgreSQL)
# To temporarily switch back to SQLite for testing, uncomment:
# DATABASES = {
# "default": {
# "ENGINE": "django.db.backends.sqlite3",
# "NAME": BASE_DIR / "db.sqlite3",
# }
# }
# Email backend for development
# 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

View File

@ -5,34 +5,28 @@ from .base import * # noqa
DEBUG = False
# Security settings for production
SECURE_SSL_REDIRECT = True
# Caddy handles SSL termination, so trust the X-Forwarded-Proto header
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SECURE_SSL_REDIRECT = env.bool('SECURE_SSL_REDIRECT', default=False)
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000 # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
# Allowed hosts must be set via environment variable
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS')
# Database - Must be set via environment variable
DATABASES = {
'default': env.db('DATABASE_URL')
}
# Celery - Production settings
CELERY_TASK_ALWAYS_EAGER = False
# Logging - Production level
LOGGING['loggers']['django']['level'] = 'INFO' # noqa
LOGGING['loggers']['apps']['level'] = 'INFO' # noqa
# Email backend for production
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
# Static files - Ensure WhiteNoise is properly configured
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
# Admin URL should be changed in production
ADMIN_URL = env('ADMIN_URL', default='admin/')

View File

@ -27,6 +27,7 @@ urlpatterns = [
path("physicians/", include("apps.physicians.urls", namespace="physicians")),
path("feedback/", include("apps.feedback.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("journeys/", include("apps.journeys.urls")),
path("surveys/", include("apps.surveys.urls")),

Binary file not shown.

Binary file not shown.

57
deploy.prod.sh Executable file
View 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