pre dep
This commit is contained in:
parent
177a7e0f5f
commit
bcb9c86541
@ -10,6 +10,7 @@ For the full list of settings and their values, see
|
|||||||
https://docs.djangoproject.com/en/6.0/ref/settings/
|
https://docs.djangoproject.com/en/6.0/ref/settings/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
@ -17,10 +18,12 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
|||||||
|
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
# Quick-start development settings - unsuitable for production
|
||||||
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/6.0/howto/deployment-checklist/
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
SECRET_KEY = 'django-insecure-b!)avw-ibl#m*p-_vw%k4#)*b8a*-(7k4-#6eb8un@=-mksed('
|
SECRET_KEY = "django-insecure-b!)avw-ibl#m*p-_vw%k4#)*b8a*-(7k4-#6eb8un@=-mksed("
|
||||||
|
|
||||||
|
CIPHER_KEY = os.environ.get("CIPHER_KEY", SECRET_KEY)
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
@ -31,63 +34,62 @@ ALLOWED_HOSTS = []
|
|||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
'django.contrib.admin',
|
"django.contrib.admin",
|
||||||
'django.contrib.auth',
|
"django.contrib.auth",
|
||||||
'django.contrib.contenttypes',
|
"django.contrib.contenttypes",
|
||||||
'django.contrib.sessions',
|
"django.contrib.sessions",
|
||||||
'django.contrib.messages',
|
"django.contrib.messages",
|
||||||
'django.contrib.staticfiles',
|
"django.contrib.staticfiles",
|
||||||
# Apps
|
# Apps
|
||||||
'apps.core',
|
"apps.core",
|
||||||
'apps.accounts',
|
"apps.accounts",
|
||||||
'apps.dashboard',
|
"apps.dashboard",
|
||||||
'apps.social',
|
"apps.social",
|
||||||
'django_celery_beat',
|
"django_celery_beat",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
'django.middleware.security.SecurityMiddleware',
|
"django.middleware.security.SecurityMiddleware",
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
'django.middleware.common.CommonMiddleware',
|
"django.middleware.common.CommonMiddleware",
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
# PX Source User Access Control
|
# PX Source User Access Control
|
||||||
'apps.px_sources.middleware.SourceUserRestrictionMiddleware',
|
"apps.px_sources.middleware.SourceUserRestrictionMiddleware",
|
||||||
'apps.px_sources.middleware.SourceUserSessionMiddleware',
|
"apps.px_sources.middleware.SourceUserSessionMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = 'PX360.urls'
|
ROOT_URLCONF = "PX360.urls"
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
'DIRS': [BASE_DIR / 'templates']
|
"DIRS": [BASE_DIR / "templates"],
|
||||||
,
|
"APP_DIRS": True,
|
||||||
'APP_DIRS': True,
|
"OPTIONS": {
|
||||||
'OPTIONS': {
|
"context_processors": [
|
||||||
'context_processors': [
|
"django.template.context_processors.request",
|
||||||
'django.template.context_processors.request',
|
"django.contrib.auth.context_processors.auth",
|
||||||
'django.contrib.auth.context_processors.auth',
|
"django.contrib.messages.context_processors.messages",
|
||||||
'django.contrib.messages.context_processors.messages',
|
"apps.core.context_processors.hospital_context",
|
||||||
'apps.core.context_processors.hospital_context',
|
"apps.core.context_processors.sidebar_counts",
|
||||||
'apps.core.context_processors.sidebar_counts',
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
WSGI_APPLICATION = 'PX360.wsgi.application'
|
WSGI_APPLICATION = "PX360.wsgi.application"
|
||||||
|
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
|
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
"default": {
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
'NAME': BASE_DIR / 'db.sqlite3',
|
"NAME": BASE_DIR / "db.sqlite3",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,16 +99,16 @@ DATABASES = {
|
|||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{
|
{
|
||||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -114,9 +116,9 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
# Internationalization
|
# Internationalization
|
||||||
# https://docs.djangoproject.com/en/6.0/topics/i18n/
|
# https://docs.djangoproject.com/en/6.0/topics/i18n/
|
||||||
|
|
||||||
LANGUAGE_CODE = 'en-us'
|
LANGUAGE_CODE = "en-us"
|
||||||
|
|
||||||
TIME_ZONE = 'UTC'
|
TIME_ZONE = "UTC"
|
||||||
|
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
|
|
||||||
@ -126,35 +128,34 @@ USE_TZ = True
|
|||||||
# Static files (CSS, JavaScript, Images)
|
# Static files (CSS, JavaScript, Images)
|
||||||
# https://docs.djangoproject.com/en/6.0/howto/static-files/
|
# https://docs.djangoproject.com/en/6.0/howto/static-files/
|
||||||
|
|
||||||
STATIC_URL = 'static/'
|
STATIC_URL = "static/"
|
||||||
|
|
||||||
# Celery Configuration
|
# Celery Configuration
|
||||||
CELERY_BROKER_URL = 'redis://localhost:6379/0'
|
CELERY_BROKER_URL = "redis://localhost:6379/0"
|
||||||
CELERY_RESULT_BACKEND = 'redis://localhost:6379/0'
|
CELERY_RESULT_BACKEND = "redis://localhost:6379/0"
|
||||||
CELERY_ACCEPT_CONTENT = ['json']
|
CELERY_ACCEPT_CONTENT = ["json"]
|
||||||
CELERY_TASK_SERIALIZER = 'json'
|
CELERY_TASK_SERIALIZER = "json"
|
||||||
CELERY_RESULT_SERIALIZER = 'json'
|
CELERY_RESULT_SERIALIZER = "json"
|
||||||
CELERY_TIMEZONE = TIME_ZONE
|
CELERY_TIMEZONE = TIME_ZONE
|
||||||
CELERY_ENABLE_UTC = True
|
CELERY_ENABLE_UTC = True
|
||||||
|
|
||||||
# Django Celery Beat Scheduler
|
# Django Celery Beat Scheduler
|
||||||
CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler'
|
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
|
||||||
LINKEDIN_CLIENT_SECRET ='WPL_AP1.Ek4DeQDXuv4INg1K.mGo4CQ=='
|
LINKEDIN_CLIENT_SECRET = "WPL_AP1.Ek4DeQDXuv4INg1K.mGo4CQ=="
|
||||||
LINKEDIN_REDIRECT_URI = 'http://127.0.0.1:8000/social/callback/LI/'
|
LINKEDIN_REDIRECT_URI = "http://127.0.0.1:8000/social/callback/LI/"
|
||||||
LINKEDIN_WEBHOOK_VERIFY_TOKEN = "your_random_secret_string_123"
|
LINKEDIN_WEBHOOK_VERIFY_TOKEN = "your_random_secret_string_123"
|
||||||
|
|
||||||
|
|
||||||
# YOUTUBE API CREDENTIALS
|
# YOUTUBE API CREDENTIALS
|
||||||
# Ensure this matches your Google Cloud Console settings
|
# Ensure this matches your Google Cloud Console settings
|
||||||
YOUTUBE_CLIENT_SECRETS_FILE = BASE_DIR / 'secrets' / 'yt_client_secrets.json'
|
YOUTUBE_CLIENT_SECRETS_FILE = BASE_DIR / "secrets" / "yt_client_secrets.json"
|
||||||
YOUTUBE_REDIRECT_URI = 'http://127.0.0.1:8000/social/callback/YT/'
|
YOUTUBE_REDIRECT_URI = "http://127.0.0.1:8000/social/callback/YT/"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Google REVIEWS Configuration
|
# Google REVIEWS Configuration
|
||||||
# Ensure you have your client_secrets.json file at this location
|
# Ensure you have your client_secrets.json file at this location
|
||||||
GMB_CLIENT_SECRETS_FILE = BASE_DIR / 'secrets' / 'gmb_client_secrets.json'
|
GMB_CLIENT_SECRETS_FILE = BASE_DIR / "secrets" / "gmb_client_secrets.json"
|
||||||
GMB_REDIRECT_URI = 'http://127.0.0.1:8000/social/callback/GO/'
|
GMB_REDIRECT_URI = "http://127.0.0.1:8000/social/callback/GO/"
|
||||||
|
|
||||||
|
|
||||||
# Data upload settings
|
# Data upload settings
|
||||||
@ -162,11 +163,10 @@ GMB_REDIRECT_URI = 'http://127.0.0.1:8000/social/callback/GO/'
|
|||||||
DATA_UPLOAD_MAX_NUMBER_FIELDS = 20000
|
DATA_UPLOAD_MAX_NUMBER_FIELDS = 20000
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# X API Configuration
|
# X API Configuration
|
||||||
X_CLIENT_ID = 'your_client_id'
|
X_CLIENT_ID = "your_client_id"
|
||||||
X_CLIENT_SECRET = 'your_client_secret'
|
X_CLIENT_SECRET = "your_client_secret"
|
||||||
X_REDIRECT_URI = 'http://127.0.0.1:8000/social/callback/X/'
|
X_REDIRECT_URI = "http://127.0.0.1:8000/social/callback/X/"
|
||||||
# TIER CONFIGURATION
|
# TIER CONFIGURATION
|
||||||
# Set to True if you have Enterprise Access
|
# Set to True if you have Enterprise Access
|
||||||
# Set to False for Free/Basic/Pro
|
# Set to False for Free/Basic/Pro
|
||||||
@ -174,16 +174,15 @@ X_USE_ENTERPRISE = False
|
|||||||
|
|
||||||
|
|
||||||
# --- TIKTOK CONFIG ---
|
# --- TIKTOK CONFIG ---
|
||||||
TIKTOK_CLIENT_KEY = 'your_client_key'
|
TIKTOK_CLIENT_KEY = "your_client_key"
|
||||||
TIKTOK_CLIENT_SECRET = 'your_client_secret'
|
TIKTOK_CLIENT_SECRET = "your_client_secret"
|
||||||
TIKTOK_REDIRECT_URI = 'http://127.0.0.1:8000/social/callback/TT/'
|
TIKTOK_REDIRECT_URI = "http://127.0.0.1:8000/social/callback/TT/"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# --- META API CONFIG ---
|
# --- META API CONFIG ---
|
||||||
META_APP_ID = '1229882089053768'
|
META_APP_ID = "1229882089053768"
|
||||||
META_APP_SECRET = 'b80750bd12ab7f1c21d7d0ca891ba5ab'
|
META_APP_SECRET = "b80750bd12ab7f1c21d7d0ca891ba5ab"
|
||||||
META_REDIRECT_URI = 'https://micha-nonparabolic-lovie.ngrok-free.dev/social/callback/META/'
|
META_REDIRECT_URI = "https://micha-nonparabolic-lovie.ngrok-free.dev/social/callback/META/"
|
||||||
META_WEBHOOK_VERIFY_TOKEN = 'random_secret_string_khanfaheed123456'
|
META_WEBHOOK_VERIFY_TOKEN = "random_secret_string_khanfaheed123456"
|
||||||
|
|
||||||
EMAIL_LOGO_URL = 'http://127.0.0.1:8000/static/img/HH_R_H_Logo.png'
|
EMAIL_LOGO_URL = "http://127.0.0.1:8000/static/img/HH_R_H_Logo.png"
|
||||||
|
|||||||
@ -281,6 +281,9 @@ def onboarding_step_content(request, step):
|
|||||||
# Get content for user's role
|
# Get content for user's role
|
||||||
content_list = get_wizard_content_for_user(user)
|
content_list = get_wizard_content_for_user(user)
|
||||||
|
|
||||||
|
if not content_list:
|
||||||
|
return redirect("/accounts/onboarding/wizard/checklist/")
|
||||||
|
|
||||||
# Get current step content
|
# Get current step content
|
||||||
try:
|
try:
|
||||||
current_content = content_list[step - 1]
|
current_content = content_list[step - 1]
|
||||||
@ -288,6 +291,12 @@ def onboarding_step_content(request, step):
|
|||||||
# Step doesn't exist, go to checklist
|
# Step doesn't exist, go to checklist
|
||||||
return redirect("/accounts/onboarding/wizard/checklist/")
|
return redirect("/accounts/onboarding/wizard/checklist/")
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
OnboardingService.save_wizard_step(user, step)
|
||||||
|
if step < len(content_list):
|
||||||
|
return redirect("/accounts/onboarding/wizard/step/{}/".format(step + 1))
|
||||||
|
return redirect("/accounts/onboarding/wizard/checklist/")
|
||||||
|
|
||||||
# Get completed steps
|
# Get completed steps
|
||||||
completed_steps = user.wizard_completed_steps or []
|
completed_steps = user.wizard_completed_steps or []
|
||||||
|
|
||||||
@ -638,14 +647,16 @@ def bulk_invite_users(request):
|
|||||||
return redirect("accounts:bulk-invite-users")
|
return redirect("accounts:bulk-invite-users")
|
||||||
|
|
||||||
for row in reader:
|
for row in reader:
|
||||||
rows_to_process.append({
|
rows_to_process.append(
|
||||||
"email": row.get("email", "").strip(),
|
{
|
||||||
"first_name": row.get("first_name", "").strip(),
|
"email": row.get("email", "").strip(),
|
||||||
"last_name": row.get("last_name", "").strip(),
|
"first_name": row.get("first_name", "").strip(),
|
||||||
"role": row.get("role", "").strip(),
|
"last_name": row.get("last_name", "").strip(),
|
||||||
"hospital_id": row.get("hospital_id", "").strip(),
|
"role": row.get("role", "").strip(),
|
||||||
"department_id": row.get("department_id", "").strip()
|
"hospital_id": row.get("hospital_id", "").strip(),
|
||||||
})
|
"department_id": row.get("department_id", "").strip(),
|
||||||
|
}
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messages.error(request, f"Error processing CSV file: {str(e)}")
|
messages.error(request, f"Error processing CSV file: {str(e)}")
|
||||||
|
|
||||||
@ -670,14 +681,16 @@ def bulk_invite_users(request):
|
|||||||
f_name = parts[0] if parts else "Staff"
|
f_name = parts[0] if parts else "Staff"
|
||||||
l_name = " ".join(parts[1:]) if len(parts) > 1 else "Member"
|
l_name = " ".join(parts[1:]) if len(parts) > 1 else "Member"
|
||||||
|
|
||||||
rows_to_process.append({
|
rows_to_process.append(
|
||||||
"email": email,
|
{
|
||||||
"first_name": f_name,
|
"email": email,
|
||||||
"last_name": l_name,
|
"first_name": f_name,
|
||||||
"role": role_name,
|
"last_name": l_name,
|
||||||
"hospital_id": hospital_id,
|
"role": role_name,
|
||||||
"department_id": ""
|
"hospital_id": hospital_id,
|
||||||
})
|
"department_id": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messages.error(request, f"Error processing manual entries: {str(e)}")
|
messages.error(request, f"Error processing manual entries: {str(e)}")
|
||||||
|
|
||||||
@ -775,7 +788,6 @@ def bulk_invite_users(request):
|
|||||||
if results["errors"]:
|
if results["errors"]:
|
||||||
messages.warning(request, f"Failed to invite {len(results['errors'])} users. See details below.")
|
messages.warning(request, f"Failed to invite {len(results['errors'])} users. See details below.")
|
||||||
|
|
||||||
|
|
||||||
# Get data for template
|
# Get data for template
|
||||||
roles = Role.objects.all()
|
roles = Role.objects.all()
|
||||||
hospitals = Hospital.objects.filter(status="active").order_by("name")
|
hospitals = Hospital.objects.filter(status="active").order_by("name")
|
||||||
@ -1110,7 +1122,9 @@ def provisional_user_list(request):
|
|||||||
# Filter by hospital based on user role
|
# Filter by hospital based on user role
|
||||||
# Check PX Admin first to avoid logic issues when user has multiple roles
|
# Check PX Admin first to avoid logic issues when user has multiple roles
|
||||||
if request.user.is_px_admin() and request.tenant_hospital:
|
if request.user.is_px_admin() and request.tenant_hospital:
|
||||||
provisional_users = provisional_users.filter(hospital=request.tenant_hospital)
|
from django.db.models import Q
|
||||||
|
|
||||||
|
provisional_users = provisional_users.filter(Q(hospital=request.tenant_hospital) | Q(hospital__isnull=True))
|
||||||
elif request.user.is_hospital_admin() and request.user.hospital:
|
elif request.user.is_hospital_admin() and request.user.hospital:
|
||||||
provisional_users = provisional_users.filter(hospital=request.user.hospital)
|
provisional_users = provisional_users.filter(hospital=request.user.hospital)
|
||||||
|
|
||||||
|
|||||||
@ -110,7 +110,7 @@ def generate_action_recommendations_task(self, user_id=None, hospital_id=None, d
|
|||||||
def precompute_dashboard_cache_task(self):
|
def precompute_dashboard_cache_task(self):
|
||||||
"""
|
"""
|
||||||
Async task: Pre-compute all cacheable analytics data for all active hospitals.
|
Async task: Pre-compute all cacheable analytics data for all active hospitals.
|
||||||
Run every 5 minutes so the dashboard is always fast.
|
Run daily at 3 AM. Users can trigger on-demand refresh via dashboard button.
|
||||||
"""
|
"""
|
||||||
from apps.analytics.services.ai_analytics import (
|
from apps.analytics.services.ai_analytics import (
|
||||||
ExecutiveSummaryGenerator,
|
ExecutiveSummaryGenerator,
|
||||||
|
|||||||
@ -504,6 +504,35 @@ def refresh_ai_analytics(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@block_source_user
|
||||||
|
@login_required
|
||||||
|
def refresh_dashboard_cache(request):
|
||||||
|
"""
|
||||||
|
API endpoint: Trigger dashboard cache refresh on demand.
|
||||||
|
POST to trigger refresh, returns immediately with task status.
|
||||||
|
"""
|
||||||
|
if request.method != "POST":
|
||||||
|
return JsonResponse({"error": "POST method required"}, status=405)
|
||||||
|
|
||||||
|
from .tasks import precompute_dashboard_cache_task
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
# Trigger async cache refresh
|
||||||
|
task = precompute_dashboard_cache_task.delay()
|
||||||
|
|
||||||
|
# Clear user's dashboard cache so next load gets fresh data
|
||||||
|
cache.delete(f"analytics_dashboard_{user.id}_all")
|
||||||
|
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),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@block_source_user
|
@block_source_user
|
||||||
@login_required
|
@login_required
|
||||||
def kpi_list(request):
|
def kpi_list(request):
|
||||||
|
|||||||
@ -16,6 +16,7 @@ urlpatterns = [
|
|||||||
|
|
||||||
# AI Analytics API
|
# AI Analytics API
|
||||||
path('api/ai-analytics/refresh/', ui_views.refresh_ai_analytics, name='refresh_ai_analytics'),
|
path('api/ai-analytics/refresh/', ui_views.refresh_ai_analytics, name='refresh_ai_analytics'),
|
||||||
|
path('api/dashboard/refresh-cache/', ui_views.refresh_dashboard_cache, name='refresh_dashboard_cache'),
|
||||||
path('api/ask-data/query/', ask_views.ask_data_query, name='ask_data_query'),
|
path('api/ask-data/query/', ask_views.ask_data_query, name='ask_data_query'),
|
||||||
|
|
||||||
# KPI Reports
|
# KPI Reports
|
||||||
|
|||||||
@ -556,42 +556,12 @@ class ComplaintForm(HospitalFieldMixin, forms.ModelForm):
|
|||||||
self.fields["main_section"].queryset = MainSection.objects.none()
|
self.fields["main_section"].queryset = MainSection.objects.none()
|
||||||
self.fields["subsection"].queryset = SubSection.objects.none()
|
self.fields["subsection"].queryset = SubSection.objects.none()
|
||||||
|
|
||||||
# Load all locations (no filtering needed)
|
# Load locations: Inpatient, Outpatient Clinics, Emergency, Others
|
||||||
self.fields["location"].queryset = Location.objects.all().order_by("name_en")
|
self.fields["location"].queryset = Location.objects.filter(id__in=[48, 49, 82, 110]).order_by("name_en")
|
||||||
|
|
||||||
# Load active PX sources for optional selection
|
# Load active PX sources for optional selection
|
||||||
self.fields["source"].queryset = PXSource.objects.filter(is_active=True).order_by("name_en")
|
self.fields["source"].queryset = PXSource.objects.filter(is_active=True).order_by("name_en")
|
||||||
|
|
||||||
# Check both initial data and POST data for location to load sections
|
|
||||||
location_id = None
|
|
||||||
if "location" in self.initial:
|
|
||||||
location_id = self.initial["location"]
|
|
||||||
elif "location" in self.data:
|
|
||||||
location_id = self.data["location"]
|
|
||||||
|
|
||||||
if location_id:
|
|
||||||
# Filter sections based on selected location
|
|
||||||
from apps.organizations.models import SubSection
|
|
||||||
|
|
||||||
available_sections = (
|
|
||||||
SubSection.objects.filter(location_id=location_id).values_list("main_section_id", flat=True).distinct()
|
|
||||||
)
|
|
||||||
self.fields["main_section"].queryset = MainSection.objects.filter(id__in=available_sections).order_by(
|
|
||||||
"name_en"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Load subsections if section is selected
|
|
||||||
section_id = None
|
|
||||||
if "main_section" in self.initial:
|
|
||||||
section_id = self.initial["main_section"]
|
|
||||||
elif "main_section" in self.data:
|
|
||||||
section_id = self.data["main_section"]
|
|
||||||
|
|
||||||
if section_id:
|
|
||||||
self.fields["subsection"].queryset = SubSection.objects.filter(
|
|
||||||
location_id=location_id, main_section_id=section_id
|
|
||||||
).order_by("name_en")
|
|
||||||
|
|
||||||
# Hospital field is configured by HospitalFieldMixin
|
# Hospital field is configured by HospitalFieldMixin
|
||||||
# Now filter departments and staff based on hospital
|
# Now filter departments and staff based on hospital
|
||||||
hospital_id = None
|
hospital_id = None
|
||||||
|
|||||||
@ -16,6 +16,7 @@ from django.db import models
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from apps.core.encryption import EncryptedCharField, compute_national_id_hash, mask_national_id
|
||||||
from apps.core.models import PriorityChoices, SeverityChoices, TenantModel, TimeStampedModel, UUIDModel
|
from apps.core.models import PriorityChoices, SeverityChoices, TenantModel, TimeStampedModel, UUIDModel
|
||||||
|
|
||||||
|
|
||||||
@ -213,9 +214,10 @@ class Complaint(UUIDModel, TimeStampedModel):
|
|||||||
max_length=200, blank=True, verbose_name="Patient Name", help_text="Name of the patient involved"
|
max_length=200, blank=True, verbose_name="Patient Name", help_text="Name of the patient involved"
|
||||||
)
|
)
|
||||||
|
|
||||||
national_id = models.CharField(
|
national_id = EncryptedCharField(
|
||||||
max_length=20, blank=True, verbose_name="National ID/Iqama No.", help_text="Saudi National ID or Iqama number"
|
max_length=20, blank=True, verbose_name="National ID/Iqama No.", help_text="Saudi National ID or Iqama number"
|
||||||
)
|
)
|
||||||
|
national_id_hash = models.CharField(max_length=64, blank=True, db_index=True)
|
||||||
|
|
||||||
incident_date = models.DateField(
|
incident_date = models.DateField(
|
||||||
null=True, blank=True, verbose_name="Incident Date", help_text="Date when the incident occurred"
|
null=True, blank=True, verbose_name="Incident Date", help_text="Date when the incident occurred"
|
||||||
@ -512,6 +514,9 @@ class Complaint(UUIDModel, TimeStampedModel):
|
|||||||
|
|
||||||
return reverse("complaints:complaint_detail", kwargs={"pk": self.pk})
|
return reverse("complaints:complaint_detail", kwargs={"pk": self.pk})
|
||||||
|
|
||||||
|
def get_masked_national_id(self):
|
||||||
|
return mask_national_id(self.national_id)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""Calculate SLA due date on creation, generate reference number, and sync complaint_type from metadata"""
|
"""Calculate SLA due date on creation, generate reference number, and sync complaint_type from metadata"""
|
||||||
# Track status change for signals
|
# Track status change for signals
|
||||||
@ -543,6 +548,11 @@ class Complaint(UUIDModel, TimeStampedModel):
|
|||||||
if self.complaint_type == "complaint" and ai_complaint_type != "complaint":
|
if self.complaint_type == "complaint" and ai_complaint_type != "complaint":
|
||||||
self.complaint_type = ai_complaint_type
|
self.complaint_type = ai_complaint_type
|
||||||
|
|
||||||
|
if self.national_id:
|
||||||
|
self.national_id_hash = compute_national_id_hash(self.national_id)
|
||||||
|
else:
|
||||||
|
self.national_id_hash = ""
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def calculate_sla_due_date(self):
|
def calculate_sla_due_date(self):
|
||||||
@ -658,6 +668,45 @@ class Complaint(UUIDModel, TimeStampedModel):
|
|||||||
ComplaintStatus.CONTACTED_NO_RESPONSE,
|
ComplaintStatus.CONTACTED_NO_RESPONSE,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
PUBLIC_STATUS_MAP = {
|
||||||
|
ComplaintStatus.OPEN: {"label": _("Received"), "slug": "received", "progress": 15, "css": "amber"},
|
||||||
|
ComplaintStatus.IN_PROGRESS: {"label": _("In Progress"), "slug": "in_progress", "progress": 50, "css": "blue"},
|
||||||
|
ComplaintStatus.PARTIALLY_RESOLVED: {
|
||||||
|
"label": _("In Progress"),
|
||||||
|
"slug": "in_progress",
|
||||||
|
"progress": 75,
|
||||||
|
"css": "blue",
|
||||||
|
},
|
||||||
|
ComplaintStatus.RESOLVED: {"label": _("Resolved"), "slug": "resolved", "progress": 100, "css": "emerald"},
|
||||||
|
ComplaintStatus.CLOSED: {"label": _("Closed"), "slug": "closed", "progress": 100, "css": "slate"},
|
||||||
|
ComplaintStatus.CANCELLED: {"label": _("Cancelled"), "slug": "cancelled", "progress": 0, "css": "rose"},
|
||||||
|
ComplaintStatus.CONTACTED: {"label": _("In Progress"), "slug": "in_progress", "progress": 50, "css": "blue"},
|
||||||
|
ComplaintStatus.CONTACTED_NO_RESPONSE: {
|
||||||
|
"label": _("In Progress"),
|
||||||
|
"slug": "in_progress",
|
||||||
|
"progress": 50,
|
||||||
|
"css": "blue",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def public_status(self):
|
||||||
|
mapping = self.PUBLIC_STATUS_MAP.get(
|
||||||
|
self.status,
|
||||||
|
{
|
||||||
|
"label": _("Received"),
|
||||||
|
"slug": "received",
|
||||||
|
"progress": 15,
|
||||||
|
"css": "amber",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"label": str(mapping["label"]),
|
||||||
|
"slug": mapping["slug"],
|
||||||
|
"progress": mapping["progress"],
|
||||||
|
"css": mapping["css"],
|
||||||
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def short_description_en(self):
|
def short_description_en(self):
|
||||||
"""Get AI-generated short description (English) from metadata"""
|
"""Get AI-generated short description (English) from metadata"""
|
||||||
@ -2225,8 +2274,7 @@ class OnCallAdmin(UUIDModel, TimeStampedModel):
|
|||||||
"accounts.User",
|
"accounts.User",
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name="on_call_schedules",
|
related_name="on_call_schedules",
|
||||||
help_text="PX Admin user who is on-call",
|
help_text="User who is on-call (PX Admin, PX Coordinator, or Hospital Admin)",
|
||||||
limit_choices_to={"groups__name": "PX Admin"},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Optional: date range for this on-call assignment
|
# Optional: date range for this on-call assignment
|
||||||
|
|||||||
@ -1977,6 +1977,8 @@ def public_complaint_submit(request):
|
|||||||
severity="medium", # Default, AI will update
|
severity="medium", # Default, AI will update
|
||||||
priority="medium", # Default, AI will update
|
priority="medium", # Default, AI will update
|
||||||
status="open", # Start as open
|
status="open", # Start as open
|
||||||
|
complaint_source_type=ComplaintSourceType.INTERNAL,
|
||||||
|
source=PXSource.objects.filter(name_en="Public Form").first(),
|
||||||
reference_number=reference_number,
|
reference_number=reference_number,
|
||||||
# Location hierarchy (FK relationships)
|
# Location hierarchy (FK relationships)
|
||||||
location=location,
|
location=location,
|
||||||
@ -2112,15 +2114,39 @@ def public_complaint_track(request):
|
|||||||
except Complaint.DoesNotExist:
|
except Complaint.DoesNotExist:
|
||||||
error_message = _("No complaint found with this reference number. Please check and try again.")
|
error_message = _("No complaint found with this reference number. Please check and try again.")
|
||||||
|
|
||||||
# Get public updates only (exclude internal notes)
|
public_status = None
|
||||||
public_updates = []
|
public_updates = []
|
||||||
if complaint:
|
if complaint:
|
||||||
public_updates = complaint.updates.filter(
|
public_status = complaint.public_status
|
||||||
update_type__in=["status_change", "resolution", "communication"]
|
|
||||||
).order_by("-created_at")
|
public_updates = list(
|
||||||
|
complaint.updates.filter(update_type__in=["status_change", "resolution", "communication"]).order_by(
|
||||||
|
"-created_at"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
_status_map = {
|
||||||
|
"open": str(_("Received")),
|
||||||
|
"in_progress": str(_("In Progress")),
|
||||||
|
"partially_resolved": str(_("In Progress")),
|
||||||
|
"contacted": str(_("In Progress")),
|
||||||
|
"contacted_no_response": str(_("In Progress")),
|
||||||
|
"resolved": str(_("Resolved")),
|
||||||
|
"closed": str(_("Closed")),
|
||||||
|
"cancelled": str(_("Cancelled")),
|
||||||
|
}
|
||||||
|
for update in public_updates:
|
||||||
|
if update.comments:
|
||||||
|
for internal, public_label in _status_map.items():
|
||||||
|
update.comments = update.comments.replace(internal, public_label)
|
||||||
|
if hasattr(update, "old_status") and update.old_status:
|
||||||
|
update.old_status = _status_map.get(update.old_status, update.old_status)
|
||||||
|
if hasattr(update, "new_status") and update.new_status:
|
||||||
|
update.new_status = _status_map.get(update.new_status, update.new_status)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"complaint": complaint,
|
"complaint": complaint,
|
||||||
|
"public_status": public_status,
|
||||||
"public_updates": public_updates,
|
"public_updates": public_updates,
|
||||||
"error_message": error_message,
|
"error_message": error_message,
|
||||||
"reference_number": reference_number,
|
"reference_number": reference_number,
|
||||||
@ -2288,7 +2314,10 @@ def api_lookup_patient(request):
|
|||||||
lookup_method = None
|
lookup_method = None
|
||||||
|
|
||||||
if national_id:
|
if national_id:
|
||||||
patient = Patient.objects.filter(national_id=national_id, status="active").first()
|
from apps.core.encryption import compute_national_id_hash
|
||||||
|
|
||||||
|
nid_hash = compute_national_id_hash(national_id)
|
||||||
|
patient = Patient.objects.filter(national_id_hash=nid_hash, status="active").first()
|
||||||
lookup_method = "national_id"
|
lookup_method = "national_id"
|
||||||
|
|
||||||
if not patient and phone:
|
if not patient and phone:
|
||||||
|
|||||||
@ -28,8 +28,8 @@ logger = logging.getLogger(__name__)
|
|||||||
def check_px_admin(request):
|
def check_px_admin(request):
|
||||||
"""Check if user is PX Admin, return redirect if not."""
|
"""Check if user is PX Admin, return redirect if not."""
|
||||||
if not request.user.is_px_admin():
|
if not request.user.is_px_admin():
|
||||||
messages.error(request, _('You do not have permission to access this page.'))
|
messages.error(request, _("You do not have permission to access this page."))
|
||||||
return redirect('dashboard')
|
return redirect("dashboard")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@ -42,14 +42,14 @@ def oncall_schedule_list(request):
|
|||||||
if redirect_response:
|
if redirect_response:
|
||||||
return redirect_response
|
return redirect_response
|
||||||
|
|
||||||
schedules = OnCallAdminSchedule.objects.select_related('hospital').all()
|
schedules = OnCallAdminSchedule.objects.select_related("hospital").all()
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'schedules': schedules,
|
"schedules": schedules,
|
||||||
'title': _('On-Call Admin Schedules'),
|
"title": _("On-Call Admin Schedules"),
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'complaints/oncall/schedule_list.html', context)
|
return render(request, "complaints/oncall/schedule_list.html", context)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@ -61,27 +61,27 @@ def oncall_schedule_create(request):
|
|||||||
if redirect_response:
|
if redirect_response:
|
||||||
return redirect_response
|
return redirect_response
|
||||||
|
|
||||||
hospitals = Hospital.objects.filter(status='active')
|
hospitals = Hospital.objects.filter(status="active")
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == "POST":
|
||||||
try:
|
try:
|
||||||
# Parse working days from checkboxes
|
# Parse working days from checkboxes
|
||||||
working_days = []
|
working_days = []
|
||||||
for day in range(7):
|
for day in range(7):
|
||||||
if request.POST.get(f'working_day_{day}'):
|
if request.POST.get(f"working_day_{day}"):
|
||||||
working_days.append(day)
|
working_days.append(day)
|
||||||
|
|
||||||
if not working_days:
|
if not working_days:
|
||||||
working_days = [0, 1, 2, 3, 4] # Default to Mon-Fri
|
working_days = [0, 1, 2, 3, 4] # Default to Mon-Fri
|
||||||
|
|
||||||
# Get form data
|
# Get form data
|
||||||
hospital_id = request.POST.get('hospital')
|
hospital_id = request.POST.get("hospital")
|
||||||
hospital = Hospital.objects.get(id=hospital_id) if hospital_id else None
|
hospital = Hospital.objects.get(id=hospital_id) if hospital_id else None
|
||||||
|
|
||||||
work_start_time = request.POST.get('work_start_time', '08:00')
|
work_start_time = request.POST.get("work_start_time", "08:00")
|
||||||
work_end_time = request.POST.get('work_end_time', '17:00')
|
work_end_time = request.POST.get("work_end_time", "17:00")
|
||||||
timezone_str = request.POST.get('timezone', 'Asia/Riyadh')
|
timezone_str = request.POST.get("timezone", "Asia/Riyadh")
|
||||||
is_active = request.POST.get('is_active') == 'on'
|
is_active = request.POST.get("is_active") == "on"
|
||||||
|
|
||||||
# Create schedule
|
# Create schedule
|
||||||
schedule = OnCallAdminSchedule.objects.create(
|
schedule = OnCallAdminSchedule.objects.create(
|
||||||
@ -90,40 +90,48 @@ def oncall_schedule_create(request):
|
|||||||
work_start_time=work_start_time,
|
work_start_time=work_start_time,
|
||||||
work_end_time=work_end_time,
|
work_end_time=work_end_time,
|
||||||
timezone=timezone_str,
|
timezone=timezone_str,
|
||||||
is_active=is_active
|
is_active=is_active,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log audit
|
# Log audit
|
||||||
AuditService.log_event(
|
AuditService.log_event(
|
||||||
event_type='oncall_schedule_created',
|
event_type="oncall_schedule_created",
|
||||||
description=f"On-call schedule created: {schedule}",
|
description=f"On-call schedule created: {schedule}",
|
||||||
user=request.user,
|
user=request.user,
|
||||||
content_object=schedule,
|
content_object=schedule,
|
||||||
metadata={
|
metadata={
|
||||||
'hospital': str(hospital) if hospital else 'system-wide',
|
"hospital": str(hospital) if hospital else "system-wide",
|
||||||
'working_days': working_days,
|
"working_days": working_days,
|
||||||
'work_hours': f"{work_start_time}-{work_end_time}"
|
"work_hours": f"{work_start_time}-{work_end_time}",
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
messages.success(request, _('On-call schedule created successfully.'))
|
messages.success(request, _("On-call schedule created successfully."))
|
||||||
return redirect('complaints:oncall_schedule_detail', pk=schedule.id)
|
return redirect("complaints:oncall_schedule_detail", pk=schedule.id)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error creating on-call schedule: {str(e)}")
|
logger.error(f"Error creating on-call schedule: {str(e)}")
|
||||||
messages.error(request, _('Error creating on-call schedule. Please try again.'))
|
messages.error(request, _("Error creating on-call schedule. Please try again."))
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'hospitals': hospitals,
|
"hospitals": hospitals,
|
||||||
'timezones': [
|
"timezones": [
|
||||||
'Asia/Riyadh', 'Asia/Dubai', 'Asia/Kuwait', 'Asia/Qatar',
|
"Asia/Riyadh",
|
||||||
'Asia/Bahrain', 'Asia/Muscat', 'Asia/Amman', 'Asia/Beirut',
|
"Asia/Dubai",
|
||||||
'Asia/Cairo', 'Asia/Jerusalem', 'Asia/Baghdad'
|
"Asia/Kuwait",
|
||||||
|
"Asia/Qatar",
|
||||||
|
"Asia/Bahrain",
|
||||||
|
"Asia/Muscat",
|
||||||
|
"Asia/Amman",
|
||||||
|
"Asia/Beirut",
|
||||||
|
"Asia/Cairo",
|
||||||
|
"Asia/Jerusalem",
|
||||||
|
"Asia/Baghdad",
|
||||||
],
|
],
|
||||||
'title': _('Create On-Call Schedule'),
|
"title": _("Create On-Call Schedule"),
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'complaints/oncall/schedule_form.html', context)
|
return render(request, "complaints/oncall/schedule_form.html", context)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@ -135,24 +143,21 @@ def oncall_schedule_detail(request, pk):
|
|||||||
if redirect_response:
|
if redirect_response:
|
||||||
return redirect_response
|
return redirect_response
|
||||||
|
|
||||||
schedule = get_object_or_404(
|
schedule = get_object_or_404(OnCallAdminSchedule.objects.select_related("hospital"), pk=pk)
|
||||||
OnCallAdminSchedule.objects.select_related('hospital'),
|
|
||||||
pk=pk
|
|
||||||
)
|
|
||||||
|
|
||||||
on_call_admins = schedule.on_call_admins.select_related('admin_user').all()
|
on_call_admins = schedule.on_call_admins.select_related("admin_user").all()
|
||||||
|
|
||||||
# Check if currently working hours
|
# Check if currently working hours
|
||||||
is_working_hours = schedule.is_working_time()
|
is_working_hours = schedule.is_working_time()
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'schedule': schedule,
|
"schedule": schedule,
|
||||||
'on_call_admins': on_call_admins,
|
"on_call_admins": on_call_admins,
|
||||||
'is_working_hours': is_working_hours,
|
"is_working_hours": is_working_hours,
|
||||||
'title': _('On-Call Schedule Details'),
|
"title": _("On-Call Schedule Details"),
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'complaints/oncall/schedule_detail.html', context)
|
return render(request, "complaints/oncall/schedule_detail.html", context)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@ -165,63 +170,71 @@ def oncall_schedule_edit(request, pk):
|
|||||||
return redirect_response
|
return redirect_response
|
||||||
|
|
||||||
schedule = get_object_or_404(OnCallAdminSchedule, pk=pk)
|
schedule = get_object_or_404(OnCallAdminSchedule, pk=pk)
|
||||||
hospitals = Hospital.objects.filter(status='active')
|
hospitals = Hospital.objects.filter(status="active")
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == "POST":
|
||||||
try:
|
try:
|
||||||
# Parse working days from checkboxes
|
# Parse working days from checkboxes
|
||||||
working_days = []
|
working_days = []
|
||||||
for day in range(7):
|
for day in range(7):
|
||||||
if request.POST.get(f'working_day_{day}'):
|
if request.POST.get(f"working_day_{day}"):
|
||||||
working_days.append(day)
|
working_days.append(day)
|
||||||
|
|
||||||
if not working_days:
|
if not working_days:
|
||||||
working_days = [0, 1, 2, 3, 4] # Default to Mon-Fri
|
working_days = [0, 1, 2, 3, 4] # Default to Mon-Fri
|
||||||
|
|
||||||
# Get form data
|
# Get form data
|
||||||
hospital_id = request.POST.get('hospital')
|
hospital_id = request.POST.get("hospital")
|
||||||
schedule.hospital = Hospital.objects.get(id=hospital_id) if hospital_id else None
|
schedule.hospital = Hospital.objects.get(id=hospital_id) if hospital_id else None
|
||||||
|
|
||||||
schedule.working_days = working_days
|
schedule.working_days = working_days
|
||||||
schedule.work_start_time = request.POST.get('work_start_time', '08:00')
|
schedule.work_start_time = request.POST.get("work_start_time", "08:00")
|
||||||
schedule.work_end_time = request.POST.get('work_end_time', '17:00')
|
schedule.work_end_time = request.POST.get("work_end_time", "17:00")
|
||||||
schedule.timezone = request.POST.get('timezone', 'Asia/Riyadh')
|
schedule.timezone = request.POST.get("timezone", "Asia/Riyadh")
|
||||||
schedule.is_active = request.POST.get('is_active') == 'on'
|
schedule.is_active = request.POST.get("is_active") == "on"
|
||||||
|
|
||||||
schedule.save()
|
schedule.save()
|
||||||
|
|
||||||
# Log audit
|
# Log audit
|
||||||
AuditService.log_event(
|
AuditService.log_event(
|
||||||
event_type='oncall_schedule_updated',
|
event_type="oncall_schedule_updated",
|
||||||
description=f"On-call schedule updated: {schedule}",
|
description=f"On-call schedule updated: {schedule}",
|
||||||
user=request.user,
|
user=request.user,
|
||||||
content_object=schedule,
|
content_object=schedule,
|
||||||
metadata={
|
metadata={
|
||||||
'hospital': str(schedule.hospital) if schedule.hospital else 'system-wide',
|
"hospital": str(schedule.hospital) if schedule.hospital else "system-wide",
|
||||||
'working_days': working_days,
|
"working_days": working_days,
|
||||||
'is_active': schedule.is_active
|
"is_active": schedule.is_active,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
messages.success(request, _('On-call schedule updated successfully.'))
|
messages.success(request, _("On-call schedule updated successfully."))
|
||||||
return redirect('complaints:oncall_schedule_detail', pk=schedule.id)
|
return redirect("complaints:oncall_schedule_detail", pk=schedule.id)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating on-call schedule: {str(e)}")
|
logger.error(f"Error updating on-call schedule: {str(e)}")
|
||||||
messages.error(request, _('Error updating on-call schedule. Please try again.'))
|
messages.error(request, _("Error updating on-call schedule. Please try again."))
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'schedule': schedule,
|
"schedule": schedule,
|
||||||
'hospitals': hospitals,
|
"hospitals": hospitals,
|
||||||
'timezones': [
|
"timezones": [
|
||||||
'Asia/Riyadh', 'Asia/Dubai', 'Asia/Kuwait', 'Asia/Qatar',
|
"Asia/Riyadh",
|
||||||
'Asia/Bahrain', 'Asia/Muscat', 'Asia/Amman', 'Asia/Beirut',
|
"Asia/Dubai",
|
||||||
'Asia/Cairo', 'Asia/Jerusalem', 'Asia/Baghdad'
|
"Asia/Kuwait",
|
||||||
|
"Asia/Qatar",
|
||||||
|
"Asia/Bahrain",
|
||||||
|
"Asia/Muscat",
|
||||||
|
"Asia/Amman",
|
||||||
|
"Asia/Beirut",
|
||||||
|
"Asia/Cairo",
|
||||||
|
"Asia/Jerusalem",
|
||||||
|
"Asia/Baghdad",
|
||||||
],
|
],
|
||||||
'title': _('Edit On-Call Schedule'),
|
"title": _("Edit On-Call Schedule"),
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'complaints/oncall/schedule_form.html', context)
|
return render(request, "complaints/oncall/schedule_form.html", context)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@ -239,23 +252,23 @@ def oncall_schedule_delete(request, pk):
|
|||||||
try:
|
try:
|
||||||
# Log before deletion
|
# Log before deletion
|
||||||
AuditService.log_event(
|
AuditService.log_event(
|
||||||
event_type='oncall_schedule_deleted',
|
event_type="oncall_schedule_deleted",
|
||||||
description=f"On-call schedule deleted: {schedule}",
|
description=f"On-call schedule deleted: {schedule}",
|
||||||
user=request.user,
|
user=request.user,
|
||||||
metadata={
|
metadata={
|
||||||
'hospital': str(schedule.hospital) if schedule.hospital else 'system-wide',
|
"hospital": str(schedule.hospital) if schedule.hospital else "system-wide",
|
||||||
'schedule_id': str(pk)
|
"schedule_id": str(pk),
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
schedule.delete()
|
schedule.delete()
|
||||||
messages.success(request, _('On-call schedule deleted successfully.'))
|
messages.success(request, _("On-call schedule deleted successfully."))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error deleting on-call schedule: {str(e)}")
|
logger.error(f"Error deleting on-call schedule: {str(e)}")
|
||||||
messages.error(request, _('Error deleting on-call schedule.'))
|
messages.error(request, _("Error deleting on-call schedule."))
|
||||||
|
|
||||||
return redirect('complaints:oncall_schedule_list')
|
return redirect("complaints:oncall_schedule_list")
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@ -270,24 +283,28 @@ def oncall_admin_add(request, schedule_pk):
|
|||||||
schedule = get_object_or_404(OnCallAdminSchedule, pk=schedule_pk)
|
schedule = get_object_or_404(OnCallAdminSchedule, pk=schedule_pk)
|
||||||
|
|
||||||
# Get all PX Admins not already on this schedule
|
# Get all PX Admins not already on this schedule
|
||||||
existing_admin_ids = schedule.on_call_admins.values_list('admin_user_id', flat=True)
|
existing_admin_ids = schedule.on_call_admins.values_list("admin_user_id", flat=True)
|
||||||
available_admins = User.objects.filter(
|
available_admins = (
|
||||||
groups__name='PX Admin',
|
User.objects.filter(
|
||||||
is_active=True
|
Q(groups__name="PX Admin") | Q(groups__name="PX Coordinator") | Q(groups__name="Hospital Admin"),
|
||||||
).exclude(id__in=existing_admin_ids)
|
is_active=True,
|
||||||
|
)
|
||||||
|
.exclude(id__in=existing_admin_ids)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == "POST":
|
||||||
try:
|
try:
|
||||||
admin_user_id = request.POST.get('admin_user')
|
admin_user_id = request.POST.get("admin_user")
|
||||||
if not admin_user_id:
|
if not admin_user_id:
|
||||||
messages.error(request, _('Please select an admin user.'))
|
messages.error(request, _("Please select an admin user."))
|
||||||
return redirect('complaints:oncall_admin_add', schedule_pk=schedule_pk)
|
return redirect("complaints:oncall_admin_add", schedule_pk=schedule_pk)
|
||||||
|
|
||||||
admin_user = User.objects.get(id=admin_user_id)
|
admin_user = User.objects.get(id=admin_user_id)
|
||||||
|
|
||||||
# Parse dates
|
# Parse dates
|
||||||
start_date = request.POST.get('start_date') or None
|
start_date = request.POST.get("start_date") or None
|
||||||
end_date = request.POST.get('end_date') or None
|
end_date = request.POST.get("end_date") or None
|
||||||
|
|
||||||
# Create on-call admin assignment
|
# Create on-call admin assignment
|
||||||
on_call_admin = OnCallAdmin.objects.create(
|
on_call_admin = OnCallAdmin.objects.create(
|
||||||
@ -295,41 +312,44 @@ def oncall_admin_add(request, schedule_pk):
|
|||||||
admin_user=admin_user,
|
admin_user=admin_user,
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
notification_priority=int(request.POST.get('notification_priority', 1)),
|
notification_priority=int(request.POST.get("notification_priority", 1)),
|
||||||
is_active=request.POST.get('is_active') == 'on',
|
is_active=request.POST.get("is_active") == "on",
|
||||||
notify_email=request.POST.get('notify_email') == 'on',
|
notify_email=request.POST.get("notify_email") == "on",
|
||||||
notify_sms=request.POST.get('notify_sms') == 'on',
|
notify_sms=request.POST.get("notify_sms") == "on",
|
||||||
sms_phone=request.POST.get('sms_phone', '')
|
sms_phone=request.POST.get("sms_phone", ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log audit
|
# Log audit
|
||||||
AuditService.log_event(
|
AuditService.log_event(
|
||||||
event_type='oncall_admin_added',
|
event_type="oncall_admin_added",
|
||||||
description=f"Admin {admin_user.get_full_name()} added to on-call schedule",
|
description=f"Admin {admin_user.get_full_name()} added to on-call schedule",
|
||||||
user=request.user,
|
user=request.user,
|
||||||
content_object=on_call_admin,
|
content_object=on_call_admin,
|
||||||
metadata={
|
metadata={
|
||||||
'schedule': str(schedule),
|
"schedule": str(schedule),
|
||||||
'admin_user': str(admin_user),
|
"admin_user": str(admin_user),
|
||||||
'start_date': start_date,
|
"start_date": start_date,
|
||||||
'end_date': end_date
|
"end_date": end_date,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
messages.success(request, _('On-call admin added successfully.'))
|
messages.success(request, _("On-call admin added successfully."))
|
||||||
return redirect('complaints:oncall_schedule_detail', pk=schedule_pk)
|
return redirect("complaints:oncall_schedule_detail", pk=schedule_pk)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error adding on-call admin: {str(e)}")
|
logger.error(f"Error adding on-call admin: {str(e)}")
|
||||||
messages.error(request, _('Error adding on-call admin. Please try again.'))
|
messages.error(request, _("Error adding on-call admin. Please try again."))
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'schedule': schedule,
|
"schedule": schedule,
|
||||||
'available_admins': available_admins,
|
"available_admins": available_admins,
|
||||||
'title': _('Add On-Call Admin'),
|
"available_px_admins": available_admins.filter(groups__name="PX Admin"),
|
||||||
|
"available_coordinators": available_admins.filter(groups__name="PX Coordinator"),
|
||||||
|
"available_hospital_admins": available_admins.filter(groups__name="Hospital Admin"),
|
||||||
|
"title": _("Add On-Call Admin"),
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'complaints/oncall/admin_form.html', context)
|
return render(request, "complaints/oncall/admin_form.html", context)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@ -341,55 +361,52 @@ def oncall_admin_edit(request, pk):
|
|||||||
if redirect_response:
|
if redirect_response:
|
||||||
return redirect_response
|
return redirect_response
|
||||||
|
|
||||||
on_call_admin = get_object_or_404(
|
on_call_admin = get_object_or_404(OnCallAdmin.objects.select_related("schedule", "admin_user"), pk=pk)
|
||||||
OnCallAdmin.objects.select_related('schedule', 'admin_user'),
|
|
||||||
pk=pk
|
|
||||||
)
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == "POST":
|
||||||
try:
|
try:
|
||||||
# Parse dates
|
# Parse dates
|
||||||
start_date = request.POST.get('start_date') or None
|
start_date = request.POST.get("start_date") or None
|
||||||
end_date = request.POST.get('end_date') or None
|
end_date = request.POST.get("end_date") or None
|
||||||
|
|
||||||
# Update fields
|
# Update fields
|
||||||
on_call_admin.start_date = start_date
|
on_call_admin.start_date = start_date
|
||||||
on_call_admin.end_date = end_date
|
on_call_admin.end_date = end_date
|
||||||
on_call_admin.notification_priority = int(request.POST.get('notification_priority', 1))
|
on_call_admin.notification_priority = int(request.POST.get("notification_priority", 1))
|
||||||
on_call_admin.is_active = request.POST.get('is_active') == 'on'
|
on_call_admin.is_active = request.POST.get("is_active") == "on"
|
||||||
on_call_admin.notify_email = request.POST.get('notify_email') == 'on'
|
on_call_admin.notify_email = request.POST.get("notify_email") == "on"
|
||||||
on_call_admin.notify_sms = request.POST.get('notify_sms') == 'on'
|
on_call_admin.notify_sms = request.POST.get("notify_sms") == "on"
|
||||||
on_call_admin.sms_phone = request.POST.get('sms_phone', '')
|
on_call_admin.sms_phone = request.POST.get("sms_phone", "")
|
||||||
|
|
||||||
on_call_admin.save()
|
on_call_admin.save()
|
||||||
|
|
||||||
# Log audit
|
# Log audit
|
||||||
AuditService.log_event(
|
AuditService.log_event(
|
||||||
event_type='oncall_admin_updated',
|
event_type="oncall_admin_updated",
|
||||||
description=f"On-call admin updated: {on_call_admin}",
|
description=f"On-call admin updated: {on_call_admin}",
|
||||||
user=request.user,
|
user=request.user,
|
||||||
content_object=on_call_admin,
|
content_object=on_call_admin,
|
||||||
metadata={
|
metadata={
|
||||||
'schedule': str(on_call_admin.schedule),
|
"schedule": str(on_call_admin.schedule),
|
||||||
'admin_user': str(on_call_admin.admin_user),
|
"admin_user": str(on_call_admin.admin_user),
|
||||||
'is_active': on_call_admin.is_active
|
"is_active": on_call_admin.is_active,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
messages.success(request, _('On-call admin updated successfully.'))
|
messages.success(request, _("On-call admin updated successfully."))
|
||||||
return redirect('complaints:oncall_schedule_detail', pk=on_call_admin.schedule.id)
|
return redirect("complaints:oncall_schedule_detail", pk=on_call_admin.schedule.id)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating on-call admin: {str(e)}")
|
logger.error(f"Error updating on-call admin: {str(e)}")
|
||||||
messages.error(request, _('Error updating on-call admin. Please try again.'))
|
messages.error(request, _("Error updating on-call admin. Please try again."))
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'on_call_admin': on_call_admin,
|
"on_call_admin": on_call_admin,
|
||||||
'schedule': on_call_admin.schedule,
|
"schedule": on_call_admin.schedule,
|
||||||
'title': _('Edit On-Call Admin'),
|
"title": _("Edit On-Call Admin"),
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'complaints/oncall/admin_form.html', context)
|
return render(request, "complaints/oncall/admin_form.html", context)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@ -402,33 +419,30 @@ def oncall_admin_delete(request, pk):
|
|||||||
if redirect_response:
|
if redirect_response:
|
||||||
return redirect_response
|
return redirect_response
|
||||||
|
|
||||||
on_call_admin = get_object_or_404(
|
on_call_admin = get_object_or_404(OnCallAdmin.objects.select_related("schedule", "admin_user"), pk=pk)
|
||||||
OnCallAdmin.objects.select_related('schedule', 'admin_user'),
|
|
||||||
pk=pk
|
|
||||||
)
|
|
||||||
schedule_pk = on_call_admin.schedule.id
|
schedule_pk = on_call_admin.schedule.id
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Log before deletion
|
# Log before deletion
|
||||||
AuditService.log_event(
|
AuditService.log_event(
|
||||||
event_type='oncall_admin_removed',
|
event_type="oncall_admin_removed",
|
||||||
description=f"Admin removed from on-call schedule: {on_call_admin}",
|
description=f"Admin removed from on-call schedule: {on_call_admin}",
|
||||||
user=request.user,
|
user=request.user,
|
||||||
metadata={
|
metadata={
|
||||||
'schedule': str(on_call_admin.schedule),
|
"schedule": str(on_call_admin.schedule),
|
||||||
'admin_user': str(on_call_admin.admin_user),
|
"admin_user": str(on_call_admin.admin_user),
|
||||||
'oncall_admin_id': str(pk)
|
"oncall_admin_id": str(pk),
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
on_call_admin.delete()
|
on_call_admin.delete()
|
||||||
messages.success(request, _('On-call admin removed successfully.'))
|
messages.success(request, _("On-call admin removed successfully."))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error removing on-call admin: {str(e)}")
|
logger.error(f"Error removing on-call admin: {str(e)}")
|
||||||
messages.error(request, _('Error removing on-call admin.'))
|
messages.error(request, _("Error removing on-call admin."))
|
||||||
|
|
||||||
return redirect('complaints:oncall_schedule_detail', pk=schedule_pk)
|
return redirect("complaints:oncall_schedule_detail", pk=schedule_pk)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@ -441,18 +455,18 @@ def oncall_dashboard(request):
|
|||||||
return redirect_response
|
return redirect_response
|
||||||
|
|
||||||
# Get all schedules
|
# Get all schedules
|
||||||
schedules = OnCallAdminSchedule.objects.select_related('hospital').all()
|
schedules = OnCallAdminSchedule.objects.select_related("hospital").all()
|
||||||
|
|
||||||
# Get currently active on-call admins
|
# Get currently active on-call admins
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
today = now.date()
|
today = now.date()
|
||||||
|
|
||||||
active_on_call_admins = OnCallAdmin.objects.filter(
|
active_on_call_admins = (
|
||||||
is_active=True,
|
OnCallAdmin.objects.filter(is_active=True, schedule__is_active=True)
|
||||||
schedule__is_active=True
|
.select_related("admin_user", "schedule", "schedule__hospital")
|
||||||
).select_related('admin_user', 'schedule', 'schedule__hospital').filter(
|
.filter(
|
||||||
Q(start_date__isnull=True) | Q(start_date__lte=today),
|
Q(start_date__isnull=True) | Q(start_date__lte=today), Q(end_date__isnull=True) | Q(end_date__gte=today)
|
||||||
Q(end_date__isnull=True) | Q(end_date__gte=today)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check each schedule's current status
|
# Check each schedule's current status
|
||||||
@ -461,19 +475,21 @@ def oncall_dashboard(request):
|
|||||||
is_working = schedule.is_working_time()
|
is_working = schedule.is_working_time()
|
||||||
schedule_oncall = active_on_call_admins.filter(schedule=schedule)
|
schedule_oncall = active_on_call_admins.filter(schedule=schedule)
|
||||||
|
|
||||||
schedule_statuses.append({
|
schedule_statuses.append(
|
||||||
'schedule': schedule,
|
{
|
||||||
'is_working_hours': is_working,
|
"schedule": schedule,
|
||||||
'on_call_count': schedule_oncall.count(),
|
"is_working_hours": is_working,
|
||||||
'on_call_admins': schedule_oncall
|
"on_call_count": schedule_oncall.count(),
|
||||||
})
|
"on_call_admins": schedule_oncall,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'schedule_statuses': schedule_statuses,
|
"schedule_statuses": schedule_statuses,
|
||||||
'total_schedules': schedules.count(),
|
"total_schedules": schedules.count(),
|
||||||
'total_active_oncall': active_on_call_admins.count(),
|
"total_active_oncall": active_on_call_admins.count(),
|
||||||
'current_time': now,
|
"current_time": now,
|
||||||
'title': _('On-Call Dashboard'),
|
"title": _("On-Call Dashboard"),
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'complaints/oncall/dashboard.html', context)
|
return render(request, "complaints/oncall/dashboard.html", context)
|
||||||
|
|||||||
@ -1,14 +1,17 @@
|
|||||||
"""
|
"""
|
||||||
Configuration URLs
|
Configuration URLs
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from . import config_views
|
from . import config_views
|
||||||
|
|
||||||
app_name = 'config'
|
app_name = "config"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', config_views.config_dashboard, name='dashboard'),
|
path("", config_views.config_dashboard, name="dashboard"),
|
||||||
path('sla/', config_views.sla_config_list, name='sla_config_list'),
|
path("sla/", config_views.sla_config_list, name="sla_config_list"),
|
||||||
path('routing/', config_views.routing_rules_list, name='routing_rules_list'),
|
path("routing/", config_views.routing_rules_list, name="routing_rules_list"),
|
||||||
path('test/',config_views.test, name='test'),
|
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("test/", config_views.test, name="test"),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -2,15 +2,25 @@
|
|||||||
Configuration Console UI views - System configuration management
|
Configuration Console UI views - System configuration management
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.shortcuts import render
|
from django.db.models import Q
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from apps.organizations.models import Hospital
|
from apps.organizations.models import Department, Hospital
|
||||||
|
from apps.organizations.services import StaffService
|
||||||
from apps.px_action_center.models import PXActionSLAConfig, RoutingRule
|
from apps.px_action_center.models import PXActionSLAConfig, RoutingRule
|
||||||
from apps.complaints.models import OnCallAdminSchedule
|
from apps.complaints.models import OnCallAdminSchedule
|
||||||
from apps.callcenter.models import CallRecord
|
from apps.callcenter.models import CallRecord
|
||||||
from apps.core.decorators import px_admin_required
|
from apps.notifications.services import NotificationService
|
||||||
|
from apps.core.decorators import px_admin_required, admin_required
|
||||||
from apps.accounts.models import User
|
from apps.accounts.models import User
|
||||||
|
|
||||||
|
|
||||||
@ -25,6 +35,7 @@ def config_dashboard(request):
|
|||||||
oncall_schedules_count = OnCallAdminSchedule.objects.filter(is_active=True).count()
|
oncall_schedules_count = OnCallAdminSchedule.objects.filter(is_active=True).count()
|
||||||
call_records_count = CallRecord.objects.count()
|
call_records_count = CallRecord.objects.count()
|
||||||
provisional_users_count = User.objects.filter(is_provisional=True).count()
|
provisional_users_count = User.objects.filter(is_provisional=True).count()
|
||||||
|
active_users_count = User.objects.filter(is_active=True, is_superuser=False, is_provisional=False).count()
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"sla_configs_count": sla_configs_count,
|
"sla_configs_count": sla_configs_count,
|
||||||
@ -33,6 +44,7 @@ def config_dashboard(request):
|
|||||||
"oncall_schedules_count": oncall_schedules_count,
|
"oncall_schedules_count": oncall_schedules_count,
|
||||||
"call_records_count": call_records_count,
|
"call_records_count": call_records_count,
|
||||||
"provisional_users_count": provisional_users_count,
|
"provisional_users_count": provisional_users_count,
|
||||||
|
"active_users_count": active_users_count,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, "config/dashboard.html", context)
|
return render(request, "config/dashboard.html", context)
|
||||||
@ -116,6 +128,136 @@ def routing_rules_list(request):
|
|||||||
return render(request, "config/routing_rules.html", context)
|
return render(request, "config/routing_rules.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_required
|
||||||
|
def hospital_users_list(request):
|
||||||
|
"""Hospital users list view - PX Admin and Hospital Admin"""
|
||||||
|
|
||||||
|
queryset = User.objects.select_related("hospital", "department").filter(is_superuser=False, is_provisional=False)
|
||||||
|
|
||||||
|
if not request.user.is_px_admin():
|
||||||
|
if request.tenant_hospital:
|
||||||
|
queryset = queryset.filter(hospital=request.tenant_hospital)
|
||||||
|
else:
|
||||||
|
queryset = queryset.none()
|
||||||
|
|
||||||
|
hospital_filter = request.GET.get("hospital")
|
||||||
|
if hospital_filter:
|
||||||
|
queryset = queryset.filter(hospital_id=hospital_filter)
|
||||||
|
|
||||||
|
role_filter = request.GET.get("role")
|
||||||
|
if role_filter:
|
||||||
|
queryset = queryset.filter(groups__name=role_filter)
|
||||||
|
|
||||||
|
is_active = request.GET.get("is_active")
|
||||||
|
if is_active == "true":
|
||||||
|
queryset = queryset.filter(is_active=True)
|
||||||
|
elif is_active == "false":
|
||||||
|
queryset = queryset.filter(is_active=False)
|
||||||
|
|
||||||
|
search_query = request.GET.get("search")
|
||||||
|
if search_query:
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(first_name__icontains=search_query)
|
||||||
|
| Q(last_name__icontains=search_query)
|
||||||
|
| Q(email__icontains=search_query)
|
||||||
|
| Q(employee_id__icontains=search_query)
|
||||||
|
)
|
||||||
|
|
||||||
|
queryset = queryset.order_by("-date_joined")
|
||||||
|
|
||||||
|
page_size = int(request.GET.get("page_size", 25))
|
||||||
|
paginator = Paginator(queryset, page_size)
|
||||||
|
page_number = request.GET.get("page", 1)
|
||||||
|
page_obj = paginator.get_page(page_number)
|
||||||
|
|
||||||
|
if request.user.is_px_admin():
|
||||||
|
hospitals = Hospital.objects.filter(status="active").order_by("name")
|
||||||
|
elif request.tenant_hospital:
|
||||||
|
hospitals = Hospital.objects.filter(id=request.tenant_hospital.id)
|
||||||
|
else:
|
||||||
|
hospitals = Hospital.objects.none()
|
||||||
|
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
|
||||||
|
roles = Group.objects.all().order_by("name")
|
||||||
|
|
||||||
|
total_users = paginator.count
|
||||||
|
active_count = queryset.filter(is_active=True).count()
|
||||||
|
inactive_count = queryset.filter(is_active=False).count()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"page_obj": page_obj,
|
||||||
|
"users": page_obj.object_list,
|
||||||
|
"hospitals": hospitals,
|
||||||
|
"roles": roles,
|
||||||
|
"filters": request.GET,
|
||||||
|
"total_users": total_users,
|
||||||
|
"active_count": active_count,
|
||||||
|
"inactive_count": inactive_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, "config/hospital_users.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_required
|
||||||
|
def reset_user_password(request, user_id):
|
||||||
|
"""Reset a user's password and send them the new credentials via email."""
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if not request.user.is_px_admin():
|
||||||
|
if request.tenant_hospital and target_user.hospital != request.tenant_hospital:
|
||||||
|
return JsonResponse({"error": "You can only reset passwords for users in your hospital."}, status=403)
|
||||||
|
|
||||||
|
new_password = StaffService.generate_password()
|
||||||
|
target_user.set_password(new_password)
|
||||||
|
target_user.save(update_fields=["password"])
|
||||||
|
|
||||||
|
login_url = f"{request.scheme}://{request.get_host()}/accounts/login/"
|
||||||
|
|
||||||
|
html_message = render_to_string(
|
||||||
|
"config/emails/reset_password_email.html",
|
||||||
|
{
|
||||||
|
"user": target_user,
|
||||||
|
"password": new_password,
|
||||||
|
"login_url": login_url,
|
||||||
|
},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
|
plain_message = (
|
||||||
|
f"Dear {target_user.get_full_name()},\n\n"
|
||||||
|
f"Your password has been reset by an administrator.\n\n"
|
||||||
|
f"Your new credentials:\n"
|
||||||
|
f"Email: {target_user.email}\n"
|
||||||
|
f"Password: {new_password}\n\n"
|
||||||
|
f"Please login and change your password immediately.\n"
|
||||||
|
f"Login URL: {login_url}"
|
||||||
|
)
|
||||||
|
|
||||||
|
NotificationService.send_email(
|
||||||
|
email=target_user.email,
|
||||||
|
subject=_("Your PX360 Password Has Been Reset"),
|
||||||
|
message=plain_message,
|
||||||
|
html_message=html_message,
|
||||||
|
user=target_user,
|
||||||
|
notification_type="system",
|
||||||
|
)
|
||||||
|
|
||||||
|
return JsonResponse(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": f"Password has been reset for {target_user.get_full_name()}. A new password has been sent to {target_user.email}.",
|
||||||
|
"password": new_password,
|
||||||
|
"user_name": target_user.get_full_name(),
|
||||||
|
"user_email": target_user.email,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from rich import print
|
from rich import print
|
||||||
|
|
||||||
|
|||||||
71
apps/core/encryption.py
Normal file
71
apps/core/encryption.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
def _get_fernet() -> Fernet:
|
||||||
|
key = hashlib.sha256(settings.SECRET_KEY.encode()).digest()
|
||||||
|
return Fernet(base64.urlsafe_b64encode(key))
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_value(plaintext: str) -> str:
|
||||||
|
if not plaintext:
|
||||||
|
return ""
|
||||||
|
f = _get_fernet()
|
||||||
|
return f.encrypt(plaintext.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_value(ciphertext: str) -> str:
|
||||||
|
if not ciphertext:
|
||||||
|
return ""
|
||||||
|
f = _get_fernet()
|
||||||
|
return f.decrypt(ciphertext.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def compute_national_id_hash(value: str) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
salt = settings.SECRET_KEY.encode()
|
||||||
|
return hashlib.sha256(salt + value.strip().encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def mask_national_id(value: str) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
value = value.strip()
|
||||||
|
if len(value) <= 4:
|
||||||
|
return "*" * len(value)
|
||||||
|
return "*" * (len(value) - 4) + value[-4:]
|
||||||
|
|
||||||
|
|
||||||
|
class EncryptedCharField(models.CharField):
|
||||||
|
def get_prep_value(self, value):
|
||||||
|
if not value:
|
||||||
|
return value
|
||||||
|
return encrypt_value(str(value))
|
||||||
|
|
||||||
|
def from_db_value(self, value, expression, connection):
|
||||||
|
if not value:
|
||||||
|
return value
|
||||||
|
try:
|
||||||
|
return decrypt_value(value)
|
||||||
|
except Exception:
|
||||||
|
return value
|
||||||
|
|
||||||
|
def to_python(self, value):
|
||||||
|
if not value:
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
return decrypt_value(value)
|
||||||
|
except Exception:
|
||||||
|
return value
|
||||||
|
return value
|
||||||
|
|
||||||
|
def deconstruct(self):
|
||||||
|
name, path, args, kwargs = super().deconstruct()
|
||||||
|
path = "apps.core.encryption.EncryptedCharField"
|
||||||
|
return name, path, args, kwargs
|
||||||
@ -574,35 +574,19 @@ class Command(BaseCommand):
|
|||||||
def _send_complaint_notification_inline(self):
|
def _send_complaint_notification_inline(self):
|
||||||
"""Example complaint notification (currently inline - no template)"""
|
"""Example complaint notification (currently inline - no template)"""
|
||||||
html_content = f"""
|
html_content = f"""
|
||||||
<!DOCTYPE html>
|
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">Complaint Notification</h1>
|
||||||
<html>
|
<p style="margin: 0 0 15px; font-size: 16px; color: #1e293b; line-height: 1.6;">Dear Staff Member,</p>
|
||||||
<head><style>
|
<p style="margin: 0 0 20px; font-size: 16px; color: #475569; line-height: 1.6;">A new complaint has been logged and requires your attention:</p>
|
||||||
body {{ font-family: 'Segoe UI', Tahoma, sans-serif; background: #f8fafc; margin: 0; padding: 20px; }}
|
<div style="background-color: #f8fafc; border-radius: 6px; padding: 20px; margin-bottom: 20px;">
|
||||||
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }}
|
<p style="margin: 0; font-size: 14px; color: #64748b; line-height: 1.8;">
|
||||||
.header {{ background: linear-gradient(135deg, #005696 0%, #007bbd 100%); padding: 30px; text-align: center; color: white; }}
|
<strong style="color: #005696;">Reference:</strong> #COMP-2026-0050<br>
|
||||||
.content {{ padding: 30px; }}
|
<strong style="color: #005696;">Title:</strong> Cleanliness Issue in Ward 3B<br>
|
||||||
.info-box {{ background: #eef6fb; border-left: 4px solid #005696; padding: 15px; margin: 20px 0; }}
|
<strong style="color: #005696;">Patient:</strong> Mrs. Layla Ahmed<br>
|
||||||
</style></head>
|
<strong style="color: #005696;">Department:</strong> Inpatient Ward 3B<br>
|
||||||
<body>
|
<strong style="color: #005696;">Status:</strong> New
|
||||||
<div class="container">
|
</p>
|
||||||
<div class="header">
|
</div>
|
||||||
<h1>Complaint Notification</h1>
|
<p style="margin: 0; font-size: 14px; color: #475569; line-height: 1.6;">Please review and take appropriate action.</p>
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<p>Dear Staff Member,</p>
|
|
||||||
<p>A new complaint has been logged and requires your attention:</p>
|
|
||||||
<div class="info-box">
|
|
||||||
<strong>Reference:</strong> #COMP-2026-0050<br>
|
|
||||||
<strong>Title:</strong> Cleanliness Issue in Ward 3B<br>
|
|
||||||
<strong>Patient:</strong> Mrs. Layla Ahmed<br>
|
|
||||||
<strong>Department:</strong> Inpatient Ward 3B<br>
|
|
||||||
<strong>Status:</strong> New
|
|
||||||
</div>
|
|
||||||
<p>Please review and take appropriate action.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
"""
|
||||||
text_content = """
|
text_content = """
|
||||||
Complaint Notification
|
Complaint Notification
|
||||||
@ -624,34 +608,18 @@ class Command(BaseCommand):
|
|||||||
def _send_complaint_resolution_inline(self):
|
def _send_complaint_resolution_inline(self):
|
||||||
"""Example complaint resolution notification (currently inline - no template)"""
|
"""Example complaint resolution notification (currently inline - no template)"""
|
||||||
html_content = f"""
|
html_content = f"""
|
||||||
<!DOCTYPE html>
|
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">Complaint Resolved</h1>
|
||||||
<html>
|
<p style="margin: 0 0 15px; font-size: 16px; color: #1e293b; line-height: 1.6;">Dear Patient,</p>
|
||||||
<head><style>
|
<p style="margin: 0 0 20px; font-size: 16px; color: #475569; line-height: 1.6;">Your complaint has been resolved!</p>
|
||||||
body {{ font-family: 'Segoe UI', Tahoma, sans-serif; background: #f8fafc; margin: 0; padding: 20px; }}
|
<div style="background-color: #f8fafc; border-radius: 6px; padding: 20px; margin-bottom: 20px;">
|
||||||
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }}
|
<p style="margin: 0; font-size: 14px; color: #64748b; line-height: 1.8;">
|
||||||
.header {{ background: linear-gradient(135deg, #005696 0%, #007bbd 100%); padding: 30px; text-align: center; color: white; }}
|
<strong style="color: #005696;">Reference:</strong> #COMP-2026-0045<br>
|
||||||
.content {{ padding: 30px; }}
|
<strong style="color: #005696;">Title:</strong> Appointment Scheduling Issue<br>
|
||||||
.success-box {{ background: #eef6fb; border-left: 4px solid #005696; padding: 15px; margin: 20px 0; }}
|
<strong style="color: #005696;">Resolution:</strong> We have improved our scheduling system and scheduled your appointment for next week.
|
||||||
</style></head>
|
</p>
|
||||||
<body>
|
</div>
|
||||||
<div class="container">
|
<p style="margin: 0; font-size: 14px; color: #475569; line-height: 1.6;">We hope you are satisfied with the resolution. If you have any concerns, please contact us.</p>
|
||||||
<div class="header">
|
<p style="margin: 10px 0 0; font-size: 14px; color: #475569; line-height: 1.6;">Thank you for your feedback.</p>
|
||||||
<h1>Complaint Resolved</h1>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<p>Dear Patient,</p>
|
|
||||||
<div class="success-box">
|
|
||||||
<strong>Your complaint has been resolved!</strong><br><br>
|
|
||||||
<strong>Reference:</strong> #COMP-2026-0045<br>
|
|
||||||
<strong>Title:</strong> Appointment Scheduling Issue<br>
|
|
||||||
<strong>Resolution:</strong> We have improved our scheduling system and scheduled your appointment for next week.
|
|
||||||
</div>
|
|
||||||
<p>We hope you are satisfied with the resolution. If you have any concerns, please contact us.</p>
|
|
||||||
<p>Thank you for your feedback.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
"""
|
||||||
text_content = """
|
text_content = """
|
||||||
Complaint Resolved
|
Complaint Resolved
|
||||||
@ -673,21 +641,7 @@ class Command(BaseCommand):
|
|||||||
def _send_admin_new_complaint_inline(self):
|
def _send_admin_new_complaint_inline(self):
|
||||||
"""Example admin new complaint notification (currently inline - no template)"""
|
"""Example admin new complaint notification (currently inline - no template)"""
|
||||||
html_content = f"""
|
html_content = f"""
|
||||||
<!DOCTYPE html>
|
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">🚨 New Complaint Alert</h1>
|
||||||
<html>
|
|
||||||
<head><style>
|
|
||||||
body {{ font-family: 'Segoe UI', Tahoma, sans-serif; background: #f8fafc; margin: 0; padding: 20px; }}
|
|
||||||
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }}
|
|
||||||
.header {{ background: linear-gradient(135deg, #005696 0%, #007bbd 100%); padding: 30px; text-align: center; color: white; }}
|
|
||||||
.content {{ padding: 30px; }}
|
|
||||||
.alert-box {{ background: #eef6fb; border-left: 4px solid #005696; padding: 15px; margin: 20px 0; }}
|
|
||||||
</style></head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h1>🚨 New Complaint Alert</h1>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<p>Dear Administrator,</p>
|
<p>Dear Administrator,</p>
|
||||||
<div class="alert-box">
|
<div class="alert-box">
|
||||||
<strong>A new high-priority complaint has been logged:</strong><br><br>
|
<strong>A new high-priority complaint has been logged:</strong><br><br>
|
||||||
@ -700,10 +654,7 @@ class Command(BaseCommand):
|
|||||||
<strong>Date:</strong> April 7, 2026
|
<strong>Date:</strong> April 7, 2026
|
||||||
</div>
|
</div>
|
||||||
<p>Immediate attention required. Please review and assign to appropriate staff.</p>
|
<p>Immediate attention required. Please review and assign to appropriate staff.</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
"""
|
||||||
text_content = """
|
text_content = """
|
||||||
🚨 New Complaint Alert
|
🚨 New Complaint Alert
|
||||||
@ -727,21 +678,7 @@ class Command(BaseCommand):
|
|||||||
def _send_escalation_inline(self):
|
def _send_escalation_inline(self):
|
||||||
"""Example escalation notification (currently inline - no template)"""
|
"""Example escalation notification (currently inline - no template)"""
|
||||||
html_content = f"""
|
html_content = f"""
|
||||||
<!DOCTYPE html>
|
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">⚠️ Complaint Escalated</h1>
|
||||||
<html>
|
|
||||||
<head><style>
|
|
||||||
body {{ font-family: 'Segoe UI', Tahoma, sans-serif; background: #f8fafc; margin: 0; padding: 20px; }}
|
|
||||||
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }}
|
|
||||||
.header {{ background: linear-gradient(135deg, #005696 0%, #007bbd 100%); padding: 30px; text-align: center; color: white; }}
|
|
||||||
.content {{ padding: 30px; }}
|
|
||||||
.warning-box {{ background: #eef6fb; border-left: 4px solid #005696; padding: 15px; margin: 20px 0; }}
|
|
||||||
</style></head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h1>⚠️ Complaint Escalated</h1>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<p>Dear Manager,</p>
|
<p>Dear Manager,</p>
|
||||||
<div class="warning-box">
|
<div class="warning-box">
|
||||||
<strong>Complaint has been escalated to your attention:</strong><br><br>
|
<strong>Complaint has been escalated to your attention:</strong><br><br>
|
||||||
@ -752,10 +689,7 @@ class Command(BaseCommand):
|
|||||||
<strong>Date:</strong> April 7, 2026
|
<strong>Date:</strong> April 7, 2026
|
||||||
</div>
|
</div>
|
||||||
<p>Please review this complaint and provide your intervention to ensure resolution.</p>
|
<p>Please review this complaint and provide your intervention to ensure resolution.</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
"""
|
||||||
text_content = """
|
text_content = """
|
||||||
⚠️ Complaint Escalated
|
⚠️ Complaint Escalated
|
||||||
@ -935,21 +869,7 @@ class Command(BaseCommand):
|
|||||||
def _send_survey_invitation(self):
|
def _send_survey_invitation(self):
|
||||||
"""Example survey invitation (currently inline - no HTML template)"""
|
"""Example survey invitation (currently inline - no HTML template)"""
|
||||||
html_content = f"""
|
html_content = f"""
|
||||||
<!DOCTYPE html>
|
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">Share Your Experience</h1>
|
||||||
<html>
|
|
||||||
<head><style>
|
|
||||||
body {{ font-family: 'Segoe UI', Tahoma, sans-serif; background: #f8fafc; margin: 0; padding: 20px; }}
|
|
||||||
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }}
|
|
||||||
.header {{ background: linear-gradient(135deg, #005696 0%, #007bbd 100%); padding: 30px; text-align: center; color: white; }}
|
|
||||||
.content {{ padding: 30px; }}
|
|
||||||
.button {{ display: inline-block; padding: 14px 32px; background: #005696; color: white; text-decoration: none; border-radius: 8px; font-weight: 600; }}
|
|
||||||
</style></head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h1>Share Your Experience</h1>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<p>Dear Valued Patient,</p>
|
<p>Dear Valued Patient,</p>
|
||||||
<p>Thank you for visiting Al Hammadi Hospital. We would appreciate your feedback to help us improve our services.</p>
|
<p>Thank you for visiting Al Hammadi Hospital. We would appreciate your feedback to help us improve our services.</p>
|
||||||
<p>Please take a few minutes to complete our satisfaction survey:</p>
|
<p>Please take a few minutes to complete our satisfaction survey:</p>
|
||||||
@ -957,10 +877,7 @@ class Command(BaseCommand):
|
|||||||
<a href="https://px360.sa/survey/complete/abc123" class="button">Take Survey</a>
|
<a href="https://px360.sa/survey/complete/abc123" class="button">Take Survey</a>
|
||||||
</p>
|
</p>
|
||||||
<p>Your feedback is important to us!</p>
|
<p>Your feedback is important to us!</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
"""
|
||||||
text_content = """
|
text_content = """
|
||||||
Share Your Experience
|
Share Your Experience
|
||||||
@ -1004,21 +921,7 @@ class Command(BaseCommand):
|
|||||||
def _send_appreciation_notification(self):
|
def _send_appreciation_notification(self):
|
||||||
"""Example appreciation notification (currently inline - no template)"""
|
"""Example appreciation notification (currently inline - no template)"""
|
||||||
html_content = f"""
|
html_content = f"""
|
||||||
<!DOCTYPE html>
|
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">🌟 Staff Appreciation</h1>
|
||||||
<html>
|
|
||||||
<head><style>
|
|
||||||
body {{ font-family: 'Segoe UI', Tahoma, sans-serif; background: #f8fafc; margin: 0; padding: 20px; }}
|
|
||||||
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }}
|
|
||||||
.header {{ background: linear-gradient(135deg, #005696 0%, #007bbd 100%); padding: 30px; text-align: center; color: white; }}
|
|
||||||
.content {{ padding: 30px; }}
|
|
||||||
.appreciation-box {{ background: #eef6fb; border-left: 4px solid #005696; padding: 20px; margin: 20px 0; }}
|
|
||||||
</style></head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h1>🌟 Staff Appreciation</h1>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<p>Dear Team,</p>
|
<p>Dear Team,</p>
|
||||||
<div class="appreciation-box">
|
<div class="appreciation-box">
|
||||||
<strong>Excellent work recognized!</strong><br><br>
|
<strong>Excellent work recognized!</strong><br><br>
|
||||||
@ -1028,10 +931,7 @@ class Command(BaseCommand):
|
|||||||
<strong>From:</strong> Patient Family Member
|
<strong>From:</strong> Patient Family Member
|
||||||
</div>
|
</div>
|
||||||
<p>Congratulations on this recognition! Your dedication to patient care is truly appreciated.</p>
|
<p>Congratulations on this recognition! Your dedication to patient care is truly appreciated.</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
"""
|
||||||
text_content = """
|
text_content = """
|
||||||
🌟 Staff Appreciation
|
🌟 Staff Appreciation
|
||||||
@ -1086,28 +986,12 @@ class Command(BaseCommand):
|
|||||||
def _send_explanation_requested(self):
|
def _send_explanation_requested(self):
|
||||||
"""Example explanation requested from settings service (currently inline)"""
|
"""Example explanation requested from settings service (currently inline)"""
|
||||||
html_content = f"""
|
html_content = f"""
|
||||||
<!DOCTYPE html>
|
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">Explanation Requested</h1>
|
||||||
<html>
|
|
||||||
<head><style>
|
|
||||||
body {{ font-family: 'Segoe UI', Tahoma, sans-serif; background: #f8fafc; margin: 0; padding: 20px; }}
|
|
||||||
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }}
|
|
||||||
.header {{ background: linear-gradient(135deg, #005696 0%, #007bbd 100%); padding: 30px; text-align: center; color: white; }}
|
|
||||||
.content {{ padding: 30px; }}
|
|
||||||
</style></head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h1>Explanation Requested</h1>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<p>Dear Staff Member,</p>
|
<p>Dear Staff Member,</p>
|
||||||
<p>An explanation has been requested for complaint #COMP-2026-0060.</p>
|
<p>An explanation has been requested for complaint #COMP-2026-0060.</p>
|
||||||
<p><strong>Deadline:</strong> April 14, 2026</p>
|
<p><strong>Deadline:</strong> April 14, 2026</p>
|
||||||
<p>Please submit your explanation through the system.</p>
|
<p>Please submit your explanation through the system.</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
"""
|
||||||
text_content = """
|
text_content = """
|
||||||
Explanation Requested
|
Explanation Requested
|
||||||
@ -1125,29 +1009,13 @@ class Command(BaseCommand):
|
|||||||
def _send_complaint_assigned(self):
|
def _send_complaint_assigned(self):
|
||||||
"""Example complaint assigned from settings service (currently inline)"""
|
"""Example complaint assigned from settings service (currently inline)"""
|
||||||
html_content = f"""
|
html_content = f"""
|
||||||
<!DOCTYPE html>
|
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">Complaint Assigned to You</h1>
|
||||||
<html>
|
|
||||||
<head><style>
|
|
||||||
body {{ font-family: 'Segoe UI', Tahoma, sans-serif; background: #f8fafc; margin: 0; padding: 20px; }}
|
|
||||||
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }}
|
|
||||||
.header {{ background: linear-gradient(135deg, #005696 0%, #007bbd 100%); padding: 30px; text-align: center; color: white; }}
|
|
||||||
.content {{ padding: 30px; }}
|
|
||||||
</style></head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h1>Complaint Assigned to You</h1>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<p>Dear Dr. Khalid,</p>
|
<p>Dear Dr. Khalid,</p>
|
||||||
<p>A complaint has been assigned to you for review and response.</p>
|
<p>A complaint has been assigned to you for review and response.</p>
|
||||||
<p><strong>Complaint:</strong> #COMP-2026-0062 - Staff Attitude Issue</p>
|
<p><strong>Complaint:</strong> #COMP-2026-0062 - Staff Attitude Issue</p>
|
||||||
<p><strong>Department:</strong> Outpatient Clinic</p>
|
<p><strong>Department:</strong> Outpatient Clinic</p>
|
||||||
<p>Please review and provide your explanation.</p>
|
<p>Please review and provide your explanation.</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
"""
|
||||||
text_content = """
|
text_content = """
|
||||||
Complaint Assigned to You
|
Complaint Assigned to You
|
||||||
@ -1166,29 +1034,13 @@ class Command(BaseCommand):
|
|||||||
def _send_complaint_status_changed(self):
|
def _send_complaint_status_changed(self):
|
||||||
"""Example complaint status changed from settings service (currently inline)"""
|
"""Example complaint status changed from settings service (currently inline)"""
|
||||||
html_content = f"""
|
html_content = f"""
|
||||||
<!DOCTYPE html>
|
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">Complaint Status Updated</h1>
|
||||||
<html>
|
|
||||||
<head><style>
|
|
||||||
body {{ font-family: 'Segoe UI', Tahoma, sans-serif; background: #f8fafc; margin: 0; padding: 20px; }}
|
|
||||||
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }}
|
|
||||||
.header {{ background: linear-gradient(135deg, #005696 0%, #007bbd 100%); padding: 30px; text-align: center; color: white; }}
|
|
||||||
.content {{ padding: 30px; }}
|
|
||||||
</style></head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h1>Complaint Status Updated</h1>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<p>Dear Stakeholder,</p>
|
<p>Dear Stakeholder,</p>
|
||||||
<p>The status of complaint #COMP-2026-0058 has been updated.</p>
|
<p>The status of complaint #COMP-2026-0058 has been updated.</p>
|
||||||
<p><strong>Previous Status:</strong> Under Review</p>
|
<p><strong>Previous Status:</strong> Under Review</p>
|
||||||
<p><strong>New Status:</strong> Resolved</p>
|
<p><strong>New Status:</strong> Resolved</p>
|
||||||
<p><strong>Resolution:</strong> Issue has been addressed with staff member. Apology issued to patient.</p>
|
<p><strong>Resolution:</strong> Issue has been addressed with staff member. Apology issued to patient.</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
"""
|
||||||
text_content = """
|
text_content = """
|
||||||
Complaint Status Updated
|
Complaint Status Updated
|
||||||
|
|||||||
10
apps/core/templatetags/national_id_tags.py
Normal file
10
apps/core/templatetags/national_id_tags.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
from django import template
|
||||||
|
|
||||||
|
from apps.core.encryption import mask_national_id
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def mask_id(value):
|
||||||
|
return mask_national_id(value)
|
||||||
@ -231,9 +231,9 @@ class HISAdapter:
|
|||||||
"""Get or create patient from HIS demographic data"""
|
"""Get or create patient from HIS demographic data"""
|
||||||
patient_id = patient_data.get("PatientID")
|
patient_id = patient_data.get("PatientID")
|
||||||
mrn = patient_id
|
mrn = patient_id
|
||||||
national_id = patient_data.get("SSN")
|
national_id = patient_data.get("SSN") or ""
|
||||||
phone = patient_data.get("MobileNo")
|
phone = patient_data.get("MobileNo") or ""
|
||||||
email = patient_data.get("Email")
|
email = patient_data.get("Email") or ""
|
||||||
full_name = patient_data.get("PatientName")
|
full_name = patient_data.get("PatientName")
|
||||||
nationality = patient_data.get("PatientNationality", "")
|
nationality = patient_data.get("PatientNationality", "")
|
||||||
|
|
||||||
@ -249,8 +249,8 @@ class HISAdapter:
|
|||||||
if patient:
|
if patient:
|
||||||
patient.first_name = first_name
|
patient.first_name = first_name
|
||||||
patient.last_name = last_name
|
patient.last_name = last_name
|
||||||
patient.national_id = national_id
|
patient.national_id = national_id or ""
|
||||||
patient.phone = phone
|
patient.phone = phone or ""
|
||||||
if email is not None:
|
if email is not None:
|
||||||
patient.email = email
|
patient.email = email
|
||||||
patient.date_of_birth = date_of_birth
|
patient.date_of_birth = date_of_birth
|
||||||
@ -262,7 +262,10 @@ class HISAdapter:
|
|||||||
mrn_taken = Patient.objects.filter(mrn=mrn).exists()
|
mrn_taken = Patient.objects.filter(mrn=mrn).exists()
|
||||||
|
|
||||||
if mrn_taken and national_id:
|
if mrn_taken and national_id:
|
||||||
patient = Patient.objects.filter(national_id=national_id).first()
|
from apps.core.encryption import compute_national_id_hash
|
||||||
|
|
||||||
|
nid_hash = compute_national_id_hash(national_id)
|
||||||
|
patient = Patient.objects.filter(national_id_hash=nid_hash).first()
|
||||||
if patient:
|
if patient:
|
||||||
patient.mrn = mrn
|
patient.mrn = mrn
|
||||||
patient.primary_hospital = hospital
|
patient.primary_hospital = hospital
|
||||||
@ -288,8 +291,8 @@ class HISAdapter:
|
|||||||
first_name=first_name,
|
first_name=first_name,
|
||||||
last_name=last_name,
|
last_name=last_name,
|
||||||
national_id=national_id,
|
national_id=national_id,
|
||||||
phone=phone,
|
phone=phone or "",
|
||||||
email=email if email else "",
|
email=email or "",
|
||||||
date_of_birth=date_of_birth,
|
date_of_birth=date_of_birth,
|
||||||
gender=gender,
|
gender=gender,
|
||||||
nationality=nationality,
|
nationality=nationality,
|
||||||
|
|||||||
@ -41,8 +41,8 @@ class HISClient:
|
|||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
|
|
||||||
# Load credentials from Django settings (which reads .env)
|
# Load credentials from Django settings (which reads .env)
|
||||||
self.username = getattr(settings, 'HIS_API_USERNAME', '')
|
self.username = getattr(settings, "HIS_API_USERNAME", "")
|
||||||
self.password = getattr(settings, 'HIS_API_PASSWORD', '')
|
self.password = getattr(settings, "HIS_API_PASSWORD", "")
|
||||||
|
|
||||||
def _get_default_config(self) -> Optional[IntegrationConfig]:
|
def _get_default_config(self) -> Optional[IntegrationConfig]:
|
||||||
"""Get default active HIS configuration from database."""
|
"""Get default active HIS configuration from database."""
|
||||||
@ -62,7 +62,7 @@ class HISClient:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Priority 1: API key from Django settings
|
# Priority 1: API key from Django settings
|
||||||
api_key = getattr(settings, 'HIS_API_KEY', None)
|
api_key = getattr(settings, "HIS_API_KEY", None)
|
||||||
if api_key:
|
if api_key:
|
||||||
headers["X-API-Key"] = api_key
|
headers["X-API-Key"] = api_key
|
||||||
headers["Authorization"] = f"Bearer {api_key}"
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
@ -78,7 +78,7 @@ class HISClient:
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
# Priority 1: Django settings (.env file)
|
# Priority 1: Django settings (.env file)
|
||||||
settings_url = getattr(settings, 'HIS_API_URL', None)
|
settings_url = getattr(settings, "HIS_API_URL", None)
|
||||||
if settings_url:
|
if settings_url:
|
||||||
return settings_url
|
return settings_url
|
||||||
|
|
||||||
@ -159,18 +159,18 @@ class HISClient:
|
|||||||
|
|
||||||
def fetch_doctor_ratings(self, from_date: datetime, to_date: datetime) -> Optional[Dict]:
|
def fetch_doctor_ratings(self, from_date: datetime, to_date: datetime) -> Optional[Dict]:
|
||||||
"""
|
"""
|
||||||
Fetch doctor ratings from HIS FetchDoctorRatingMAPI1 endpoint.
|
Fetch doctor ratings from HIS FetchDoctorRatingMAPI endpoint.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
from_date: Start date for ratings
|
from_date: Start date for ratings
|
||||||
to_date: End date for ratings
|
to_date: End date for ratings
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
HIS response dict with FetchDoctorRatingMAPI1List or None on error
|
HIS response dict with FetchDoctorRatingMAPIList or None on error
|
||||||
"""
|
"""
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
api_url = getattr(settings, 'HIS_RATINGS_API_URL', None)
|
api_url = getattr(settings, "HIS_RATINGS_API_URL", None)
|
||||||
if not api_url:
|
if not api_url:
|
||||||
logger.error("HIS_RATINGS_API_URL not configured in Django settings")
|
logger.error("HIS_RATINGS_API_URL not configured in Django settings")
|
||||||
return None
|
return None
|
||||||
@ -192,11 +192,17 @@ class HISClient:
|
|||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
|
logger.info(f"HIS doctor ratings response status: {response.status_code}")
|
||||||
|
logger.debug(f"HIS doctor ratings response body: {response.text[:2000]}")
|
||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
|
logger.info(f"HIS doctor ratings response keys: {list(data.keys())}")
|
||||||
rating_count = len(data.get("FetchDoctorRatingMAPI1List", []))
|
rating_count = len(data.get("FetchDoctorRatingMAPI1List", []))
|
||||||
logger.info(f"Fetched {rating_count} doctor ratings from HIS")
|
logger.info(f"Fetched {rating_count} doctor ratings from HIS")
|
||||||
|
if rating_count == 0 and data:
|
||||||
|
logger.info(f"Full HIS response (no ratings): {data}")
|
||||||
return data
|
return data
|
||||||
else:
|
else:
|
||||||
logger.error(f"Unexpected HIS response type: {type(data)}")
|
logger.error(f"Unexpected HIS response type: {type(data)}")
|
||||||
@ -392,7 +398,7 @@ class HISClientFactory:
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
# Priority 1: If Django settings has HIS API URL, use it
|
# Priority 1: If Django settings has HIS API URL, use it
|
||||||
if getattr(settings, 'HIS_API_URL', None):
|
if getattr(settings, "HIS_API_URL", None):
|
||||||
logger.info("Using HIS API URL from Django settings (.env file)")
|
logger.info("Using HIS API URL from Django settings (.env file)")
|
||||||
return [HISClient()]
|
return [HISClient()]
|
||||||
|
|
||||||
|
|||||||
@ -1,209 +1,20 @@
|
|||||||
"""
|
"""
|
||||||
Integrations Celery tasks
|
Integrations Celery tasks
|
||||||
|
|
||||||
This module contains the core event processing logic that:
|
This module contains tasks for:
|
||||||
1. Processes inbound events from external systems
|
1. Fetching surveys from HIS systems (every 25 minutes)
|
||||||
2. Finds matching journey instances
|
2. Testing HIS connection
|
||||||
3. Completes journey stages
|
|
||||||
4. Triggers survey creation
|
|
||||||
5. Fetches surveys from HIS systems (every 5 minutes)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from django.db import transaction
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
logger = logging.getLogger("apps.integrations")
|
logger = logging.getLogger("apps.integrations")
|
||||||
|
|
||||||
|
|
||||||
@shared_task(bind=True, max_retries=3)
|
|
||||||
def process_inbound_event(self, event_id):
|
|
||||||
"""
|
|
||||||
Process an inbound integration event.
|
|
||||||
|
|
||||||
This is the core event processing task that:
|
|
||||||
1. Finds the journey instance by encounter_id
|
|
||||||
2. Finds the matching stage by trigger_event_code
|
|
||||||
3. Completes the stage
|
|
||||||
4. Creates survey instance if configured
|
|
||||||
5. Logs audit events
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event_id: UUID of the InboundEvent to process
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Processing result with status and details
|
|
||||||
"""
|
|
||||||
from apps.core.services import create_audit_log
|
|
||||||
from apps.integrations.models import InboundEvent
|
|
||||||
from apps.journeys.models import PatientJourneyInstance, PatientJourneyStageInstance, StageStatus
|
|
||||||
from apps.organizations.models import Department, Staff
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get the event
|
|
||||||
event = InboundEvent.objects.get(id=event_id)
|
|
||||||
event.mark_processing()
|
|
||||||
|
|
||||||
logger.info(f"Processing event {event.id}: {event.event_code} for encounter {event.encounter_id}")
|
|
||||||
|
|
||||||
# Find journey instance by encounter_id
|
|
||||||
try:
|
|
||||||
journey_instance = PatientJourneyInstance.objects.select_related(
|
|
||||||
"journey_template", "patient", "hospital"
|
|
||||||
).get(encounter_id=event.encounter_id)
|
|
||||||
except PatientJourneyInstance.DoesNotExist:
|
|
||||||
error_msg = f"No journey instance found for encounter {event.encounter_id}"
|
|
||||||
logger.warning(error_msg)
|
|
||||||
event.mark_ignored(error_msg)
|
|
||||||
return {"status": "ignored", "reason": error_msg}
|
|
||||||
|
|
||||||
# Find matching stage by trigger_event_code
|
|
||||||
matching_stages = journey_instance.stage_instances.filter(
|
|
||||||
stage_template__trigger_event_code=event.event_code,
|
|
||||||
status__in=[StageStatus.PENDING, StageStatus.IN_PROGRESS],
|
|
||||||
).select_related("stage_template")
|
|
||||||
|
|
||||||
if not matching_stages.exists():
|
|
||||||
error_msg = f"No pending stage found with trigger {event.event_code}"
|
|
||||||
logger.warning(error_msg)
|
|
||||||
event.mark_ignored(error_msg)
|
|
||||||
return {"status": "ignored", "reason": error_msg}
|
|
||||||
|
|
||||||
# Get the first matching stage
|
|
||||||
stage_instance = matching_stages.first()
|
|
||||||
|
|
||||||
# Extract staff and department from event payload
|
|
||||||
staff = None
|
|
||||||
department = None
|
|
||||||
|
|
||||||
if event.physician_license:
|
|
||||||
try:
|
|
||||||
staff = Staff.objects.get(license_number=event.physician_license, hospital=journey_instance.hospital)
|
|
||||||
except Staff.DoesNotExist:
|
|
||||||
logger.warning(f"Staff member not found with license: {event.physician_license}")
|
|
||||||
|
|
||||||
if event.department_code:
|
|
||||||
try:
|
|
||||||
department = Department.objects.get(code=event.department_code, hospital=journey_instance.hospital)
|
|
||||||
except Department.DoesNotExist:
|
|
||||||
logger.warning(f"Department not found: {event.department_code}")
|
|
||||||
|
|
||||||
# Complete the stage
|
|
||||||
with transaction.atomic():
|
|
||||||
success = stage_instance.complete(
|
|
||||||
event=event, staff=staff, department=department, metadata=event.payload_json
|
|
||||||
)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
# Log stage completion
|
|
||||||
create_audit_log(
|
|
||||||
event_type="stage_completed",
|
|
||||||
description=f"Stage {stage_instance.stage_template.name} completed for encounter {event.encounter_id}",
|
|
||||||
content_object=stage_instance,
|
|
||||||
metadata={
|
|
||||||
"event_code": event.event_code,
|
|
||||||
"stage_name": stage_instance.stage_template.name,
|
|
||||||
"journey_type": journey_instance.journey_template.journey_type,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if this is a discharge event
|
|
||||||
if event.event_code.upper() == "PATIENT_DISCHARGED":
|
|
||||||
logger.info(f"Discharge event received for encounter {event.encounter_id}")
|
|
||||||
|
|
||||||
# Mark journey as completed
|
|
||||||
journey_instance.status = "completed"
|
|
||||||
journey_instance.completed_at = timezone.now()
|
|
||||||
journey_instance.save()
|
|
||||||
|
|
||||||
# Check if post-discharge survey is enabled
|
|
||||||
if journey_instance.journey_template.send_post_discharge_survey:
|
|
||||||
logger.info(
|
|
||||||
f"Post-discharge survey enabled for journey {journey_instance.id}. "
|
|
||||||
f"Will send in {journey_instance.journey_template.post_discharge_survey_delay_hours} hour(s)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Queue post-discharge survey creation task with delay
|
|
||||||
from apps.surveys.tasks import create_post_discharge_survey
|
|
||||||
|
|
||||||
delay_hours = journey_instance.journey_template.post_discharge_survey_delay_hours
|
|
||||||
delay_seconds = delay_hours * 3600
|
|
||||||
|
|
||||||
create_post_discharge_survey.apply_async(
|
|
||||||
args=[str(journey_instance.id)], countdown=delay_seconds
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Queued post-discharge survey for journey {journey_instance.id} (delay: {delay_hours}h)"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.info(f"Post-discharge survey disabled for journey {journey_instance.id}")
|
|
||||||
|
|
||||||
# Mark event as processed
|
|
||||||
event.mark_processed()
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Successfully processed event {event.id}: Completed stage {stage_instance.stage_template.name}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "processed",
|
|
||||||
"stage_completed": stage_instance.stage_template.name,
|
|
||||||
"journey_completion": journey_instance.get_completion_percentage(),
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
error_msg = "Failed to complete stage"
|
|
||||||
event.mark_failed(error_msg)
|
|
||||||
return {"status": "failed", "reason": error_msg}
|
|
||||||
|
|
||||||
except InboundEvent.DoesNotExist:
|
|
||||||
error_msg = f"Event {event_id} not found"
|
|
||||||
logger.error(error_msg)
|
|
||||||
return {"status": "error", "reason": error_msg}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = f"Error processing event: {str(e)}"
|
|
||||||
logger.error(error_msg, exc_info=True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
event.mark_failed(error_msg)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Retry the task
|
|
||||||
raise self.retry(exc=e, countdown=60 * (self.request.retries + 1))
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
|
||||||
def process_pending_events():
|
|
||||||
"""
|
|
||||||
Periodic task to process pending events.
|
|
||||||
|
|
||||||
This task runs every minute (configured in config/celery.py)
|
|
||||||
and processes all pending events.
|
|
||||||
"""
|
|
||||||
from apps.integrations.models import InboundEvent
|
|
||||||
|
|
||||||
pending_events = InboundEvent.objects.filter(status="pending").order_by("received_at")[
|
|
||||||
:100
|
|
||||||
] # Process max 100 at a time
|
|
||||||
|
|
||||||
processed_count = 0
|
|
||||||
|
|
||||||
for event in pending_events:
|
|
||||||
# Queue individual event for processing
|
|
||||||
process_inbound_event.delay(str(event.id))
|
|
||||||
processed_count += 1
|
|
||||||
|
|
||||||
if processed_count > 0:
|
|
||||||
logger.info(f"Queued {processed_count} pending events for processing")
|
|
||||||
|
|
||||||
return {"queued": processed_count}
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# HIS Survey Fetching Tasks
|
# HIS Survey Fetching Tasks
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@ -1,107 +1,141 @@
|
|||||||
"""
|
"""
|
||||||
Organizations admin
|
Organizations admin
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from .models import Department, Hospital, Organization, Patient, Staff,Location,MainSection,SubSection
|
from .models import Department, Hospital, Organization, Patient, Staff, Location, MainSection, SubSection
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Organization)
|
@admin.register(Organization)
|
||||||
class OrganizationAdmin(admin.ModelAdmin):
|
class OrganizationAdmin(admin.ModelAdmin):
|
||||||
"""Organization admin"""
|
"""Organization admin"""
|
||||||
list_display = ['name', 'code', 'city', 'status', 'created_at']
|
|
||||||
list_filter = ['status', 'city']
|
list_display = ["name", "code", "city", "status", "created_at"]
|
||||||
search_fields = ['name', 'name_ar', 'code', 'license_number']
|
list_filter = ["status", "city"]
|
||||||
ordering = ['name']
|
search_fields = ["name", "name_ar", "code"]
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {'fields': ('name', 'name_ar', 'code')}),
|
(None, {"fields": ("name", "name_ar", "code")}),
|
||||||
('Contact Information', {'fields': ('address', 'city', 'phone', 'email', 'website')}),
|
("Contact Information", {"fields": ("address", "city", "phone", "email")}),
|
||||||
('Details', {'fields': ('license_number', 'status', 'logo')}),
|
("Details", {"fields": ("status", "preferred_language")}),
|
||||||
('Metadata', {'fields': ('created_at', 'updated_at')}),
|
("Metadata", {"fields": ("created_at", "updated_at")}),
|
||||||
)
|
)
|
||||||
|
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
readonly_fields = ["created_at", "updated_at"]
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Hospital)
|
@admin.register(Hospital)
|
||||||
class HospitalAdmin(admin.ModelAdmin):
|
class HospitalAdmin(admin.ModelAdmin):
|
||||||
"""Hospital admin"""
|
"""Hospital admin"""
|
||||||
list_display = ['name', 'code', 'city', 'ceo', 'status', 'capacity', 'created_at']
|
|
||||||
list_filter = ['status', 'city']
|
list_display = ["name", "code", "city", "ceo", "status", "capacity", "created_at"]
|
||||||
search_fields = ['name', 'name_ar', 'code', 'license_number']
|
list_filter = ["status", "city"]
|
||||||
ordering = ['name']
|
search_fields = ["name", "name_ar", "code", "license_number"]
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {'fields': ('organization', 'name', 'name_ar', 'code')}),
|
(None, {"fields": ("organization", "name", "name_ar", "code")}),
|
||||||
('Contact Information', {'fields': ('address', 'city', 'phone', 'email')}),
|
("Contact Information", {"fields": ("address", "city", "phone", "email")}),
|
||||||
('Executive Leadership', {'fields': ('ceo', 'medical_director', 'coo', 'cfo')}),
|
("Executive Leadership", {"fields": ("ceo", "medical_director", "coo", "cfo")}),
|
||||||
('Details', {'fields': ('license_number', 'capacity', 'status')}),
|
("Details", {"fields": ("license_number", "capacity", "status")}),
|
||||||
('Metadata', {'fields': ('created_at', 'updated_at')}),
|
("Metadata", {"fields": ("created_at", "updated_at")}),
|
||||||
)
|
)
|
||||||
autocomplete_fields = ['organization', 'ceo', 'medical_director', 'coo', 'cfo']
|
autocomplete_fields = ["organization", "ceo", "medical_director", "coo", "cfo"]
|
||||||
|
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
readonly_fields = ["created_at", "updated_at"]
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Department)
|
@admin.register(Department)
|
||||||
class DepartmentAdmin(admin.ModelAdmin):
|
class DepartmentAdmin(admin.ModelAdmin):
|
||||||
"""Department admin"""
|
"""Department admin"""
|
||||||
list_display = ['name', 'hospital', 'code', 'manager', 'status', 'created_at']
|
|
||||||
list_filter = ['status', 'hospital']
|
list_display = ["name", "hospital", "code", "manager", "status", "created_at"]
|
||||||
search_fields = ['name', 'name_ar', 'code']
|
list_filter = ["status", "hospital"]
|
||||||
ordering = ['hospital', 'name']
|
search_fields = ["name", "name_ar", "code"]
|
||||||
autocomplete_fields = ['hospital', 'parent', 'manager']
|
ordering = ["hospital", "name"]
|
||||||
|
autocomplete_fields = ["hospital", "parent", "manager"]
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {'fields': ('hospital', 'name', 'name_ar', 'code')}),
|
(None, {"fields": ("hospital", "name", "name_ar", "code")}),
|
||||||
('Hierarchy', {'fields': ('parent', 'manager')}),
|
("Hierarchy", {"fields": ("parent", "manager")}),
|
||||||
('Contact', {'fields': ('phone', 'email', 'location')}),
|
("Contact", {"fields": ("phone", "email", "location")}),
|
||||||
('Status', {'fields': ('status',)}),
|
("Status", {"fields": ("status",)}),
|
||||||
('Metadata', {'fields': ('created_at', 'updated_at')}),
|
("Metadata", {"fields": ("created_at", "updated_at")}),
|
||||||
)
|
)
|
||||||
|
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
readonly_fields = ["created_at", "updated_at"]
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
qs = super().get_queryset(request)
|
qs = super().get_queryset(request)
|
||||||
return qs.select_related('hospital', 'manager', 'parent')
|
return qs.select_related("hospital", "manager", "parent")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Staff)
|
@admin.register(Staff)
|
||||||
class StaffAdmin(admin.ModelAdmin):
|
class StaffAdmin(admin.ModelAdmin):
|
||||||
"""Staff admin"""
|
"""Staff admin"""
|
||||||
list_display = ['__str__', 'staff_type', 'job_title', 'employee_id', 'hospital', 'department', 'email','phone', 'report_to', 'country', 'has_user_account', 'status']
|
|
||||||
list_filter = ['status', 'hospital', 'staff_type', 'specialization', 'gender', 'country']
|
list_display = [
|
||||||
search_fields = ['name', 'first_name', 'last_name', 'first_name_ar', 'last_name_ar', 'employee_id', 'license_number', 'job_title', 'phone', 'department_name', 'section']
|
"__str__",
|
||||||
ordering = ['last_name', 'first_name']
|
"staff_type",
|
||||||
autocomplete_fields = ['hospital', 'department', 'user', 'report_to']
|
"job_title",
|
||||||
actions = ['create_user_accounts', 'send_credentials_emails']
|
"employee_id",
|
||||||
|
"hospital",
|
||||||
|
"department",
|
||||||
|
"email",
|
||||||
|
"phone",
|
||||||
|
"report_to",
|
||||||
|
"country",
|
||||||
|
"has_user_account",
|
||||||
|
"status",
|
||||||
|
]
|
||||||
|
list_filter = ["status", "hospital", "staff_type", "specialization", "gender", "country"]
|
||||||
|
search_fields = [
|
||||||
|
"name",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"first_name_ar",
|
||||||
|
"last_name_ar",
|
||||||
|
"employee_id",
|
||||||
|
"license_number",
|
||||||
|
"job_title",
|
||||||
|
"phone",
|
||||||
|
"department_name",
|
||||||
|
"section",
|
||||||
|
]
|
||||||
|
ordering = ["last_name", "first_name"]
|
||||||
|
autocomplete_fields = ["hospital", "department", "user", "report_to"]
|
||||||
|
actions = ["create_user_accounts", "send_credentials_emails"]
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {'fields': ('name', 'first_name', 'last_name', 'first_name_ar', 'last_name_ar')}),
|
(None, {"fields": ("name", "first_name", "last_name", "first_name_ar", "last_name_ar")}),
|
||||||
('Role', {'fields': ('staff_type', 'job_title')}),
|
("Role", {"fields": ("staff_type", "job_title")}),
|
||||||
('Professional', {'fields': ('license_number', 'specialization', 'employee_id', 'email', 'phone')}),
|
("Professional", {"fields": ("license_number", "specialization", "employee_id", "email", "phone")}),
|
||||||
('Organization', {'fields': ('hospital', 'department', 'department_name', 'section', 'subsection', 'location')}),
|
(
|
||||||
('Hierarchy', {'fields': ('report_to',)}),
|
"Organization",
|
||||||
('Personal Information', {'fields': ('country', 'gender')}),
|
{"fields": ("hospital", "department", "department_name", "section", "subsection", "location")},
|
||||||
('Account', {'fields': ('user',)}),
|
),
|
||||||
('Status', {'fields': ('status',)}),
|
("Hierarchy", {"fields": ("report_to",)}),
|
||||||
('Metadata', {'fields': ('created_at', 'updated_at')}),
|
("Personal Information", {"fields": ("country", "gender")}),
|
||||||
|
("Account", {"fields": ("user",)}),
|
||||||
|
("Status", {"fields": ("status",)}),
|
||||||
|
("Metadata", {"fields": ("created_at", "updated_at")}),
|
||||||
)
|
)
|
||||||
|
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
readonly_fields = ["created_at", "updated_at"]
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
qs = super().get_queryset(request)
|
qs = super().get_queryset(request)
|
||||||
return qs.select_related('hospital', 'department', 'user')
|
return qs.select_related("hospital", "department", "user")
|
||||||
|
|
||||||
def has_user_account(self, obj):
|
def has_user_account(self, obj):
|
||||||
"""Display user account status"""
|
"""Display user account status"""
|
||||||
if obj.user:
|
if obj.user:
|
||||||
return '<span style="color: green;">✓ Yes</span>'
|
return '<span style="color: green;">✓ Yes</span>'
|
||||||
return '<span style="color: red;">✗ No</span>'
|
return '<span style="color: red;">✗ No</span>'
|
||||||
has_user_account.short_description = 'User Account'
|
|
||||||
|
has_user_account.short_description = "User Account"
|
||||||
has_user_account.allow_tags = True
|
has_user_account.allow_tags = True
|
||||||
|
|
||||||
def create_user_accounts(self, request, queryset):
|
def create_user_accounts(self, request, queryset):
|
||||||
@ -114,11 +148,7 @@ class StaffAdmin(admin.ModelAdmin):
|
|||||||
if not staff.user and staff.email:
|
if not staff.user and staff.email:
|
||||||
try:
|
try:
|
||||||
role = StaffService.get_staff_type_role(staff.staff_type)
|
role = StaffService.get_staff_type_role(staff.staff_type)
|
||||||
user, was_created, password = StaffService.create_user_for_staff(
|
user, was_created, password = StaffService.create_user_for_staff(staff, role=role, request=request)
|
||||||
staff,
|
|
||||||
role=role,
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
if was_created and password:
|
if was_created and password:
|
||||||
StaffService.send_credentials_email(staff, password, request)
|
StaffService.send_credentials_email(staff, password, request)
|
||||||
created += 1
|
created += 1
|
||||||
@ -126,11 +156,10 @@ class StaffAdmin(admin.ModelAdmin):
|
|||||||
failed += 1
|
failed += 1
|
||||||
|
|
||||||
self.message_user(
|
self.message_user(
|
||||||
request,
|
request, f"Created {created} user accounts. Failed: {failed}", level="success" if failed == 0 else "warning"
|
||||||
f'Created {created} user accounts. Failed: {failed}',
|
|
||||||
level='success' if failed == 0 else 'warning'
|
|
||||||
)
|
)
|
||||||
create_user_accounts.short_description = 'Create user accounts for selected staff'
|
|
||||||
|
create_user_accounts.short_description = "Create user accounts for selected staff"
|
||||||
|
|
||||||
def send_credentials_emails(self, request, queryset):
|
def send_credentials_emails(self, request, queryset):
|
||||||
"""Admin action to send credential emails to selected staff"""
|
"""Admin action to send credential emails to selected staff"""
|
||||||
@ -150,38 +179,51 @@ class StaffAdmin(admin.ModelAdmin):
|
|||||||
failed += 1
|
failed += 1
|
||||||
|
|
||||||
self.message_user(
|
self.message_user(
|
||||||
request,
|
request, f"Sent {sent} credential emails. Failed: {failed}", level="success" if failed == 0 else "warning"
|
||||||
f'Sent {sent} credential emails. Failed: {failed}',
|
|
||||||
level='success' if failed == 0 else 'warning'
|
|
||||||
)
|
)
|
||||||
send_credentials_emails.short_description = 'Send credential emails to selected staff'
|
|
||||||
|
send_credentials_emails.short_description = "Send credential emails to selected staff"
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Patient)
|
@admin.register(Patient)
|
||||||
class PatientAdmin(admin.ModelAdmin):
|
class PatientAdmin(admin.ModelAdmin):
|
||||||
"""Patient admin"""
|
"""Patient admin"""
|
||||||
list_display = ['get_full_name', 'mrn', 'national_id', 'phone', 'primary_hospital', 'status']
|
|
||||||
list_filter = ['status', 'gender', 'primary_hospital', 'city']
|
list_display = ["get_full_name", "mrn", "get_masked_national_id", "phone", "primary_hospital", "status"]
|
||||||
search_fields = ['mrn', 'national_id', 'first_name', 'last_name', 'first_name_ar', 'last_name_ar', 'phone', 'email']
|
list_filter = ["status", "gender", "primary_hospital", "city"]
|
||||||
ordering = ['last_name', 'first_name']
|
search_fields = [
|
||||||
autocomplete_fields = ['primary_hospital']
|
"mrn",
|
||||||
|
"national_id_hash",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"first_name_ar",
|
||||||
|
"last_name_ar",
|
||||||
|
"phone",
|
||||||
|
"email",
|
||||||
|
]
|
||||||
|
ordering = ["last_name", "first_name"]
|
||||||
|
autocomplete_fields = ["primary_hospital"]
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {'fields': ('mrn', 'national_id')}),
|
(None, {"fields": ("mrn", "national_id")}),
|
||||||
('Personal Information', {'fields': ('first_name', 'last_name', 'first_name_ar', 'last_name_ar')}),
|
("Personal Information", {"fields": ("first_name", "last_name", "first_name_ar", "last_name_ar")}),
|
||||||
('Demographics', {'fields': ('date_of_birth', 'gender')}),
|
("Demographics", {"fields": ("date_of_birth", "gender")}),
|
||||||
('Contact', {'fields': ('phone', 'email', 'address', 'city')}),
|
("Contact", {"fields": ("phone", "email", "address", "city")}),
|
||||||
('Hospital', {'fields': ('primary_hospital',)}),
|
("Hospital", {"fields": ("primary_hospital",)}),
|
||||||
('Status', {'fields': ('status',)}),
|
("Status", {"fields": ("status",)}),
|
||||||
('Metadata', {'fields': ('created_at', 'updated_at')}),
|
("Metadata", {"fields": ("created_at", "updated_at")}),
|
||||||
)
|
)
|
||||||
|
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
readonly_fields = ["created_at", "updated_at"]
|
||||||
|
|
||||||
|
def get_masked_national_id(self, obj):
|
||||||
|
return obj.get_masked_national_id()
|
||||||
|
|
||||||
|
get_masked_national_id.short_description = "National ID"
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
qs = super().get_queryset(request)
|
qs = super().get_queryset(request)
|
||||||
return qs.select_related('primary_hospital')
|
return qs.select_related("primary_hospital")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(Location)
|
admin.site.register(Location)
|
||||||
|
|||||||
@ -5,6 +5,7 @@ Organizations models - Hospital, Department, Physician, Employee, Patient
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from apps.core.encryption import EncryptedCharField, compute_national_id_hash, mask_national_id
|
||||||
from apps.core.models import TimeStampedModel, UUIDModel, StatusChoices
|
from apps.core.models import TimeStampedModel, UUIDModel, StatusChoices
|
||||||
|
|
||||||
|
|
||||||
@ -412,7 +413,8 @@ class Patient(UUIDModel, TimeStampedModel):
|
|||||||
|
|
||||||
# Basic information
|
# Basic information
|
||||||
mrn = models.CharField(max_length=50, unique=True, verbose_name="Medical Record Number")
|
mrn = models.CharField(max_length=50, unique=True, verbose_name="Medical Record Number")
|
||||||
national_id = models.CharField(max_length=50, blank=True, db_index=True)
|
national_id = EncryptedCharField(max_length=50, blank=True, default="")
|
||||||
|
national_id_hash = models.CharField(max_length=64, blank=True, db_index=True, default="")
|
||||||
|
|
||||||
first_name = models.CharField(max_length=100)
|
first_name = models.CharField(max_length=100)
|
||||||
last_name = models.CharField(max_length=100)
|
last_name = models.CharField(max_length=100)
|
||||||
@ -449,6 +451,17 @@ class Patient(UUIDModel, TimeStampedModel):
|
|||||||
def get_full_name(self):
|
def get_full_name(self):
|
||||||
return f"{self.first_name} {self.last_name}"
|
return f"{self.first_name} {self.last_name}"
|
||||||
|
|
||||||
|
def get_masked_national_id(self):
|
||||||
|
return mask_national_id(self.national_id)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if self.national_id:
|
||||||
|
self.national_id_hash = compute_national_id_hash(self.national_id)
|
||||||
|
else:
|
||||||
|
self.national_id = self.national_id or ""
|
||||||
|
self.national_id_hash = ""
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def generate_mrn():
|
def generate_mrn():
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -268,6 +268,8 @@ class PatientSerializer(serializers.ModelSerializer):
|
|||||||
primary_hospital_name = serializers.CharField(source="primary_hospital.name", read_only=True)
|
primary_hospital_name = serializers.CharField(source="primary_hospital.name", read_only=True)
|
||||||
full_name = serializers.CharField(source="get_full_name", read_only=True)
|
full_name = serializers.CharField(source="get_full_name", read_only=True)
|
||||||
age = serializers.SerializerMethodField()
|
age = serializers.SerializerMethodField()
|
||||||
|
national_id = serializers.SerializerMethodField()
|
||||||
|
national_id_masked = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Patient
|
model = Patient
|
||||||
@ -275,6 +277,7 @@ class PatientSerializer(serializers.ModelSerializer):
|
|||||||
"id",
|
"id",
|
||||||
"mrn",
|
"mrn",
|
||||||
"national_id",
|
"national_id",
|
||||||
|
"national_id_masked",
|
||||||
"first_name",
|
"first_name",
|
||||||
"last_name",
|
"last_name",
|
||||||
"first_name_ar",
|
"first_name_ar",
|
||||||
@ -295,6 +298,15 @@ class PatientSerializer(serializers.ModelSerializer):
|
|||||||
]
|
]
|
||||||
read_only_fields = ["id", "created_at", "updated_at"]
|
read_only_fields = ["id", "created_at", "updated_at"]
|
||||||
|
|
||||||
|
def get_national_id(self, obj):
|
||||||
|
request = self.context.get("request")
|
||||||
|
if request and request.user and request.user.is_superuser:
|
||||||
|
return obj.national_id
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_national_id_masked(self, obj):
|
||||||
|
return obj.get_masked_national_id()
|
||||||
|
|
||||||
def get_age(self, obj):
|
def get_age(self, obj):
|
||||||
"""Calculate patient age"""
|
"""Calculate patient age"""
|
||||||
if obj.date_of_birth:
|
if obj.date_of_birth:
|
||||||
@ -314,10 +326,11 @@ class PatientListSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
full_name = serializers.CharField(source="get_full_name", read_only=True)
|
full_name = serializers.CharField(source="get_full_name", read_only=True)
|
||||||
primary_hospital_name = serializers.CharField(source="primary_hospital.name", read_only=True)
|
primary_hospital_name = serializers.CharField(source="primary_hospital.name", read_only=True)
|
||||||
|
national_id_masked = serializers.CharField(source="get_masked_national_id", read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Patient
|
model = Patient
|
||||||
fields = ["id", "mrn", "full_name", "phone", "email", "primary_hospital_name", "status"]
|
fields = ["id", "mrn", "full_name", "national_id_masked", "phone", "email", "primary_hospital_name", "status"]
|
||||||
|
|
||||||
|
|
||||||
class LocationSerializer(serializers.ModelSerializer):
|
class LocationSerializer(serializers.ModelSerializer):
|
||||||
|
|||||||
@ -324,11 +324,14 @@ def patient_list(request):
|
|||||||
# Search
|
# Search
|
||||||
search_query = request.GET.get("search")
|
search_query = request.GET.get("search")
|
||||||
if search_query:
|
if search_query:
|
||||||
|
from apps.core.encryption import compute_national_id_hash
|
||||||
|
|
||||||
|
nid_hash = compute_national_id_hash(search_query)
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(
|
||||||
Q(mrn__icontains=search_query)
|
Q(mrn__icontains=search_query)
|
||||||
| Q(first_name__icontains=search_query)
|
| Q(first_name__icontains=search_query)
|
||||||
| Q(last_name__icontains=search_query)
|
| Q(last_name__icontains=search_query)
|
||||||
| Q(national_id__icontains=search_query)
|
| Q(national_id_hash=nid_hash)
|
||||||
| Q(phone__icontains=search_query)
|
| Q(phone__icontains=search_query)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -375,7 +378,7 @@ def patient_list(request):
|
|||||||
p.mrn,
|
p.mrn,
|
||||||
p.first_name,
|
p.first_name,
|
||||||
p.last_name,
|
p.last_name,
|
||||||
p.national_id,
|
p.get_masked_national_id(),
|
||||||
p.get_gender_display(),
|
p.get_gender_display(),
|
||||||
p.nationality,
|
p.nationality,
|
||||||
p.phone,
|
p.phone,
|
||||||
|
|||||||
@ -610,7 +610,7 @@ class PatientViewSet(viewsets.ModelViewSet):
|
|||||||
queryset = Patient.objects.all()
|
queryset = Patient.objects.all()
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
filterset_fields = ["status", "gender", "primary_hospital", "city", "primary_hospital__organization"]
|
filterset_fields = ["status", "gender", "primary_hospital", "city", "primary_hospital__organization"]
|
||||||
search_fields = ["mrn", "national_id", "first_name", "last_name", "phone", "email"]
|
search_fields = ["mrn", "national_id_hash", "first_name", "last_name", "phone", "email"]
|
||||||
ordering_fields = ["last_name", "created_at"]
|
ordering_fields = ["last_name", "created_at"]
|
||||||
ordering = ["last_name", "first_name"]
|
ordering = ["last_name", "first_name"]
|
||||||
|
|
||||||
@ -647,15 +647,27 @@ class PatientViewSet(viewsets.ModelViewSet):
|
|||||||
q = request.query_params.get("q", "").strip()
|
q = request.query_params.get("q", "").strip()
|
||||||
queryset = self.get_queryset().filter(status="active")
|
queryset = self.get_queryset().filter(status="active")
|
||||||
if q:
|
if q:
|
||||||
|
from apps.core.encryption import compute_national_id_hash
|
||||||
|
|
||||||
|
nid_hash = compute_national_id_hash(q)
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(
|
||||||
models.Q(mrn__icontains=q)
|
models.Q(mrn__icontains=q)
|
||||||
| models.Q(first_name__icontains=q)
|
| models.Q(first_name__icontains=q)
|
||||||
| models.Q(last_name__icontains=q)
|
| models.Q(last_name__icontains=q)
|
||||||
| models.Q(national_id__icontains=q)
|
| models.Q(national_id_hash=nid_hash)
|
||||||
| models.Q(phone__icontains=q)
|
| models.Q(phone__icontains=q)
|
||||||
)
|
)
|
||||||
queryset = queryset[:20]
|
queryset = queryset[:20]
|
||||||
data = [{"id": p.id, "first_name": p.first_name, "last_name": p.last_name, "mrn": p.mrn} for p in queryset]
|
data = [
|
||||||
|
{
|
||||||
|
"id": p.id,
|
||||||
|
"first_name": p.first_name,
|
||||||
|
"last_name": p.last_name,
|
||||||
|
"mrn": p.mrn,
|
||||||
|
"national_id_masked": p.get_masked_national_id(),
|
||||||
|
}
|
||||||
|
for p in queryset
|
||||||
|
]
|
||||||
return Response(data)
|
return Response(data)
|
||||||
|
|
||||||
|
|
||||||
@ -706,7 +718,7 @@ class SubSectionViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
@permission_classes([])
|
@permission_classes([])
|
||||||
def api_location_list(request):
|
def api_location_list(request):
|
||||||
"""API endpoint for location dropdown (public access)"""
|
"""API endpoint for location dropdown (public access)"""
|
||||||
locations = Location.objects.all().order_by("id")
|
locations = Location.objects.filter(id__in=[48, 49, 82, 110]).order_by("id")
|
||||||
serializer = LocationSerializer(locations, many=True)
|
serializer = LocationSerializer(locations, many=True)
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|||||||
@ -81,14 +81,19 @@ class DoctorRatingAdapter:
|
|||||||
|
|
||||||
formats = [
|
formats = [
|
||||||
"%d-%b-%Y %H:%M:%S",
|
"%d-%b-%Y %H:%M:%S",
|
||||||
|
"%d-%b-%Y %H:%M",
|
||||||
"%d-%b-%Y",
|
"%d-%b-%Y",
|
||||||
"%d-%b-%y %H:%M:%S",
|
"%d-%b-%y %H:%M:%S",
|
||||||
|
"%d-%b-%y %H:%M",
|
||||||
"%d-%b-%y",
|
"%d-%b-%y",
|
||||||
"%Y-%m-%d %H:%M:%S",
|
"%Y-%m-%d %H:%M:%S",
|
||||||
|
"%Y-%m-%d %H:%M",
|
||||||
"%Y-%m-%d",
|
"%Y-%m-%d",
|
||||||
"%d/%m/%Y %H:%M:%S",
|
"%d/%m/%Y %H:%M:%S",
|
||||||
|
"%d/%m/%Y %H:%M",
|
||||||
"%d/%m/%Y",
|
"%d/%m/%Y",
|
||||||
"%m/%d/%Y %H:%M:%S",
|
"%m/%d/%Y %H:%M:%S",
|
||||||
|
"%m/%d/%Y %H:%M",
|
||||||
"%m/%d/%Y",
|
"%m/%d/%Y",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ Physicians Forms
|
|||||||
|
|
||||||
Forms for doctor rating imports and filtering.
|
Forms for doctor rating imports and filtering.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
|
|
||||||
from apps.organizations.models import Hospital
|
from apps.organizations.models import Hospital
|
||||||
@ -17,16 +18,17 @@ class DoctorRatingImportForm(HospitalFieldMixin, forms.Form):
|
|||||||
- PX Admins: See dropdown with all hospitals
|
- PX Admins: See dropdown with all hospitals
|
||||||
- Others: Hidden field, auto-set to user's hospital
|
- Others: Hidden field, auto-set to user's hospital
|
||||||
"""
|
"""
|
||||||
|
|
||||||
hospital = forms.ModelChoiceField(
|
hospital = forms.ModelChoiceField(
|
||||||
queryset=Hospital.objects.filter(status='active'),
|
queryset=Hospital.objects.filter(status="active"),
|
||||||
label="Hospital",
|
label="Hospital",
|
||||||
help_text="Select the hospital these ratings belong to"
|
help_text="Select the hospital these ratings belong to",
|
||||||
)
|
)
|
||||||
|
|
||||||
csv_file = forms.FileField(
|
csv_file = forms.FileField(
|
||||||
label="CSV File",
|
label="CSV File",
|
||||||
help_text="Upload the Doctor Rating Report CSV file",
|
help_text="Upload the Doctor Rating Report CSV file",
|
||||||
widget=forms.FileInput(attrs={'accept': '.csv'})
|
widget=forms.FileInput(attrs={"accept": ".csv"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
skip_header_rows = forms.IntegerField(
|
skip_header_rows = forms.IntegerField(
|
||||||
@ -34,14 +36,14 @@ class DoctorRatingImportForm(HospitalFieldMixin, forms.Form):
|
|||||||
initial=6,
|
initial=6,
|
||||||
min_value=0,
|
min_value=0,
|
||||||
max_value=20,
|
max_value=20,
|
||||||
help_text="Number of rows to skip before the column headers (Doctor Rating Report typically has 6 header rows)"
|
help_text="Number of rows to skip before the column headers (Doctor Rating Report typically has 6 header rows)",
|
||||||
)
|
)
|
||||||
|
|
||||||
def clean_csv_file(self):
|
def clean_csv_file(self):
|
||||||
csv_file = self.cleaned_data['csv_file']
|
csv_file = self.cleaned_data["csv_file"]
|
||||||
|
|
||||||
# Check file extension
|
# Check file extension
|
||||||
if not csv_file.name.endswith('.csv'):
|
if not csv_file.name.endswith(".csv"):
|
||||||
raise forms.ValidationError("File must be a CSV file (.csv extension)")
|
raise forms.ValidationError("File must be a CSV file (.csv extension)")
|
||||||
|
|
||||||
# Check file size (max 10MB)
|
# Check file size (max 10MB)
|
||||||
@ -51,26 +53,69 @@ class DoctorRatingImportForm(HospitalFieldMixin, forms.Form):
|
|||||||
return csv_file
|
return csv_file
|
||||||
|
|
||||||
|
|
||||||
|
class DoctorRatingFetchForm(HospitalFieldMixin, forms.Form):
|
||||||
|
"""
|
||||||
|
Form for fetching doctor ratings from HIS API by date range.
|
||||||
|
|
||||||
|
Hospital field visibility:
|
||||||
|
- PX Admins: See dropdown with all hospitals
|
||||||
|
- Others: Hidden field, auto-set to user's hospital
|
||||||
|
"""
|
||||||
|
|
||||||
|
hospital = forms.ModelChoiceField(
|
||||||
|
queryset=Hospital.objects.filter(status="active"),
|
||||||
|
label="Hospital",
|
||||||
|
help_text="Select the hospital for tracking the import job",
|
||||||
|
)
|
||||||
|
|
||||||
|
from_date = forms.DateField(
|
||||||
|
label="From Date",
|
||||||
|
widget=forms.DateInput(
|
||||||
|
attrs={
|
||||||
|
"type": "date",
|
||||||
|
"class": "w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy/20 focus:border-navy outline-none transition",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
help_text="Start date for fetching ratings",
|
||||||
|
)
|
||||||
|
|
||||||
|
to_date = forms.DateField(
|
||||||
|
label="To Date",
|
||||||
|
widget=forms.DateInput(
|
||||||
|
attrs={
|
||||||
|
"type": "date",
|
||||||
|
"class": "w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy/20 focus:border-navy outline-none transition",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
help_text="End date for fetching ratings",
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
from_date = cleaned_data.get("from_date")
|
||||||
|
to_date = cleaned_data.get("to_date")
|
||||||
|
|
||||||
|
if from_date and to_date and from_date > to_date:
|
||||||
|
raise forms.ValidationError("From date must be before or equal to to date.")
|
||||||
|
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
class DoctorRatingFilterForm(forms.Form):
|
class DoctorRatingFilterForm(forms.Form):
|
||||||
"""
|
"""
|
||||||
Form for filtering individual doctor ratings.
|
Form for filtering individual doctor ratings.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
hospital = forms.ModelChoiceField(
|
hospital = forms.ModelChoiceField(
|
||||||
queryset=Hospital.objects.filter(status='active'),
|
queryset=Hospital.objects.filter(status="active"), required=False, label="Hospital"
|
||||||
required=False,
|
|
||||||
label="Hospital"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
doctor_id = forms.CharField(
|
doctor_id = forms.CharField(
|
||||||
required=False,
|
required=False, label="Doctor ID", widget=forms.TextInput(attrs={"placeholder": "e.g., 10738"})
|
||||||
label="Doctor ID",
|
|
||||||
widget=forms.TextInput(attrs={'placeholder': 'e.g., 10738'})
|
|
||||||
)
|
)
|
||||||
|
|
||||||
doctor_name = forms.CharField(
|
doctor_name = forms.CharField(
|
||||||
required=False,
|
required=False, label="Doctor Name", widget=forms.TextInput(attrs={"placeholder": "Search by doctor name"})
|
||||||
label="Doctor Name",
|
|
||||||
widget=forms.TextInput(attrs={'placeholder': 'Search by doctor name'})
|
|
||||||
)
|
)
|
||||||
|
|
||||||
rating_min = forms.IntegerField(
|
rating_min = forms.IntegerField(
|
||||||
@ -78,7 +123,7 @@ class DoctorRatingFilterForm(forms.Form):
|
|||||||
min_value=1,
|
min_value=1,
|
||||||
max_value=5,
|
max_value=5,
|
||||||
label="Min Rating",
|
label="Min Rating",
|
||||||
widget=forms.NumberInput(attrs={'placeholder': '1-5'})
|
widget=forms.NumberInput(attrs={"placeholder": "1-5"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
rating_max = forms.IntegerField(
|
rating_max = forms.IntegerField(
|
||||||
@ -86,29 +131,18 @@ class DoctorRatingFilterForm(forms.Form):
|
|||||||
min_value=1,
|
min_value=1,
|
||||||
max_value=5,
|
max_value=5,
|
||||||
label="Max Rating",
|
label="Max Rating",
|
||||||
widget=forms.NumberInput(attrs={'placeholder': '1-5'})
|
widget=forms.NumberInput(attrs={"placeholder": "1-5"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
date_from = forms.DateField(
|
date_from = forms.DateField(required=False, label="From Date", widget=forms.DateInput(attrs={"type": "date"}))
|
||||||
required=False,
|
|
||||||
label="From Date",
|
|
||||||
widget=forms.DateInput(attrs={'type': 'date'})
|
|
||||||
)
|
|
||||||
|
|
||||||
date_to = forms.DateField(
|
date_to = forms.DateField(required=False, label="To Date", widget=forms.DateInput(attrs={"type": "date"}))
|
||||||
required=False,
|
|
||||||
label="To Date",
|
|
||||||
widget=forms.DateInput(attrs={'type': 'date'})
|
|
||||||
)
|
|
||||||
|
|
||||||
source = forms.ChoiceField(
|
source = forms.ChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
label="Source",
|
label="Source",
|
||||||
choices=[('', 'All Sources')] + [
|
choices=[("", "All Sources")]
|
||||||
('his_api', 'HIS API'),
|
+ [("his_api", "HIS API"), ("csv_import", "CSV Import"), ("manual", "Manual Entry")],
|
||||||
('csv_import', 'CSV Import'),
|
|
||||||
('manual', 'Manual Entry')
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, user, *args, **kwargs):
|
def __init__(self, user, *args, **kwargs):
|
||||||
@ -116,9 +150,9 @@ class DoctorRatingFilterForm(forms.Form):
|
|||||||
|
|
||||||
# Filter hospital choices based on user role
|
# Filter hospital choices based on user role
|
||||||
if user.is_px_admin():
|
if user.is_px_admin():
|
||||||
self.fields['hospital'].queryset = Hospital.objects.filter(status='active')
|
self.fields["hospital"].queryset = Hospital.objects.filter(status="active")
|
||||||
elif user.hospital:
|
elif user.hospital:
|
||||||
self.fields['hospital'].queryset = Hospital.objects.filter(id=user.hospital.id)
|
self.fields["hospital"].queryset = Hospital.objects.filter(id=user.hospital.id)
|
||||||
self.fields['hospital'].initial = user.hospital
|
self.fields["hospital"].initial = user.hospital
|
||||||
else:
|
else:
|
||||||
self.fields['hospital'].queryset = Hospital.objects.none()
|
self.fields["hospital"].queryset = Hospital.objects.none()
|
||||||
|
|||||||
@ -21,9 +21,9 @@ from apps.core.services import AuditService
|
|||||||
from apps.organizations.models import Hospital
|
from apps.organizations.models import Hospital
|
||||||
|
|
||||||
from .adapter import DoctorRatingAdapter
|
from .adapter import DoctorRatingAdapter
|
||||||
from .forms import DoctorRatingImportForm
|
from .forms import DoctorRatingFetchForm, DoctorRatingImportForm
|
||||||
from .models import DoctorRatingImportJob, PhysicianIndividualRating
|
from .models import DoctorRatingImportJob, PhysicianIndividualRating
|
||||||
from .tasks import process_doctor_rating_job
|
from .tasks import _fetch_and_process_his_doctor_ratings, process_doctor_rating_job
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -226,6 +226,82 @@ def doctor_rating_import(request):
|
|||||||
return render(request, "physicians/doctor_rating_import.html", context)
|
return render(request, "physicians/doctor_rating_import.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def doctor_rating_fetch(request):
|
||||||
|
"""
|
||||||
|
Fetch doctor ratings from HIS API by date range.
|
||||||
|
|
||||||
|
Runs the fetch synchronously for immediate feedback, then redirects
|
||||||
|
to the job status page (which will already show completed results).
|
||||||
|
"""
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
if not user.is_px_admin() and not user.is_hospital_admin():
|
||||||
|
messages.error(request, "You don't have permission to fetch doctor ratings.")
|
||||||
|
return redirect("physicians:physician_list")
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = DoctorRatingFetchForm(request.POST, request=request)
|
||||||
|
|
||||||
|
if form.is_valid():
|
||||||
|
try:
|
||||||
|
hospital = form.cleaned_data["hospital"]
|
||||||
|
from_date = form.cleaned_data["from_date"]
|
||||||
|
to_date = form.cleaned_data["to_date"]
|
||||||
|
|
||||||
|
date_label = f"{from_date.isoformat()} to {to_date.isoformat()}"
|
||||||
|
|
||||||
|
job = DoctorRatingImportJob.objects.create(
|
||||||
|
name=f"HIS Fetch - {date_label}",
|
||||||
|
status=DoctorRatingImportJob.JobStatus.PENDING,
|
||||||
|
source=DoctorRatingImportJob.JobSource.HIS_API,
|
||||||
|
created_by=user,
|
||||||
|
hospital=hospital,
|
||||||
|
)
|
||||||
|
|
||||||
|
AuditService.log_event(
|
||||||
|
event_type="doctor_rating_his_fetch",
|
||||||
|
description=f"Fetching HIS ratings for {date_label}",
|
||||||
|
user=user,
|
||||||
|
metadata={
|
||||||
|
"job_id": str(job.id),
|
||||||
|
"hospital": hospital.name,
|
||||||
|
"from_date": from_date.isoformat(),
|
||||||
|
"to_date": to_date.isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _fetch_and_process_his_doctor_ratings(str(job.id), from_date.isoformat(), to_date.isoformat())
|
||||||
|
|
||||||
|
if result.get("success"):
|
||||||
|
total = result.get("total_ratings", 0)
|
||||||
|
if total == 0:
|
||||||
|
messages.info(request, f"No ratings found for {date_label}.")
|
||||||
|
else:
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
f"Fetched {total} ratings from HIS: "
|
||||||
|
f"{result.get('success_count', 0)} imported, "
|
||||||
|
f"{result.get('duplicate_count', 0)} duplicates, "
|
||||||
|
f"{result.get('failed_count', 0)} failed.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
messages.error(request, f"HIS fetch failed: {result.get('error', 'Unknown error')}")
|
||||||
|
|
||||||
|
return redirect("physicians:doctor_rating_job_status", job_id=job.id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching HIS ratings: {str(e)}", exc_info=True)
|
||||||
|
messages.error(request, f"Error: {str(e)}")
|
||||||
|
else:
|
||||||
|
form = DoctorRatingFetchForm(request=request)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"form": form,
|
||||||
|
}
|
||||||
|
return render(request, "physicians/doctor_rating_fetch.html", context)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def doctor_rating_review(request):
|
def doctor_rating_review(request):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Management command to import doctor ratings from HIS API.
|
Management command to import doctor ratings from HIS API.
|
||||||
|
|
||||||
This command fetches doctor ratings from the HIS FetchDoctorRatingMAPI1 endpoint
|
This command fetches doctor ratings from the HIS FetchDoctorRatingMAPI endpoint
|
||||||
and imports them into the system. It supports importing for specific months,
|
and imports them into the system. It supports importing for specific months,
|
||||||
multiple months, or full historical data.
|
multiple months, or full historical data.
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ Background tasks for:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@ -249,84 +250,81 @@ def cleanup_old_import_jobs(days: int = 30):
|
|||||||
return {"cleaned_count": count}
|
return {"cleaned_count": count}
|
||||||
|
|
||||||
|
|
||||||
@shared_task(bind=True, max_retries=3, default_retry_delay=300)
|
def _fetch_and_process_his_doctor_ratings(job_id: str, from_date_iso: str, to_date_iso: str) -> Dict:
|
||||||
def fetch_his_doctor_ratings_monthly(self):
|
|
||||||
"""
|
"""
|
||||||
Monthly task to fetch doctor ratings from HIS API.
|
Core logic to fetch and process HIS doctor ratings.
|
||||||
|
|
||||||
Runs on the 1st of each month to fetch the previous month's ratings.
|
Can be called synchronously (from a view) or wrapped in a Celery task.
|
||||||
Example: On March 1st, fetches all ratings from February 1-28/29.
|
|
||||||
|
|
||||||
This task runs at 1:00 AM on the 1st of each month, before the
|
|
||||||
aggregation task which runs at 2:00 AM.
|
|
||||||
"""
|
"""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from calendar import monthrange
|
|
||||||
|
from apps.integrations.services.his_client import HISClient
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Calculate previous month
|
job = DoctorRatingImportJob.objects.get(id=job_id)
|
||||||
now = timezone.now()
|
except DoctorRatingImportJob.DoesNotExist:
|
||||||
if now.month == 1:
|
logger.error(f"Doctor rating import job {job_id} not found")
|
||||||
target_year = now.year - 1
|
return {"error": "Job not found"}
|
||||||
target_month = 12
|
|
||||||
else:
|
|
||||||
target_year = now.year
|
|
||||||
target_month = now.month - 1
|
|
||||||
|
|
||||||
month_label = f"{target_year}-{target_month:02d}"
|
try:
|
||||||
logger.info(f"Starting monthly HIS doctor rating fetch for {month_label}")
|
from_date = datetime.fromisoformat(from_date_iso)
|
||||||
|
to_date = datetime.fromisoformat(to_date_iso)
|
||||||
|
if to_date.hour == 0 and to_date.minute == 0 and to_date.second == 0:
|
||||||
|
to_date = to_date.replace(hour=23, minute=59, second=59)
|
||||||
|
|
||||||
# Calculate date range for the month
|
date_label = f"{from_date_iso} to {to_date_iso}"
|
||||||
from_date = datetime(target_year, target_month, 1)
|
logger.info(f"Starting HIS doctor rating fetch for {date_label}")
|
||||||
last_day = monthrange(target_year, target_month)[1]
|
|
||||||
to_date = datetime(target_year, target_month, last_day, 23, 59, 59)
|
|
||||||
|
|
||||||
# Initialize HIS client
|
job.status = DoctorRatingImportJob.JobStatus.PROCESSING
|
||||||
from apps.integrations.services.his_client import HISClient
|
job.started_at = timezone.now()
|
||||||
|
job.save()
|
||||||
|
|
||||||
client = HISClient()
|
client = HISClient()
|
||||||
|
|
||||||
# Fetch ratings from HIS
|
|
||||||
his_data = client.fetch_doctor_ratings(from_date, to_date)
|
his_data = client.fetch_doctor_ratings(from_date, to_date)
|
||||||
|
|
||||||
if not his_data:
|
if not his_data:
|
||||||
|
job.status = DoctorRatingImportJob.JobStatus.FAILED
|
||||||
|
job.error_message = "Failed to fetch data from HIS API"
|
||||||
|
job.completed_at = timezone.now()
|
||||||
|
job.save()
|
||||||
logger.error("Failed to fetch data from HIS API")
|
logger.error("Failed to fetch data from HIS API")
|
||||||
return {"success": False, "error": "Failed to fetch data from HIS API", "month": month_label}
|
return {"success": False, "error": "Failed to fetch data from HIS API", "date_range": date_label}
|
||||||
|
|
||||||
if his_data.get("Code") != 200:
|
if his_data.get("Code") != 200:
|
||||||
error_msg = his_data.get("Message", "Unknown error")
|
error_msg = his_data.get("Message", "Unknown error")
|
||||||
|
job.status = DoctorRatingImportJob.JobStatus.FAILED
|
||||||
|
job.error_message = f"HIS API error: {error_msg}"
|
||||||
|
job.completed_at = timezone.now()
|
||||||
|
job.save()
|
||||||
logger.error(f"HIS API error: {error_msg}")
|
logger.error(f"HIS API error: {error_msg}")
|
||||||
return {"success": False, "error": f"HIS API error: {error_msg}", "month": month_label}
|
return {"success": False, "error": f"HIS API error: {error_msg}", "date_range": date_label}
|
||||||
|
|
||||||
ratings_list = his_data.get("FetchDoctorRatingMAPI1List", [])
|
ratings_list = his_data.get("FetchDoctorRatingMAPI1List", [])
|
||||||
|
|
||||||
if not ratings_list:
|
if not ratings_list:
|
||||||
logger.info(f"No ratings found for {month_label}")
|
logger.info(
|
||||||
|
f"HIS returned no ratings. Response keys: {list(his_data.keys())}, Code: {his_data.get('Code')}, Message: {his_data.get('Message')}"
|
||||||
|
)
|
||||||
|
logger.info(f"Full HIS response: {his_data}")
|
||||||
|
job.status = DoctorRatingImportJob.JobStatus.COMPLETED
|
||||||
|
job.total_records = 0
|
||||||
|
job.processed_count = 0
|
||||||
|
job.completed_at = timezone.now()
|
||||||
|
job.results = {"stats": {"total": 0, "success": 0, "failed": 0, "duplicates": 0, "staff_matched": 0}}
|
||||||
|
job.save()
|
||||||
|
logger.info(f"No ratings found for {date_label}")
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"month": month_label,
|
"date_range": date_label,
|
||||||
"total_ratings": 0,
|
"total_ratings": 0,
|
||||||
"message": "No ratings found for this period",
|
"message": "No ratings found for this period",
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(f"Fetched {len(ratings_list)} ratings from HIS for {month_label}")
|
logger.info(f"Fetched {len(ratings_list)} ratings from HIS for {date_label}")
|
||||||
|
|
||||||
# Create import job for tracking
|
job.total_records = len(ratings_list)
|
||||||
first_hospital = Hospital.objects.first()
|
job.save()
|
||||||
if first_hospital:
|
|
||||||
job = DoctorRatingImportJob.objects.create(
|
|
||||||
name=f"Monthly HIS Import - {month_label}",
|
|
||||||
status=DoctorRatingImportJob.JobStatus.PROCESSING,
|
|
||||||
source=DoctorRatingImportJob.JobSource.HIS_API,
|
|
||||||
hospital=first_hospital,
|
|
||||||
total_records=len(ratings_list),
|
|
||||||
started_at=timezone.now(),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
job = None
|
|
||||||
logger.warning("No hospitals found, creating ratings without import job")
|
|
||||||
|
|
||||||
# Process ratings
|
|
||||||
stats = {
|
stats = {
|
||||||
"total": len(ratings_list),
|
"total": len(ratings_list),
|
||||||
"success": 0,
|
"success": 0,
|
||||||
@ -337,7 +335,6 @@ def fetch_his_doctor_ratings_monthly(self):
|
|||||||
|
|
||||||
for idx, rating_data in enumerate(ratings_list, 1):
|
for idx, rating_data in enumerate(ratings_list, 1):
|
||||||
try:
|
try:
|
||||||
# Find hospital by name
|
|
||||||
hospital_name = rating_data.get("HospitalName", "")
|
hospital_name = rating_data.get("HospitalName", "")
|
||||||
hospital = Hospital.objects.filter(name__iexact=hospital_name).first()
|
hospital = Hospital.objects.filter(name__iexact=hospital_name).first()
|
||||||
|
|
||||||
@ -349,7 +346,6 @@ def fetch_his_doctor_ratings_monthly(self):
|
|||||||
logger.warning(f"Hospital not found: {hospital_name}")
|
logger.warning(f"Hospital not found: {hospital_name}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Process the rating
|
|
||||||
result = DoctorRatingAdapter.process_his_rating_record(rating_data, hospital)
|
result = DoctorRatingAdapter.process_his_rating_record(rating_data, hospital)
|
||||||
|
|
||||||
if result["is_duplicate"]:
|
if result["is_duplicate"]:
|
||||||
@ -362,8 +358,7 @@ def fetch_his_doctor_ratings_monthly(self):
|
|||||||
stats["failed"] += 1
|
stats["failed"] += 1
|
||||||
logger.warning(f"Failed to process rating: {result.get('message')}")
|
logger.warning(f"Failed to process rating: {result.get('message')}")
|
||||||
|
|
||||||
# Update job progress every 100 records
|
if idx % 100 == 0:
|
||||||
if job and idx % 100 == 0:
|
|
||||||
job.processed_count = idx
|
job.processed_count = idx
|
||||||
job.success_count = stats["success"]
|
job.success_count = stats["success"]
|
||||||
job.failed_count = stats["failed"]
|
job.failed_count = stats["failed"]
|
||||||
@ -374,31 +369,29 @@ def fetch_his_doctor_ratings_monthly(self):
|
|||||||
stats["failed"] += 1
|
stats["failed"] += 1
|
||||||
logger.error(f"Error processing rating {idx}: {e}", exc_info=True)
|
logger.error(f"Error processing rating {idx}: {e}", exc_info=True)
|
||||||
|
|
||||||
# Finalize job
|
job.processed_count = stats["total"]
|
||||||
if job:
|
job.success_count = stats["success"]
|
||||||
job.processed_count = stats["total"]
|
job.failed_count = stats["failed"]
|
||||||
job.success_count = stats["success"]
|
job.completed_at = timezone.now()
|
||||||
job.failed_count = stats["failed"]
|
|
||||||
job.completed_at = timezone.now()
|
|
||||||
|
|
||||||
if stats["failed"] == 0:
|
if stats["failed"] == 0:
|
||||||
job.status = DoctorRatingImportJob.JobStatus.COMPLETED
|
job.status = DoctorRatingImportJob.JobStatus.COMPLETED
|
||||||
elif stats["success"] == 0:
|
elif stats["success"] == 0:
|
||||||
job.status = DoctorRatingImportJob.JobStatus.FAILED
|
job.status = DoctorRatingImportJob.JobStatus.FAILED
|
||||||
else:
|
else:
|
||||||
job.status = DoctorRatingImportJob.JobStatus.PARTIAL
|
job.status = DoctorRatingImportJob.JobStatus.PARTIAL
|
||||||
|
|
||||||
job.results = {"stats": stats}
|
job.results = {"stats": stats}
|
||||||
job.save()
|
job.save()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Completed monthly HIS doctor rating fetch for {month_label}: "
|
f"Completed HIS doctor rating fetch for {date_label}: "
|
||||||
f"{stats['success']} success, {stats['failed']} failed, {stats['duplicates']} duplicates"
|
f"{stats['success']} success, {stats['failed']} failed, {stats['duplicates']} duplicates"
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"month": month_label,
|
"date_range": date_label,
|
||||||
"total_ratings": stats["total"],
|
"total_ratings": stats["total"],
|
||||||
"success_count": stats["success"],
|
"success_count": stats["success"],
|
||||||
"failed_count": stats["failed"],
|
"failed_count": stats["failed"],
|
||||||
@ -407,6 +400,117 @@ def fetch_his_doctor_ratings_monthly(self):
|
|||||||
}
|
}
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(f"Error in monthly HIS doctor rating fetch: {exc}", exc_info=True)
|
logger.error(f"Error in HIS doctor rating fetch: {exc}", exc_info=True)
|
||||||
# Retry the task
|
try:
|
||||||
|
job.status = DoctorRatingImportJob.JobStatus.FAILED
|
||||||
|
job.error_message = str(exc)
|
||||||
|
job.completed_at = timezone.now()
|
||||||
|
job.save()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, max_retries=3, default_retry_delay=300)
|
||||||
|
def fetch_his_doctor_ratings(self, job_id: str, from_date_iso: str, to_date_iso: str):
|
||||||
|
"""
|
||||||
|
Celery task wrapper for fetching and processing HIS doctor ratings.
|
||||||
|
|
||||||
|
Used by the monthly scheduled task. For manual UI fetches, the view
|
||||||
|
calls _fetch_and_process_his_doctor_ratings() directly.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return _fetch_and_process_his_doctor_ratings(job_id, from_date_iso, to_date_iso)
|
||||||
|
except Exception as exc:
|
||||||
|
raise self.retry(exc=exc)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, max_retries=3, default_retry_delay=300)
|
||||||
|
def fetch_his_doctor_ratings_monthly(self):
|
||||||
|
"""
|
||||||
|
Monthly task to fetch doctor ratings from HIS API.
|
||||||
|
|
||||||
|
Runs on the 1st of each month to fetch the previous month's ratings.
|
||||||
|
Example: On March 1st, fetches all ratings from February 1-28/29.
|
||||||
|
|
||||||
|
This task runs at 1:00 AM on the 1st of each month, before the
|
||||||
|
aggregation task which runs at 2:00 AM.
|
||||||
|
"""
|
||||||
|
from calendar import monthrange
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
try:
|
||||||
|
now = timezone.now()
|
||||||
|
if now.month == 1:
|
||||||
|
target_year = now.year - 1
|
||||||
|
target_month = 12
|
||||||
|
else:
|
||||||
|
target_year = now.year
|
||||||
|
target_month = now.month - 1
|
||||||
|
|
||||||
|
month_label = f"{target_year}-{target_month:02d}"
|
||||||
|
logger.info(f"Starting monthly HIS doctor rating fetch for {month_label}")
|
||||||
|
|
||||||
|
from_date = datetime(target_year, target_month, 1)
|
||||||
|
last_day = monthrange(target_year, target_month)[1]
|
||||||
|
to_date = datetime(target_year, target_month, last_day)
|
||||||
|
|
||||||
|
first_hospital = Hospital.objects.first()
|
||||||
|
if not first_hospital:
|
||||||
|
logger.error("No hospitals found")
|
||||||
|
return {"success": False, "error": "No hospitals found"}
|
||||||
|
|
||||||
|
job = DoctorRatingImportJob.objects.create(
|
||||||
|
name=f"Monthly HIS Import - {month_label}",
|
||||||
|
status=DoctorRatingImportJob.JobStatus.PENDING,
|
||||||
|
source=DoctorRatingImportJob.JobSource.HIS_API,
|
||||||
|
hospital=first_hospital,
|
||||||
|
)
|
||||||
|
|
||||||
|
fetch_his_doctor_ratings.delay(str(job.id), from_date.date().isoformat(), to_date.date().isoformat())
|
||||||
|
|
||||||
|
return {"success": True, "job_id": str(job.id), "month": month_label}
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Error in monthly HIS doctor rating fetch: {exc}", exc_info=True)
|
||||||
|
raise self.retry(exc=exc)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, max_retries=3, default_retry_delay=300)
|
||||||
|
def fetch_his_doctor_ratings_daily(self):
|
||||||
|
"""
|
||||||
|
Daily task to fetch doctor ratings from HIS API for yesterday.
|
||||||
|
|
||||||
|
Query window: FromDate=yesterday 00:00:00, ToDate=yesterday 23:59:59
|
||||||
|
e.g. FromDate=08-Apr-2026 00:00:00&ToDate=08-Apr-2026 23:59:59
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
try:
|
||||||
|
yesterday = timezone.now().date() - timedelta(days=1)
|
||||||
|
|
||||||
|
from_date = datetime.combine(yesterday, datetime.min.time())
|
||||||
|
to_date = datetime(yesterday.year, yesterday.month, yesterday.day, 23, 59, 59)
|
||||||
|
|
||||||
|
date_label = f"{from_date.date().isoformat()} to {to_date.date().isoformat()}"
|
||||||
|
logger.info(f"Starting daily HIS doctor rating fetch for {date_label}")
|
||||||
|
|
||||||
|
first_hospital = Hospital.objects.first()
|
||||||
|
if not first_hospital:
|
||||||
|
logger.error("No hospitals found")
|
||||||
|
return {"success": False, "error": "No hospitals found"}
|
||||||
|
|
||||||
|
job = DoctorRatingImportJob.objects.create(
|
||||||
|
name=f"Daily HIS Import - {yesterday.isoformat()}",
|
||||||
|
status=DoctorRatingImportJob.JobStatus.PENDING,
|
||||||
|
source=DoctorRatingImportJob.JobSource.HIS_API,
|
||||||
|
hospital=first_hospital,
|
||||||
|
)
|
||||||
|
|
||||||
|
fetch_his_doctor_ratings.delay(str(job.id), from_date.isoformat(), to_date.isoformat())
|
||||||
|
|
||||||
|
return {"success": True, "job_id": str(job.id), "date": yesterday.isoformat()}
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Error in daily HIS doctor rating fetch: {exc}", exc_info=True)
|
||||||
raise self.retry(exc=exc)
|
raise self.retry(exc=exc)
|
||||||
|
|||||||
@ -34,6 +34,8 @@ urlpatterns = [
|
|||||||
path("individual-ratings/", import_views.individual_ratings_list, name="individual_ratings_list"),
|
path("individual-ratings/", import_views.individual_ratings_list, name="individual_ratings_list"),
|
||||||
# Doctor Rating Import (CSV Upload)
|
# Doctor Rating Import (CSV Upload)
|
||||||
path("import/", import_views.doctor_rating_import, name="doctor_rating_import"),
|
path("import/", import_views.doctor_rating_import, name="doctor_rating_import"),
|
||||||
|
# Doctor Rating Fetch (HIS API)
|
||||||
|
path("fetch/", import_views.doctor_rating_fetch, name="doctor_rating_fetch"),
|
||||||
path("import/review/", import_views.doctor_rating_review, name="doctor_rating_review"),
|
path("import/review/", import_views.doctor_rating_review, name="doctor_rating_review"),
|
||||||
path("import/jobs/", import_views.doctor_rating_job_list, name="doctor_rating_job_list"),
|
path("import/jobs/", import_views.doctor_rating_job_list, name="doctor_rating_job_list"),
|
||||||
path("import/jobs/<uuid:job_id>/", import_views.doctor_rating_job_status, name="doctor_rating_job_status"),
|
path("import/jobs/<uuid:job_id>/", import_views.doctor_rating_job_status, name="doctor_rating_job_status"),
|
||||||
|
|||||||
@ -99,17 +99,31 @@ def source_detail(request, pk):
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
cutoff = timezone.now() - timedelta(days=30)
|
cutoff = timezone.now() - timedelta(days=30)
|
||||||
|
recent_usage = usage_stats_queryset.filter(created_at__gte=cutoff)
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
|
||||||
|
complaint_ct = ContentType.objects.get(app_label="complaints", model="complaint")
|
||||||
|
inquiry_ct = ContentType.objects.get(app_label="complaints", model="inquiry")
|
||||||
usage_stats = {
|
usage_stats = {
|
||||||
"total": usage_stats_queryset.count(),
|
"total": usage_stats_queryset.count(),
|
||||||
"recent": usage_stats_queryset.filter(created_at__gte=cutoff).count(),
|
"recent": recent_usage.count(),
|
||||||
|
"complaints": recent_usage.filter(content_type=complaint_ct).count(),
|
||||||
|
"inquiries": recent_usage.filter(content_type=inquiry_ct).count(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
source_complaints = source.complaints.select_related("hospital", "department").order_by("-created_at")[:50]
|
||||||
|
source_inquiries = source.inquiries.select_related("hospital", "department").order_by("-created_at")[:50]
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"source": source,
|
"source": source,
|
||||||
"usage_records": usage_records,
|
"usage_records": usage_records,
|
||||||
"source_users": source_users,
|
"source_users": source_users,
|
||||||
"available_users": available_users,
|
"available_users": available_users,
|
||||||
"usage_stats": usage_stats,
|
"usage_stats": usage_stats,
|
||||||
|
"source_complaints": source_complaints,
|
||||||
|
"source_inquiries": source_inquiries,
|
||||||
|
"complaints_count": source.complaints.count(),
|
||||||
|
"inquiries_count": source.inquiries.count(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, "px_sources/source_detail.html", context)
|
return render(request, "px_sources/source_detail.html", context)
|
||||||
|
|||||||
@ -214,7 +214,11 @@ def survey_instance_detail(request, pk):
|
|||||||
@login_required
|
@login_required
|
||||||
def survey_template_list(request):
|
def survey_template_list(request):
|
||||||
"""Survey templates list view"""
|
"""Survey templates list view"""
|
||||||
queryset = SurveyTemplate.objects.select_related("hospital").prefetch_related("questions")
|
queryset = (
|
||||||
|
SurveyTemplate.objects.select_related("hospital")
|
||||||
|
.prefetch_related("questions")
|
||||||
|
.annotate(questions_count=Count("questions"))
|
||||||
|
)
|
||||||
|
|
||||||
user = request.user
|
user = request.user
|
||||||
if user.is_px_admin():
|
if user.is_px_admin():
|
||||||
|
|||||||
@ -29,17 +29,12 @@ app.autodiscover_tasks()
|
|||||||
|
|
||||||
# Celery Beat schedule for periodic tasks
|
# Celery Beat schedule for periodic tasks
|
||||||
app.conf.beat_schedule = {
|
app.conf.beat_schedule = {
|
||||||
# Process unprocessed integration events every 1 minute
|
# Fetch patient data from HIS every 25 minutes
|
||||||
"process-integration-events": {
|
|
||||||
"task": "apps.integrations.tasks.process_pending_events",
|
|
||||||
"schedule": crontab(minute="*/1"),
|
|
||||||
},
|
|
||||||
# Fetch patient data from HIS every 5 minutes
|
|
||||||
"fetch-his-surveys": {
|
"fetch-his-surveys": {
|
||||||
"task": "apps.integrations.tasks.fetch_his_surveys",
|
"task": "apps.integrations.tasks.fetch_his_surveys",
|
||||||
"schedule": crontab(minute="*/5"),
|
"schedule": crontab(minute="*/25"),
|
||||||
"options": {
|
"options": {
|
||||||
"expires": 300,
|
"expires": 1500,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
# TEST TASK - Fetch from JSON file (uncomment for testing, remove when done)
|
# TEST TASK - Fetch from JSON file (uncomment for testing, remove when done)
|
||||||
@ -117,6 +112,11 @@ app.conf.beat_schedule = {
|
|||||||
"task": "apps.physicians.tasks.fetch_his_doctor_ratings_monthly",
|
"task": "apps.physicians.tasks.fetch_his_doctor_ratings_monthly",
|
||||||
"schedule": crontab(hour=1, minute=0, day_of_month=1),
|
"schedule": crontab(hour=1, minute=0, day_of_month=1),
|
||||||
},
|
},
|
||||||
|
# Fetch doctor ratings from HIS daily at 1:30 AM (yesterday's ratings)
|
||||||
|
"fetch-his-doctor-ratings-daily": {
|
||||||
|
"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 monthly ratings on the 1st of each month at 2 AM
|
||||||
"calculate-physician-ratings": {
|
"calculate-physician-ratings": {
|
||||||
"task": "apps.physicians.tasks.calculate_monthly_ratings",
|
"task": "apps.physicians.tasks.calculate_monthly_ratings",
|
||||||
@ -183,10 +183,10 @@ app.conf.beat_schedule = {
|
|||||||
"task": "apps.surveys.tasks.process_survey_text_analysis",
|
"task": "apps.surveys.tasks.process_survey_text_analysis",
|
||||||
"schedule": crontab(minute="*/30"),
|
"schedule": crontab(minute="*/30"),
|
||||||
},
|
},
|
||||||
# Pre-compute analytics dashboard cache every 5 minutes
|
# Pre-compute analytics dashboard cache daily at 3 AM
|
||||||
"precompute-analytics-cache": {
|
"precompute-analytics-cache": {
|
||||||
"task": "apps.analytics.tasks.precompute_dashboard_cache_task",
|
"task": "apps.analytics.tasks.precompute_dashboard_cache_task",
|
||||||
"schedule": crontab(minute="*/5"),
|
"schedule": crontab(hour=3, minute=0),
|
||||||
},
|
},
|
||||||
# Generate AI executive summary daily at 6 AM
|
# Generate AI executive summary daily at 6 AM
|
||||||
"generate-daily-executive-summary": {
|
"generate-daily-executive-summary": {
|
||||||
|
|||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
131
requirements.txt
131
requirements.txt
@ -1,58 +1,141 @@
|
|||||||
httpx==0.28.1
|
aiohappyeyeballs==2.6.1
|
||||||
|
aiohttp==3.13.3
|
||||||
|
aiohttp-retry==2.9.1
|
||||||
|
aiosignal==1.4.0
|
||||||
amqp==5.3.1
|
amqp==5.3.1
|
||||||
|
annotated-types==0.7.0
|
||||||
|
anyio==4.12.0
|
||||||
asgiref==3.11.0
|
asgiref==3.11.0
|
||||||
|
attrs==25.4.0
|
||||||
billiard==4.2.4
|
billiard==4.2.4
|
||||||
|
brotli==1.2.0
|
||||||
|
cachetools==6.2.4
|
||||||
celery==5.6.2
|
celery==5.6.2
|
||||||
certifi==2026.1.4
|
certifi==2026.1.4
|
||||||
|
cffi==2.0.0
|
||||||
charset-normalizer==3.4.4
|
charset-normalizer==3.4.4
|
||||||
click==8.3.1
|
click==8.3.1
|
||||||
click-didyoumean==0.3.1
|
click-didyoumean==0.3.1
|
||||||
click-plugins==1.1.1.2
|
click-plugins==1.1.1.2
|
||||||
click-repl==0.3.0
|
click-repl==0.3.0
|
||||||
cron_descriptor==2.0.6
|
cron-descriptor==1.4.5
|
||||||
Django==5.2.10
|
cssselect2==0.8.0
|
||||||
django-celery-beat==2.8.1
|
distro==1.9.0
|
||||||
django-crontab==0.7.1
|
django==6.0.1
|
||||||
|
django-celery-beat==2.9.0
|
||||||
|
django-environ==0.12.0
|
||||||
django-extensions==4.1
|
django-extensions==4.1
|
||||||
|
django-filter==25.1
|
||||||
|
django-stubs==5.2.8
|
||||||
|
django-stubs-ext==5.2.8
|
||||||
django-timezone-field==7.2.1
|
django-timezone-field==7.2.1
|
||||||
|
djangorestframework==3.16.1
|
||||||
|
djangorestframework-simplejwt==5.5.1
|
||||||
|
djangorestframework-stubs==3.16.6
|
||||||
|
drf-spectacular==0.29.0
|
||||||
|
et-xmlfile==2.0.0
|
||||||
|
fastuuid==0.14.0
|
||||||
|
filelock==3.20.2
|
||||||
|
fonttools==4.61.1
|
||||||
|
frozenlist==1.8.0
|
||||||
|
fsspec==2025.12.0
|
||||||
google-api-core==2.29.0
|
google-api-core==2.29.0
|
||||||
google-api-python-client==2.188.0
|
google-api-python-client==2.187.0
|
||||||
google-auth==2.47.0
|
google-auth==2.41.1
|
||||||
google-auth-httplib2==0.3.0
|
google-auth-httplib2==0.3.0
|
||||||
google-auth-oauthlib==1.2.4
|
google-auth-oauthlib==1.2.3
|
||||||
googleapis-common-protos==1.72.0
|
googleapis-common-protos==1.72.0
|
||||||
httplib2==0.31.2
|
grpcio==1.67.1
|
||||||
|
gunicorn==23.0.0
|
||||||
|
h11==0.16.0
|
||||||
|
hf-xet==1.2.0
|
||||||
|
httpcore==1.0.9
|
||||||
|
httplib2==0.31.0
|
||||||
|
httpx==0.28.1
|
||||||
|
huggingface-hub==1.2.3
|
||||||
idna==3.11
|
idna==3.11
|
||||||
|
importlib-metadata==8.7.1
|
||||||
|
inflection==0.5.1
|
||||||
|
jinja2==3.1.6
|
||||||
|
jiter==0.12.0
|
||||||
|
jsonschema==4.25.1
|
||||||
|
jsonschema-specifications==2025.9.1
|
||||||
kombu==5.6.2
|
kombu==5.6.2
|
||||||
|
litellm==1.80.11
|
||||||
|
markdown-it-py==4.0.0
|
||||||
|
markupsafe==3.0.3
|
||||||
|
mdurl==0.1.2
|
||||||
|
multidict==6.7.0
|
||||||
|
numpy==2.4.3
|
||||||
oauthlib==3.3.1
|
oauthlib==3.3.1
|
||||||
packaging==26.0
|
openai==2.14.0
|
||||||
prompt_toolkit==3.0.52
|
openpyxl==3.1.5
|
||||||
|
packaging==25.0
|
||||||
|
pandas==3.0.1
|
||||||
|
pillow==12.1.0
|
||||||
|
pip==24.0
|
||||||
|
polib==1.2.0
|
||||||
|
prompt-toolkit==3.0.52
|
||||||
|
propcache==0.4.1
|
||||||
proto-plus==1.27.0
|
proto-plus==1.27.0
|
||||||
protobuf==6.33.4
|
protobuf==6.33.3
|
||||||
pyasn1==0.6.2
|
psycopg2-binary==2.9.11
|
||||||
pyasn1_modules==0.4.2
|
-e file:///home/ismail/projects/HH
|
||||||
pyparsing==3.3.2
|
pyasn1==0.6.1
|
||||||
|
pyasn1-modules==0.4.2
|
||||||
|
pycparser==2.23
|
||||||
|
pydantic==2.12.5
|
||||||
|
pydantic-core==2.41.5
|
||||||
|
pydyf==0.12.1
|
||||||
|
pygments==2.19.2
|
||||||
|
pyjwt==2.10.1
|
||||||
|
pyparsing==3.3.1
|
||||||
|
pyphen==0.17.2
|
||||||
python-crontab==3.3.0
|
python-crontab==3.3.0
|
||||||
python-dateutil==2.9.0.post0
|
python-dateutil==2.9.0.post0
|
||||||
|
python-dotenv==1.2.1
|
||||||
|
pytz==2025.2
|
||||||
|
pyyaml==6.0.3
|
||||||
redis==7.1.0
|
redis==7.1.0
|
||||||
|
referencing==0.37.0
|
||||||
|
regex==2025.11.3
|
||||||
|
reportlab==4.4.7
|
||||||
requests==2.32.5
|
requests==2.32.5
|
||||||
requests-oauthlib==2.0.0
|
requests-oauthlib==2.0.0
|
||||||
|
rich==14.2.0
|
||||||
|
rpds-py==0.30.0
|
||||||
rsa==4.9.1
|
rsa==4.9.1
|
||||||
|
shellingham==1.5.4
|
||||||
six==1.17.0
|
six==1.17.0
|
||||||
|
sniffio==1.3.1
|
||||||
sqlparse==0.5.5
|
sqlparse==0.5.5
|
||||||
stack-data==0.6.3
|
tiktoken==0.12.0
|
||||||
traitlets==5.14.3
|
tinycss2==1.5.1
|
||||||
trio==0.32.0
|
tinyhtml5==2.0.0
|
||||||
trio-websocket==0.12.2
|
tokenizers==0.22.2
|
||||||
|
tqdm==4.67.1
|
||||||
tweepy==4.16.0
|
tweepy==4.16.0
|
||||||
twilio==9.10.3
|
twilio==9.10.3
|
||||||
types-PyYAML==6.0.12.20250915
|
typer-slim==0.21.0
|
||||||
|
types-pyyaml==6.0.12.20250915
|
||||||
types-requests==2.32.4.20250913
|
types-requests==2.32.4.20250913
|
||||||
typing_extensions==4.15.0
|
typing-extensions==4.15.0
|
||||||
|
typing-inspection==0.4.2
|
||||||
tzdata==2025.3
|
tzdata==2025.3
|
||||||
tzlocal==5.3.1
|
tzlocal==5.3.1
|
||||||
|
ua-parser==1.0.1
|
||||||
|
ua-parser-builtins==202601
|
||||||
|
unidecode==1.4.0
|
||||||
uritemplate==4.2.0
|
uritemplate==4.2.0
|
||||||
urllib3==2.6.3
|
urllib3==2.6.2
|
||||||
|
user-agents==2.2.0
|
||||||
vine==5.1.0
|
vine==5.1.0
|
||||||
wcwidth==0.3.1
|
watchdog==6.0.0
|
||||||
rich==13.9.4
|
wcwidth==0.2.14
|
||||||
|
weasyprint==67.0
|
||||||
|
webencodings==0.5.1
|
||||||
|
whitenoise==6.11.0
|
||||||
|
xlrd==2.0.2
|
||||||
|
yarl==1.22.0
|
||||||
|
zipp==3.23.0
|
||||||
|
zopfli==0.4.0
|
||||||
|
|||||||
@ -2,137 +2,48 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block title %}{% trans "Welcome to PX360 - Al Hammadi Hospital" %}{% endblock %}
|
{% block title %}{% trans "Welcome to PX360 - Al Hammadi Hospital" %}{% endblock %}
|
||||||
|
|
||||||
{% block preheader %}{% trans "You have been invited to join PX360. Complete your account setup." %}{% endblock %}
|
{% block preheader %}{% trans "You have been invited to join PX360. Complete your account setup." %}{% endblock %}
|
||||||
|
|
||||||
{% block hero_title %}{% trans "Welcome to PX360!" %}{% endblock %}
|
|
||||||
|
|
||||||
{% block hero_subtitle %}{% trans "Your comprehensive Patient Experience management platform" %}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Greeting -->
|
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">{% trans "Welcome to PX360!" %}</h1>
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td style="padding-bottom: 20px;">
|
|
||||||
<p style="margin: 0; font-size: 16px; color: #1e293b; line-height: 1.6;">
|
|
||||||
{% blocktrans with name=user.first_name|default:user.email %}Hello <strong>{{ name }}</strong>,{% endblocktrans %}
|
|
||||||
</p>
|
|
||||||
<p style="margin: 15px 0 0 0; font-size: 16px; color: #64748b; line-height: 1.6;">
|
|
||||||
{% trans "You have been invited to join PX360, our comprehensive Patient Experience management platform. To complete your account setup, please click the button below." %}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- What You'll Do -->
|
<p style="margin: 0 0 15px; font-size: 16px; color: #1e293b; line-height: 1.6;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-top: 25px;">
|
{% blocktrans with name=user.first_name|default:user.email %}Hello <strong>{{ name }}</strong>,{% endblocktrans %}
|
||||||
<tr>
|
</p>
|
||||||
<td>
|
<p style="margin: 0 0 20px; font-size: 16px; color: #475569; line-height: 1.6;">
|
||||||
<h3 style="margin: 0 0 15px 0; font-size: 18px; font-weight: 600; color: #005696;">
|
{% trans "You have been invited to join PX360, our comprehensive Patient Experience management platform. To complete your account setup, please click the button below." %}
|
||||||
{% trans "During the onboarding process, you will:" %}
|
</p>
|
||||||
</h3>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<!-- Item 1 -->
|
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;">
|
|
||||||
<tr>
|
|
||||||
<td width="40" valign="top" style="padding-right: 10px;">
|
|
||||||
<span style="display: inline-block; width: 32px; height: 32px; background-color: #eef6fb; border-radius: 50%; text-align: center; line-height: 32px; font-size: 18px;">✓</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
|
|
||||||
{% trans "Learn about PX360 features and your role responsibilities" %}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- Item 2 -->
|
<!-- Onboarding Steps -->
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;">
|
<div style="margin-bottom: 25px;">
|
||||||
<tr>
|
<p style="margin: 0 0 12px; font-size: 15px; font-weight: 600; color: #005696;">{% trans "During onboarding, you will:" %}</p>
|
||||||
<td width="40" valign="top" style="padding-right: 10px;">
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
<span style="display: inline-block; width: 32px; height: 32px; background-color: #eef6fb; border-radius: 50%; text-align: center; line-height: 32px; font-size: 18px;">✓</span>
|
<tr><td style="padding: 6px 0; font-size: 15px; color: #475569;">✓ {% trans "Learn about PX360 features and your role" %}</td></tr>
|
||||||
</td>
|
<tr><td style="padding: 6px 0; font-size: 15px; color: #475569;">✓ {% trans "Set up your profile and preferences" %}</td></tr>
|
||||||
<td>
|
<tr><td style="padding: 6px 0; font-size: 15px; color: #475569;">✓ {% trans "Complete required training" %}</td></tr>
|
||||||
<p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
|
<tr><td style="padding: 6px 0; font-size: 15px; color: #475569;">✓ {% trans "Activate your account" %}</td></tr>
|
||||||
{% trans "Set up your profile and preferences" %}
|
</table>
|
||||||
</p>
|
</div>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- Item 3 -->
|
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;">
|
|
||||||
<tr>
|
|
||||||
<td width="40" valign="top" style="padding-right: 10px;">
|
|
||||||
<span style="display: inline-block; width: 32px; height: 32px; background-color: #eef6fb; border-radius: 50%; text-align: center; line-height: 32px; font-size: 18px;">✓</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
|
|
||||||
{% trans "Complete required training materials" %}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- Item 4 -->
|
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td width="40" valign="top" style="padding-right: 10px;">
|
|
||||||
<span style="display: inline-block; width: 32px; height: 32px; background-color: #eef6fb; border-radius: 50%; text-align: center; line-height: 32px; font-size: 18px;">✓</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
|
|
||||||
{% trans "Activate your account and start using PX360" %}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- CTA Button -->
|
<!-- CTA Button -->
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-top: 30px;">
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
|
<a href="{{ invitation_url }}"
|
||||||
<tr>
|
style="display: inline-block; padding: 12px 32px; font-size: 16px; font-weight: 600; color: #ffffff; text-decoration: none; border-radius: 6px; background-color: #005696;">
|
||||||
<td align="center" style="border-radius: 6px;" bgcolor="#005696">
|
{% trans "Complete Account Setup" %}
|
||||||
<a href="{{ invitation_url }}" target="_blank"
|
</a>
|
||||||
style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 600; color: #ffffff; text-decoration: none; border-radius: 6px; background-color: #005696;">
|
|
||||||
{% trans "Complete Account Setup" %}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Security Notice -->
|
<div style="padding: 15px; background-color: #fef3c7; border-radius: 6px; margin-top: 20px;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-top: 30px;">
|
<p style="margin: 0; font-size: 14px; color: #92400e; line-height: 1.5;">
|
||||||
<tr>
|
<strong>{% trans "Important:" %}</strong> {% trans "This invitation link will expire in 7 days." %}
|
||||||
<td style="background-color: #fffbeb; border-left: 4px solid #f59e0b; padding: 15px; border-radius: 4px;">
|
</p>
|
||||||
<p style="margin: 0; font-size: 14px; color: #92400e; line-height: 1.5;">
|
</div>
|
||||||
<strong>{% trans "Important:" %}</strong> {% trans "This invitation link will expire in 7 days. If you don't complete the setup within this period, you'll need to request a new invitation." %}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- Help Section -->
|
<p style="margin: 25px 0 0; font-size: 13px; color: #94a3b8; line-height: 1.5;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-top: 25px;">
|
{% trans "Need help? Contact support@alhammadi.com or call +966 11 123 4567." %}
|
||||||
<tr>
|
</p>
|
||||||
<td style="border-top: 1px solid #e2e8f0; padding-top: 20px;">
|
|
||||||
<p style="margin: 0; font-size: 14px; color: #64748b; line-height: 1.6;">
|
|
||||||
{% trans "If you have any questions or need assistance, please contact our support team at" %}
|
|
||||||
<a href="mailto:support@alhammadi.com" style="color: #005696; text-decoration: none;">support@alhammadi.com</a>
|
|
||||||
{% trans "or call us at" %} <strong>+966 11 123 4567</strong>.
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -12,64 +12,99 @@
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<div class="inline-flex items-center justify-center w-20 h-20 bg-navy rounded-full mb-6">
|
<div class="inline-flex items-center justify-center w-20 h-20 bg-navy rounded-full mb-6">
|
||||||
|
{% if current_content.icon %}
|
||||||
|
<i data-lucide="{{ current_content.icon }}" class="w-10 h-10 text-white"></i>
|
||||||
|
{% else %}
|
||||||
<i data-lucide="book-open" class="w-10 h-10 text-white"></i>
|
<i data-lucide="book-open" class="w-10 h-10 text-white"></i>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-xl md:text-2xl font-bold text-navy mb-1">
|
<h1 class="text-xl md:text-2xl font-bold text-navy mb-1">
|
||||||
{% trans "Review Onboarding Material" %}
|
{{ current_content.get_localized_title }}
|
||||||
</h1>
|
</h1>
|
||||||
|
{% if current_content.get_localized_description %}
|
||||||
<p class="text-sm text-slate">
|
<p class="text-sm text-slate">
|
||||||
{% trans "Please review the following important information" %}
|
{{ current_content.get_localized_description }}
|
||||||
</p>
|
</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progress -->
|
<!-- Progress -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<span class="text-sm font-medium text-gray-500">{% trans "Step 3 of 3" %}</span>
|
<span class="text-sm font-medium text-gray-500">{% trans "Step" %} {{ step }} {% trans "of" %} {{ content|length }}</span>
|
||||||
<span class="text-sm font-medium text-navy">100%</span>
|
<span class="text-sm font-medium text-navy">{{ progress_percentage }}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full bg-gray-200 h-2 rounded-full overflow-hidden">
|
<div class="w-full bg-gray-200 h-2.5 rounded-full overflow-hidden">
|
||||||
<div class="bg-navy h-full w-full"></div>
|
<div class="bg-navy h-full rounded-full transition-all duration-500" style="width: {{ progress_percentage }}%"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Step Indicators -->
|
||||||
<div class="space-y-6 mb-8">
|
<div class="flex items-center justify-center gap-2 mb-8">
|
||||||
{% for content in content_items %}
|
{% for item in content %}
|
||||||
<div class="bg-slate-50 rounded-xl p-6 border border-slate-200">
|
<div class="flex items-center">
|
||||||
<div class="flex items-start gap-4 mb-4">
|
{% if item.id == current_content.id %}
|
||||||
<div class="flex items-center justify-center w-12 h-12 bg-light rounded-xl flex-shrink-0">
|
<div class="w-8 h-8 rounded-full bg-navy text-white flex items-center justify-center text-sm font-bold">
|
||||||
<i data-lucide="file-text" class="w-6 h-6 text-navy"></i>
|
{{ forloop.counter }}
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<h3 class="text-xl font-bold text-gray-800 mb-2">{{ content.title }}</h3>
|
|
||||||
<div class="prose prose-sm text-gray-600">
|
|
||||||
{{ content.body|safe }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% elif forloop.counter0 < step|add:"-1" %}
|
||||||
|
<div class="w-8 h-8 rounded-full bg-green-500 text-white flex items-center justify-center">
|
||||||
|
<i data-lucide="check" class="w-4 h-4"></i>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="w-8 h-8 rounded-full bg-gray-200 text-gray-400 flex items-center justify-center text-sm font-bold">
|
||||||
|
{{ forloop.counter }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if not forloop.last %}
|
||||||
|
<div class="w-8 h-0.5 {% if forloop.counter < step %}bg-green-500{% else %}bg-gray-200{% endif %}"></div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Content Body -->
|
||||||
|
<div class="space-y-6 mb-8">
|
||||||
|
<div class="bg-slate-50 rounded-xl p-6 border border-slate-200">
|
||||||
|
<div class="prose prose-sm max-w-none text-gray-600">
|
||||||
|
{{ current_content.get_localized_content|safe }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Confirmation -->
|
<!-- Confirmation -->
|
||||||
<div class="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-6">
|
<div class="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-6">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<input type="checkbox" id="confirm_reviewed" name="confirm_reviewed" required
|
<input type="checkbox" id="confirm_reviewed" name="confirm_reviewed" required
|
||||||
class="w-5 h-5 mt-0.5 text-blue-500 border-gray-300 rounded focus:ring-blue-500">
|
class="w-5 h-5 mt-0.5 text-blue-500 border-gray-300 rounded focus:ring-blue-500">
|
||||||
<label for="confirm_reviewed" class="text-sm text-blue-700">
|
<label for="confirm_reviewed" class="text-sm text-blue-700">
|
||||||
<span class="font-bold">{% trans "I have reviewed all the onboarding material above" %}</span>
|
<span class="font-bold">{% trans "I have reviewed the onboarding material above" %}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<form method="post">
|
<form method="post" id="stepForm">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit" class="w-full bg-navy text-white px-6 py-4 rounded-xl font-bold hover:bg-blue transition shadow-lg">
|
<input type="hidden" name="step" value="{{ step }}">
|
||||||
{% trans "Complete Onboarding" %}
|
<div class="flex gap-3">
|
||||||
<i data-lucide="check-circle" class="w-5 h-5 inline ml-2"></i>
|
{% if previous_step %}
|
||||||
</button>
|
<a href="/accounts/onboarding/wizard/step/{{ previous_step }}/" class="px-6 py-4 border-2 border-gray-200 rounded-xl font-medium text-gray-600 hover:bg-gray-50 transition">
|
||||||
|
{% trans "Previous" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if next_step %}
|
||||||
|
<button type="submit" class="flex-1 bg-navy text-white px-6 py-4 rounded-xl font-bold hover:bg-blue transition shadow-lg">
|
||||||
|
{% trans "Next Step" %}
|
||||||
|
<i data-lucide="arrow-right" class="w-5 h-5 inline ml-2"></i>
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<a href="/accounts/onboarding/wizard/checklist/" class="flex-1 bg-navy text-white px-6 py-4 rounded-xl font-bold text-center hover:bg-blue transition shadow-lg block">
|
||||||
|
{% trans "Continue to Checklist" %}
|
||||||
|
<i data-lucide="clipboard-check" class="w-5 h-5 inline ml-2"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -78,6 +113,16 @@
|
|||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
|
|
||||||
|
document.getElementById('stepForm').addEventListener('submit', function(e) {
|
||||||
|
const checkbox = document.getElementById('confirm_reviewed');
|
||||||
|
if (!checkbox.checked) {
|
||||||
|
e.preventDefault();
|
||||||
|
checkbox.focus();
|
||||||
|
checkbox.classList.add('ring-2', 'ring-red-300');
|
||||||
|
setTimeout(() => checkbox.classList.remove('ring-2', 'ring-red-300'), 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -826,9 +826,32 @@
|
|||||||
function refreshDashboard() {
|
function refreshDashboard() {
|
||||||
const icon = document.querySelector('[data-lucide="refresh-cw"]');
|
const icon = document.querySelector('[data-lucide="refresh-cw"]');
|
||||||
icon.classList.add('animate-spin');
|
icon.classList.add('animate-spin');
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
// Call API to trigger cache refresh
|
||||||
}, 500);
|
fetch('{% url "analytics:refresh_dashboard_cache" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': '{{ csrf_token }}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
showToast(data.message || 'Dashboard cache refresh triggered');
|
||||||
|
// Reload after a short delay to get fresh data
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 2000);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Failed to trigger dashboard refresh:', err);
|
||||||
|
showToast('Failed to trigger dashboard refresh');
|
||||||
|
icon.classList.remove('animate-spin');
|
||||||
|
// Fallback: just reload the page
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshAiAnalytics() {
|
function refreshAiAnalytics() {
|
||||||
|
|||||||
@ -357,7 +357,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
MRN: ${patient.mrn} |
|
MRN: ${patient.mrn} |
|
||||||
Phone: ${patient.phone || 'N/A'} |
|
Phone: ${patient.phone || 'N/A'} |
|
||||||
ID: ${patient.national_id || 'N/A'}
|
ID: ${patient.national_id_masked || 'N/A'}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -302,7 +302,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
MRN: ${patient.mrn} |
|
MRN: ${patient.mrn} |
|
||||||
Phone: ${patient.phone || 'N/A'} |
|
Phone: ${patient.phone || 'N/A'} |
|
||||||
ID: ${patient.national_id || 'N/A'}
|
ID: ${patient.national_id_masked || 'N/A'}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -799,6 +799,74 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Location hierarchy cascading dropdowns
|
||||||
|
if (locationSelect) {
|
||||||
|
locationSelect.addEventListener('change', function () {
|
||||||
|
const locationId = this.value;
|
||||||
|
if (!locationId) {
|
||||||
|
if (mainSectionSelect) mainSectionSelect.innerHTML = '<option value="">{% trans "Select Section" %}</option>';
|
||||||
|
if (subsectionSelect) subsectionSelect.innerHTML = '<option value="">{% trans "Select Subsection" %}</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mainSectionSelect) {
|
||||||
|
mainSectionSelect.innerHTML = '<option value="">{% trans "Loading..." %}</option>';
|
||||||
|
}
|
||||||
|
fetch('{% url "organizations:ajax_main_sections" %}?location_id=' + locationId)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const sections = data.sections || [];
|
||||||
|
if (mainSectionSelect) {
|
||||||
|
mainSectionSelect.innerHTML = '<option value="">{% trans "Select Section" %}</option>';
|
||||||
|
sections.forEach(section => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = section.id;
|
||||||
|
opt.textContent = {% if LANG == 'ar' %}section.name_ar || section.name{% else %}section.name{% endif %};
|
||||||
|
mainSectionSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (subsectionSelect) {
|
||||||
|
subsectionSelect.innerHTML = '<option value="">{% trans "Select Subsection" %}</option>';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Failed to load main sections:', err);
|
||||||
|
if (mainSectionSelect) mainSectionSelect.innerHTML = '<option value="">{% trans "Error loading sections" %}</option>';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mainSectionSelect) {
|
||||||
|
mainSectionSelect.addEventListener('change', function () {
|
||||||
|
const locationId = locationSelect ? locationSelect.value : '';
|
||||||
|
const mainSectionId = this.value;
|
||||||
|
if (!locationId || !mainSectionId) {
|
||||||
|
if (subsectionSelect) subsectionSelect.innerHTML = '<option value="">{% trans "Select Subsection" %}</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (subsectionSelect) {
|
||||||
|
subsectionSelect.innerHTML = '<option value="">{% trans "Loading..." %}</option>';
|
||||||
|
}
|
||||||
|
fetch('{% url "organizations:ajax_subsections" %}?location_id=' + locationId + '&main_section_id=' + mainSectionId)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const subsections = data.subsections || [];
|
||||||
|
if (subsectionSelect) {
|
||||||
|
subsectionSelect.innerHTML = '<option value="">{% trans "Select Subsection" %}</option>';
|
||||||
|
subsections.forEach(sub => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = sub.internal_id || sub.id;
|
||||||
|
opt.textContent = {% if LANG == 'ar' %}sub.name_ar || sub.name{% else %}sub.name{% endif %};
|
||||||
|
subsectionSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Failed to load subsections:', err);
|
||||||
|
if (subsectionSelect) subsectionSelect.innerHTML = '<option value="">{% trans "Error loading subsections" %}</option>';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Complaint type selection
|
// Complaint type selection
|
||||||
complaintTypeCards.forEach(card => {
|
complaintTypeCards.forEach(card => {
|
||||||
card.addEventListener('click', function() {
|
card.addEventListener('click', function() {
|
||||||
@ -807,6 +875,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
});
|
});
|
||||||
this.classList.add('active');
|
this.classList.add('active');
|
||||||
complaintTypeInput.value = this.dataset.value;
|
complaintTypeInput.value = this.dataset.value;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Patient auto-lookup by national_id
|
// Patient auto-lookup by national_id
|
||||||
|
|||||||
@ -138,22 +138,45 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label">
|
<label class="form-label">
|
||||||
{% trans "PX Admin User" %} <span class="text-red-500">*</span>
|
{% trans "On-Call User" %} <span class="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select name="admin_user" required class="form-select">
|
<select name="admin_user" required class="form-select">
|
||||||
<option value="">{% trans "Select an admin..." %}</option>
|
<option value="">{% trans "Select a user..." %}</option>
|
||||||
{% for admin in available_admins %}
|
{% if available_px_admins %}
|
||||||
<option value="{{ admin.id }}">
|
<optgroup label="{% trans 'PX Admins' %}">
|
||||||
{{ admin.get_full_name|default:admin.email }} ({{ admin.email }})
|
{% for admin in available_px_admins %}
|
||||||
</option>
|
<option value="{{ admin.id }}">
|
||||||
{% empty %}
|
{{ admin.get_full_name|default:admin.email }} ({{ admin.email }})
|
||||||
<option value="" disabled>{% trans "No available PX Admins" %}</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</optgroup>
|
||||||
|
{% endif %}
|
||||||
|
{% if available_coordinators %}
|
||||||
|
<optgroup label="{% trans 'PX Coordinators' %}">
|
||||||
|
{% for admin in available_coordinators %}
|
||||||
|
<option value="{{ admin.id }}">
|
||||||
|
{{ admin.get_full_name|default:admin.email }} ({{ admin.email }})
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</optgroup>
|
||||||
|
{% endif %}
|
||||||
|
{% if available_hospital_admins %}
|
||||||
|
<optgroup label="{% trans 'Hospital Admins' %}">
|
||||||
|
{% for admin in available_hospital_admins %}
|
||||||
|
<option value="{{ admin.id }}">
|
||||||
|
{{ admin.get_full_name|default:admin.email }} ({{ admin.email }})
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</optgroup>
|
||||||
|
{% endif %}
|
||||||
|
{% if not available_admins %}
|
||||||
|
<option value="" disabled>{% trans "No available users" %}</option>
|
||||||
|
{% endif %}
|
||||||
</select>
|
</select>
|
||||||
{% if not available_admins %}
|
{% if not available_admins %}
|
||||||
<p class="mt-2 text-sm text-amber-600 flex items-center gap-2">
|
<p class="mt-2 text-sm text-amber-600 flex items-center gap-2">
|
||||||
<i data-lucide="alert-triangle" class="w-4 h-4"></i>
|
<i data-lucide="alert-triangle" class="w-4 h-4"></i>
|
||||||
{% trans "All PX Admins are already assigned to this schedule." %}
|
{% trans "All eligible users are already assigned to this schedule." %}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,14 +5,16 @@
|
|||||||
<h3 class="text-xl font-bold text-navy flex items-center gap-2">
|
<h3 class="text-xl font-bold text-navy flex items-center gap-2">
|
||||||
<i data-lucide="bot" class="w-6 h-6"></i> {% trans "AI Analysis" %}
|
<i data-lucide="bot" class="w-6 h-6"></i> {% trans "AI Analysis" %}
|
||||||
</h3>
|
</h3>
|
||||||
{% if not complaint.emotion and not complaint.short_description_en and not complaint.suggested_actions %}
|
|
||||||
{% if user.is_px_admin or user.is_hospital_admin %}
|
{% if user.is_px_admin or user.is_hospital_admin %}
|
||||||
<button id="reanalyzeBtn" onclick="reanalyzeComplaintAI()" class="inline-flex items-center gap-1 px-3 py-1.5 bg-white border border-slate-200 rounded-lg text-xs font-semibold text-navy hover:bg-slate-50 transition">
|
<button id="reanalyzeBtn" onclick="reanalyzeComplaintAI()" class="inline-flex items-center gap-1 px-3 py-1.5 bg-white border border-slate-200 rounded-lg text-xs font-semibold text-navy hover:bg-slate-50 transition">
|
||||||
<i data-lucide="refresh-cw" class="w-3 h-3"></i>
|
<i data-lucide="refresh-cw" class="w-3 h-3"></i>
|
||||||
{% trans "Analyze" %}
|
{% if complaint.emotion or complaint.short_description_en or complaint.suggested_actions %}
|
||||||
|
{% trans "Reanalyze" %}
|
||||||
|
{% else %}
|
||||||
|
{% trans "Analyze" %}
|
||||||
|
{% endif %}
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if complaint.emotion %}
|
{% if complaint.emotion %}
|
||||||
@ -118,12 +120,9 @@
|
|||||||
{% if not complaint.emotion and not complaint.short_description_en and not complaint.suggested_actions %}
|
{% if not complaint.emotion and not complaint.short_description_en and not complaint.suggested_actions %}
|
||||||
<div id="aiEmptyState" class="text-center py-12">
|
<div id="aiEmptyState" class="text-center py-12">
|
||||||
<i data-lucide="bot" class="w-16 h-16 mx-auto text-slate-300 mb-4"></i>
|
<i data-lucide="bot" class="w-16 h-16 mx-auto text-slate-300 mb-4"></i>
|
||||||
<p class="text-slate mb-4">{% trans "No AI analysis available for this complaint" %}</p>
|
<p class="text-slate">{% trans "No AI analysis available for this complaint" %}</p>
|
||||||
{% if user.is_px_admin or user.is_hospital_admin %}
|
{% if user.is_px_admin or user.is_hospital_admin %}
|
||||||
<button onclick="reanalyzeComplaintAI()" class="inline-flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-blue to-navy text-white rounded-lg font-semibold hover:from-navy hover:to-blue transition text-sm shadow-lg">
|
<p class="text-slate text-sm mt-1">{% trans "Click \"Analyze\" above to run AI analysis" %}</p>
|
||||||
<i data-lucide="sparkles" class="w-4 h-4"></i>
|
|
||||||
{% trans "Run AI Analysis" %}
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -189,7 +188,7 @@ function reanalyzeComplaintAI() {
|
|||||||
})
|
})
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
let html = '<div class="flex items-center justify-between mb-6"><h3 class="text-xl font-bold text-navy flex items-center gap-2"><i data-lucide="bot" class="w-6 h-6"></i> {% trans "AI Analysis" %}</h3><span class="text-xs text-green-600 font-semibold">{% trans "Analysis complete" %}</span></div>';
|
let html = '<div class="flex items-center justify-between mb-6"><h3 class="text-xl font-bold text-navy flex items-center gap-2"><i data-lucide="bot" class="w-6 h-6"></i> {% trans "AI Analysis" %}</h3><div class="flex items-center gap-3"><span class="text-xs text-green-600 font-semibold">{% trans "Analysis complete" %}</span><button id="reanalyzeBtn" onclick="reanalyzeComplaintAI()" class="inline-flex items-center gap-1 px-3 py-1.5 bg-white border border-slate-200 rounded-lg text-xs font-semibold text-navy hover:bg-slate-50 transition"><i data-lucide="refresh-cw" class="w-3 h-3"></i> {% trans "Reanalyze" %}</button></div></div>';
|
||||||
|
|
||||||
if (data.emotion && data.emotion !== 'neutral') {
|
if (data.emotion && data.emotion !== 'neutral') {
|
||||||
html += '<div class="bg-gradient-to-r from-light to-blue-50 border border-blue-200 rounded-2xl p-6 mb-6">';
|
html += '<div class="bg-gradient-to-r from-light to-blue-50 border border-blue-200 rounded-2xl p-6 mb-6">';
|
||||||
|
|||||||
@ -59,7 +59,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="min-h-screen bg-gradient-to-br from-light via-blue-50 to-light py-12 px-4 sm:px-6 lg:px-8">
|
<div class="min-h-screen py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div class="max-w-3xl mx-auto">
|
<div class="max-w-3xl mx-auto">
|
||||||
<!-- Success Icon Animation -->
|
<!-- Success Icon Animation -->
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
@ -184,7 +184,7 @@
|
|||||||
|
|
||||||
<!-- Footer Note -->
|
<!-- Footer Note -->
|
||||||
<div class="text-center mt-8">
|
<div class="text-center mt-8">
|
||||||
<p class="text-slate text-sm">
|
<p class="text-white/70 text-sm">
|
||||||
{% trans "Your feedback helps us improve our services" %}
|
{% trans "Your feedback helps us improve our services" %}
|
||||||
<i data-lucide="heart" class="w-4 h-4 inline-block text-red-500 mx-1"></i>
|
<i data-lucide="heart" class="w-4 h-4 inline-block text-red-500 mx-1"></i>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -9,11 +9,6 @@ header.glass-card {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.public-bg {
|
|
||||||
background: linear-gradient(to bottom right, #005696, #007bbd, #eef6fb) !important;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-switcher {
|
.lang-switcher {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 1rem;
|
top: 1rem;
|
||||||
@ -166,21 +161,22 @@ body.public-bg {
|
|||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="text-right hidden md:block">
|
<div class="text-right hidden md:block">
|
||||||
<span class="text-xs font-bold text-slate/40 uppercase tracking-widest block mb-1">{% trans "Current Status" %}</span>
|
<span class="text-xs font-bold text-slate/40 uppercase tracking-widest block mb-1">{% trans "Current Status" %}</span>
|
||||||
<p class="font-bold text-navy">{{ complaint.get_status_display }}</p>
|
<p class="font-bold text-navy">{{ public_status.label }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-6 py-3 rounded-2xl text-sm font-black uppercase tracking-wider shadow-sm border-b-4
|
<div class="px-6 py-3 rounded-2xl text-sm font-black uppercase tracking-wider shadow-sm border-b-4
|
||||||
{% if complaint.status == 'open' %}bg-amber-50 text-amber-700 border-amber-200
|
{% if public_status.css == 'amber' %}bg-amber-50 text-amber-700 border-amber-200
|
||||||
{% elif complaint.status == 'in_progress' %}bg-blue-50 text-blue-700 border-blue-200
|
{% elif public_status.css == 'blue' %}bg-blue-50 text-blue-700 border-blue-200
|
||||||
{% elif complaint.status == 'resolved' %}bg-emerald-50 text-emerald-700 border-emerald-200
|
{% elif public_status.css == 'emerald' %}bg-emerald-50 text-emerald-700 border-emerald-200
|
||||||
|
{% elif public_status.css == 'rose' %}bg-rose-50 text-rose-700 border-rose-200
|
||||||
{% else %}bg-slate-50 text-slate-700 border-slate-200{% endif %}">
|
{% else %}bg-slate-50 text-slate-700 border-slate-200{% endif %}">
|
||||||
{{ complaint.status }}
|
{{ public_status.label }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 h-2 w-full bg-slate-100 rounded-full overflow-hidden">
|
<div class="mt-8 h-2 w-full bg-slate-100 rounded-full overflow-hidden">
|
||||||
<div class="h-full bg-navy transition-all duration-1000"
|
<div class="h-full bg-navy transition-all duration-1000"
|
||||||
style="width: {% if complaint.status == 'resolved' %}100%{% elif complaint.status == 'in_progress' %}50%{% else %}15%{% endif %}">
|
style="width: {{ public_status.progress }}%">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -39,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Escalation Rules -->
|
<!-- Escalation Rules -->
|
||||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6 text-center card-hover">
|
{% comment %} <div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6 text-center card-hover">
|
||||||
<div class="w-16 h-16 mx-auto mb-4 bg-orange-100 rounded-2xl flex items-center justify-center">
|
<div class="w-16 h-16 mx-auto mb-4 bg-orange-100 rounded-2xl flex items-center justify-center">
|
||||||
<i data-lucide="trending-up" class="w-8 h-8 text-orange-600"></i>
|
<i data-lucide="trending-up" class="w-8 h-8 text-orange-600"></i>
|
||||||
</div>
|
</div>
|
||||||
@ -62,7 +62,7 @@
|
|||||||
<i data-lucide="settings-2" class="w-4 h-4"></i>
|
<i data-lucide="settings-2" class="w-4 h-4"></i>
|
||||||
{% trans "Manage Thresholds" %}
|
{% trans "Manage Thresholds" %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div> {% endcomment %}
|
||||||
|
|
||||||
<!-- On-Call Schedules -->
|
<!-- On-Call Schedules -->
|
||||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6 text-center card-hover">
|
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6 text-center card-hover">
|
||||||
@ -80,7 +80,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- PX Actions Section -->
|
<!-- PX Actions Section -->
|
||||||
<section class="mb-8">
|
{% comment %} <section class="mb-8">
|
||||||
<h2 class="text-lg font-bold text-navy mb-4 flex items-center gap-2">
|
<h2 class="text-lg font-bold text-navy mb-4 flex items-center gap-2">
|
||||||
<i data-lucide="zap" class="w-5 h-5 text-yellow-500"></i>
|
<i data-lucide="zap" class="w-5 h-5 text-yellow-500"></i>
|
||||||
{% trans "PX Actions" %}
|
{% trans "PX Actions" %}
|
||||||
@ -112,7 +112,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section> {% endcomment %}
|
||||||
|
|
||||||
<!-- User Management Section -->
|
<!-- User Management Section -->
|
||||||
<section class="mb-8">
|
<section class="mb-8">
|
||||||
@ -127,8 +127,8 @@
|
|||||||
<i data-lucide="users" class="w-8 h-8 text-green-600"></i>
|
<i data-lucide="users" class="w-8 h-8 text-green-600"></i>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="font-bold text-navy text-lg mb-2">{% trans "Users" %}</h3>
|
<h3 class="font-bold text-navy text-lg mb-2">{% trans "Users" %}</h3>
|
||||||
<p class="text-sm text-slate mb-4">{% trans "Manage system users and permissions" %}</p>
|
<p class="text-sm text-slate mb-4">{{ active_users_count }} {% trans "active users" %}</p>
|
||||||
<a href="/admin/accounts/user/" class="inline-flex items-center gap-2 bg-navy text-white px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg shadow-navy/20 hover:bg-blue transition">
|
<a href="{% url 'config:hospital_users_list' %}" class="inline-flex items-center gap-2 bg-navy text-white px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg shadow-navy/20 hover:bg-blue transition">
|
||||||
<i data-lucide="user-cog" class="w-4 h-4"></i>
|
<i data-lucide="user-cog" class="w-4 h-4"></i>
|
||||||
{% trans "Manage Users" %}
|
{% trans "Manage Users" %}
|
||||||
</a>
|
</a>
|
||||||
@ -170,7 +170,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hospital Notifications -->
|
<!-- Hospital Notifications -->
|
||||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6 text-center card-hover">
|
{% comment %} <div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6 text-center card-hover">
|
||||||
<div class="w-16 h-16 mx-auto mb-4 bg-pink-100 rounded-2xl flex items-center justify-center">
|
<div class="w-16 h-16 mx-auto mb-4 bg-pink-100 rounded-2xl flex items-center justify-center">
|
||||||
<i data-lucide="bell-ring" class="w-8 h-8 text-pink-600"></i>
|
<i data-lucide="bell-ring" class="w-8 h-8 text-pink-600"></i>
|
||||||
</div>
|
</div>
|
||||||
@ -180,7 +180,7 @@
|
|||||||
<i data-lucide="bell" class="w-4 h-4"></i>
|
<i data-lucide="bell" class="w-4 h-4"></i>
|
||||||
{% trans "Configure" %}
|
{% trans "Configure" %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div> {% endcomment %}
|
||||||
|
|
||||||
<!-- Send SMS -->
|
<!-- Send SMS -->
|
||||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6 text-center card-hover">
|
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6 text-center card-hover">
|
||||||
|
|||||||
79
templates/config/emails/reset_password_email.html
Normal file
79
templates/config/emails/reset_password_email.html
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
{% extends 'emails/base_email_template.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Your PX360 Password Has Been Reset - Al Hammadi Hospital" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block preheader %}{% trans "Your password has been reset by an administrator. Find your new credentials below." %}{% endblock %}
|
||||||
|
|
||||||
|
{% block hero_title %}{% trans "Password Reset" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block hero_subtitle %}{% trans "Your PX360 account password has been reset" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td style="padding-bottom: 20px;">
|
||||||
|
<p style="margin: 0; font-size: 16px; color: #1e293b; line-height: 1.6;">
|
||||||
|
{% trans "Dear" %} <strong>{{ user.get_full_name }}</strong>,
|
||||||
|
</p>
|
||||||
|
<p style="margin: 15px 0 0 0; font-size: 16px; color: #64748b; line-height: 1.6;">
|
||||||
|
{% trans "Your PX360 account password has been reset by an administrator. Please use the new credentials below to login." %}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 20px 0; background-color: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 20px;">
|
||||||
|
<h3 style="margin: 0 0 15px 0; font-size: 18px; font-weight: 600; color: #005696; text-align: center;">
|
||||||
|
{% trans "Your New Credentials" %}
|
||||||
|
</h3>
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td width="120" style="padding: 10px 0; font-size: 14px; color: #64748b; font-weight: 500;">
|
||||||
|
{% trans "Email:" %}
|
||||||
|
</td>
|
||||||
|
<td style="padding: 10px 0; font-size: 16px; color: #1e293b; font-weight: 600;">
|
||||||
|
{{ user.email }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px 0; font-size: 14px; color: #64748b; font-weight: 500;">
|
||||||
|
{% trans "Password:" %}
|
||||||
|
</td>
|
||||||
|
<td style="padding: 10px 0; font-size: 16px; color: #1e293b; font-weight: 600; font-family: monospace;">
|
||||||
|
{{ password }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 15px; background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 8px;">
|
||||||
|
<p style="margin: 0 0 10px 0; font-size: 14px; font-weight: 600; color: #92400e;">
|
||||||
|
{% trans "Security Notice:" %}
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; font-size: 14px; color: #78350f; line-height: 1.6;">
|
||||||
|
{% trans "Please change your password immediately after logging in for security purposes." %}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block cta_url %}{{ login_url }}{% endblock %}
|
||||||
|
{% block cta_text %}{% trans "Login to PX360" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block info_title %}{% trans "Need Assistance?" %}{% endblock %}
|
||||||
|
{% block info_content %}
|
||||||
|
{% trans "If you did not expect this password reset, please contact your system administrator immediately." %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block footer_address %}
|
||||||
|
PX360 Patient Experience Management System<br>
|
||||||
|
Al Hammadi Hospital
|
||||||
|
{% endblock %}
|
||||||
509
templates/config/hospital_users.html
Normal file
509
templates/config/hospital_users.html
Normal file
@ -0,0 +1,509 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Hospital Users" %} - PX360{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.page-header-gradient {
|
||||||
|
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.section-card:hover {
|
||||||
|
border-color: #005696;
|
||||||
|
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-bottom: 2px solid #e2e8f0;
|
||||||
|
background: linear-gradient(to right, #f8fafc, #f1f5f9);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||||
|
border-color: #005696;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.filter-card:hover {
|
||||||
|
border-color: #005696;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-header {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-bottom: 2px solid #e2e8f0;
|
||||||
|
background: linear-gradient(to right, #f8fafc, #f1f5f9);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-row-hover {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
.table-row-hover:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-left-color: #007bbd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.025em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="page-header-gradient">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold mb-1">{% trans "Hospital Users" %}</h1>
|
||||||
|
<p class="text-blue-100 text-sm">{% trans "Manage hospital user accounts and reset passwords" %}</p>
|
||||||
|
</div>
|
||||||
|
<a href="{% url 'config:dashboard' %}" class="inline-flex items-center px-4 py-2.5 bg-white text-navy font-medium rounded-xl hover:bg-blue-50 transition">
|
||||||
|
<i data-lucide="arrow-left" class="w-4 h-4 me-2"></i>{% trans "Back to Config" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statistics Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="w-12 h-12 bg-navy/10 rounded-xl flex items-center justify-center">
|
||||||
|
<i data-lucide="users" class="w-6 h-6 text-navy"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<h6 class="text-slate-500 text-xs font-medium mb-1">{% trans "Total Users" %}</h6>
|
||||||
|
<h2 class="text-2xl font-bold text-gray-800">{{ total_users }}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="w-12 h-12 bg-green-500/10 rounded-xl flex items-center justify-center">
|
||||||
|
<i data-lucide="user-check" class="w-6 h-6 text-green-600"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<h6 class="text-slate-500 text-xs font-medium mb-1">{% trans "Active" %}</h6>
|
||||||
|
<h2 class="text-2xl font-bold text-gray-800">{{ active_count }}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="w-12 h-12 bg-red-500/10 rounded-xl flex items-center justify-center">
|
||||||
|
<i data-lucide="user-x" class="w-6 h-6 text-red-600"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<h6 class="text-slate-500 text-xs font-medium mb-1">{% trans "Inactive" %}</h6>
|
||||||
|
<h2 class="text-2xl font-bold text-gray-800">{{ inactive_count }}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="filter-card">
|
||||||
|
<div class="filter-header">
|
||||||
|
<div class="section-icon bg-navy/10">
|
||||||
|
<i data-lucide="filter" class="w-5 h-5 text-navy"></i>
|
||||||
|
</div>
|
||||||
|
<h5 class="text-base font-semibold text-gray-800">{% trans "Filters" %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="p-5">
|
||||||
|
<form method="get" class="flex flex-wrap gap-4 items-end">
|
||||||
|
{% if user.is_px_admin %}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-xs font-bold text-slate uppercase">{% trans "Hospital" %}</label>
|
||||||
|
<select name="hospital" class="px-3 py-1.5 bg-white border border-slate-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-navy">
|
||||||
|
<option value="">{% trans "All" %}</option>
|
||||||
|
{% for hospital in hospitals %}
|
||||||
|
<option value="{{ hospital.id }}" {% if request.GET.hospital == hospital.id|stringformat:"s" %}selected{% endif %}>{{ hospital.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-xs font-bold text-slate uppercase">{% trans "Role" %}</label>
|
||||||
|
<select name="role" class="px-3 py-1.5 bg-white border border-slate-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-navy">
|
||||||
|
<option value="">{% trans "All" %}</option>
|
||||||
|
{% for role in roles %}
|
||||||
|
<option value="{{ role.name }}" {% if request.GET.role == role.name %}selected{% endif %}>{{ role.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-xs font-bold text-slate uppercase">{% trans "Status" %}</label>
|
||||||
|
<select name="is_active" class="px-3 py-1.5 bg-white border border-slate-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-navy">
|
||||||
|
<option value="">{% trans "All" %}</option>
|
||||||
|
<option value="true" {% if request.GET.is_active == 'true' %}selected{% endif %}>{% trans "Active" %}</option>
|
||||||
|
<option value="false" {% if request.GET.is_active == 'false' %}selected{% endif %}>{% trans "Inactive" %}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-xs font-bold text-slate uppercase">{% trans "Search" %}</label>
|
||||||
|
<input type="text" name="search" class="px-3 py-1.5 border border-slate-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-navy w-40" placeholder="{% trans 'Name, email, ID...' %}" value="{{ request.GET.search|default:'' }}">
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button type="submit" class="px-4 py-1.5 bg-navy text-white rounded-lg text-xs font-bold hover:bg-blue transition">{% trans "Apply" %}</button>
|
||||||
|
<a href="{% url 'config:hospital_users_list' %}" class="px-4 py-1.5 border border-slate-200 rounded-lg text-xs font-semibold text-slate hover:bg-light transition">{% trans "Clear" %}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Users List Section -->
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-header flex justify-between items-center">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="section-icon bg-navy/10">
|
||||||
|
<i data-lucide="users" class="w-5 h-5 text-navy"></i>
|
||||||
|
</div>
|
||||||
|
<h5 class="text-lg font-semibold text-gray-800">{% trans "User Accounts" %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-slate font-medium">
|
||||||
|
{% trans "Showing" %} <span class="font-bold text-navy">{{ page_obj.start_index|default:0 }}-{{ page_obj.end_index|default:0 }}</span> {% trans "of" %} <span class="font-bold text-navy">{{ page_obj.paginator.count|default:0 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-slate-50 border-b border-slate-200">
|
||||||
|
<tr class="text-xs font-bold text-slate uppercase tracking-wider">
|
||||||
|
<th class="px-5 py-3 text-left">{% trans "Name" %}</th>
|
||||||
|
<th class="px-5 py-3 text-left">{% trans "Email" %}</th>
|
||||||
|
<th class="px-5 py-3 text-left">{% trans "Hospital" %}</th>
|
||||||
|
<th class="px-5 py-3 text-left">{% trans "Department" %}</th>
|
||||||
|
<th class="px-5 py-3 text-left">{% trans "Role(s)" %}</th>
|
||||||
|
<th class="px-5 py-3 text-left">{% trans "Status" %}</th>
|
||||||
|
<th class="px-5 py-3 text-left">{% trans "Actions" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="text-sm divide-y divide-slate-100">
|
||||||
|
{% for u in users %}
|
||||||
|
<tr class="table-row-hover">
|
||||||
|
<td class="px-5 py-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-8 h-8 bg-navy rounded-full flex items-center justify-center text-white font-bold text-xs">
|
||||||
|
{{ u.first_name.0 }}{{ u.last_name.0 }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-bold text-navy">{{ u.get_full_name }}</div>
|
||||||
|
{% if u.employee_id %}
|
||||||
|
<div class="text-xs text-slate">{{ u.employee_id }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-5 py-4 text-slate-800">{{ u.email }}</td>
|
||||||
|
<td class="px-5 py-4">
|
||||||
|
{% if u.hospital %}
|
||||||
|
<span class="px-2 py-1 rounded-lg text-xs font-bold bg-blue-50 text-blue-700">{{ u.hospital.name }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-slate text-xs">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-5 py-4">
|
||||||
|
{% if u.department %}
|
||||||
|
<span class="px-2 py-1 rounded-lg text-xs font-bold bg-slate-100 text-slate-600">{{ u.department.get_localized_name }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-slate text-xs">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-5 py-4">
|
||||||
|
{% for role_name in u.get_role_names %}
|
||||||
|
<span class="inline-block px-2 py-0.5 rounded-lg text-xs font-bold bg-purple-50 text-purple-700 mr-1 mb-1">{{ role_name }}</span>
|
||||||
|
{% empty %}
|
||||||
|
<span class="text-slate text-xs">-</span>
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td class="px-5 py-4">
|
||||||
|
{% if u.is_active %}
|
||||||
|
<span class="status-badge bg-green-100 text-green-600">{% trans "Active" %}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-badge bg-red-100 text-red-600">{% trans "Inactive" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-5 py-4">
|
||||||
|
<button type="button" class="p-1.5 text-orange-600 hover:bg-orange-50 rounded-lg transition" onclick="openResetModal('{{ u.id }}', '{{ u.get_full_name }}', '{{ u.email }}')" title="{% trans 'Reset Password' %}">
|
||||||
|
<i data-lucide="key-round" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="px-5 py-12 text-center">
|
||||||
|
<div class="flex flex-col items-center justify-center">
|
||||||
|
<div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mb-4">
|
||||||
|
<i data-lucide="users" class="w-8 h-8 text-slate-300"></i>
|
||||||
|
</div>
|
||||||
|
<p class="text-slate font-medium mb-2">{% trans "No users found" %}</p>
|
||||||
|
<p class="text-slate text-sm">{% trans "Try adjusting your filters" %}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if page_obj.has_other_pages %}
|
||||||
|
<div class="bg-slate-50 px-6 py-4 flex items-center justify-between border-t border-slate-100">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<span class="text-xs text-slate font-medium">
|
||||||
|
{% trans "Showing" %} <span class="font-bold text-navy">{{ page_obj.start_index }}-{{ page_obj.end_index }}</span> {% trans "of" %} <span class="font-bold text-navy">{{ page_obj.paginator.count }}</span> {% trans "entries" %}
|
||||||
|
</span>
|
||||||
|
<form method="get" class="flex items-center gap-2" id="pageSizeForm">
|
||||||
|
{% for key, value in request.GET.items %}
|
||||||
|
{% if key != 'page_size' and key != 'page' %}
|
||||||
|
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<label class="text-xs text-slate">{% trans "Show" %}</label>
|
||||||
|
<select name="page_size" onchange="document.getElementById('pageSizeForm').submit()" class="px-2 py-1 bg-white border border-slate-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-navy">
|
||||||
|
<option value="10" {% if page_obj.paginator.per_page == 10 %}selected{% endif %}>10</option>
|
||||||
|
<option value="25" {% if page_obj.paginator.per_page == 25 %}selected{% endif %}>25</option>
|
||||||
|
<option value="50" {% if page_obj.paginator.per_page == 50 %}selected{% endif %}>50</option>
|
||||||
|
<option value="100" {% if page_obj.paginator.per_page == 100 %}selected{% endif %}>100</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<a href="?page={{ page_obj.previous_page_number }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}"
|
||||||
|
class="w-8 h-8 flex items-center justify-center rounded-lg border border-slate-200 bg-white hover:bg-slate-50 transition">
|
||||||
|
<i data-lucide="chevron-left" class="w-4 h-4 text-slate"></i>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="w-8 h-8 flex items-center justify-center rounded-lg border border-slate-200 bg-slate-100 text-slate-300 cursor-not-allowed">
|
||||||
|
<i data-lucide="chevron-left" class="w-4 h-4"></i>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for num in page_obj.paginator.page_range %}
|
||||||
|
{% if num == page_obj.number %}
|
||||||
|
<span class="w-8 h-8 flex items-center justify-center rounded-lg bg-navy text-white text-xs font-bold">{{ num }}</span>
|
||||||
|
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||||
|
<a href="?page={{ num }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}"
|
||||||
|
class="w-8 h-8 flex items-center justify-center rounded-lg border border-slate-200 bg-white hover:bg-slate-50 text-xs font-bold text-slate transition">
|
||||||
|
{{ num }}
|
||||||
|
</a>
|
||||||
|
{% elif num == 1 or num == page_obj.paginator.num_pages %}
|
||||||
|
<a href="?page={{ num }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}"
|
||||||
|
class="w-8 h-8 flex items-center justify-center rounded-lg border border-slate-200 bg-white hover:bg-slate-50 text-xs font-bold text-slate transition">
|
||||||
|
{{ num }}
|
||||||
|
</a>
|
||||||
|
{% elif num == page_obj.number|add:'-3' or num == page_obj.number|add:'3' %}
|
||||||
|
<span class="w-8 h-8 flex items-center justify-center text-xs text-slate">...</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<a href="?page={{ page_obj.next_page_number }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}"
|
||||||
|
class="w-8 h-8 flex items-center justify-center rounded-lg border border-slate-200 bg-white hover:bg-slate-50 transition">
|
||||||
|
<i data-lucide="chevron-right" class="w-4 h-4 text-slate"></i>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="w-8 h-8 flex items-center justify-center rounded-lg border border-slate-200 bg-slate-100 text-slate-300 cursor-not-allowed">
|
||||||
|
<i data-lucide="chevron-right" class="w-4 h-4"></i>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reset Password Confirmation Modal -->
|
||||||
|
<div class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50" id="resetPasswordModal">
|
||||||
|
<div class="bg-white rounded-2xl shadow-2xl max-w-md w-full mx-4 overflow-hidden">
|
||||||
|
<div class="bg-orange-500 text-white px-6 py-4 flex justify-between items-center">
|
||||||
|
<h5 class="font-bold text-lg flex items-center gap-2">
|
||||||
|
<i data-lucide="key-round" class="w-5 h-5"></i>
|
||||||
|
{% trans "Reset Password" %}
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="text-white hover:bg-orange-600 rounded-full p-1 transition" onclick="closeModal('resetPasswordModal')">
|
||||||
|
<i data-lucide="x" class="w-5 h-5"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<p class="text-slate mb-2">{% trans "Reset password for" %} <strong class="text-navy" id="resetUserName"></strong>?</p>
|
||||||
|
<p class="text-slate text-sm mb-1">{% trans "Email:" %} <span id="resetUserEmail" class="font-medium"></span></p>
|
||||||
|
<p class="text-slate text-sm">{% trans "A new temporary password will be generated and sent to this user's email." %}</p>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4 bg-slate-50 flex justify-end gap-3">
|
||||||
|
<button type="button" class="px-4 py-2 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-white transition" onclick="closeModal('resetPasswordModal')">{% trans "Cancel" %}</button>
|
||||||
|
<button type="button" id="confirmResetBtn" class="px-4 py-2 bg-orange-500 text-white rounded-xl font-semibold hover:bg-orange-600 transition" onclick="confirmResetPassword()">{% trans "Reset Password" %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reset Password Success Modal -->
|
||||||
|
<div class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50" id="resetSuccessModal">
|
||||||
|
<div class="bg-white rounded-2xl shadow-2xl max-w-md w-full mx-4 overflow-hidden">
|
||||||
|
<div class="bg-green-600 text-white px-6 py-4 flex justify-between items-center">
|
||||||
|
<h5 class="font-bold text-lg flex items-center gap-2">
|
||||||
|
<i data-lucide="check-circle" class="w-5 h-5"></i>
|
||||||
|
{% trans "Password Reset Successfully" %}
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="text-white hover:bg-green-700 rounded-full p-1 transition" onclick="closeModal('resetSuccessModal')">
|
||||||
|
<i data-lucide="x" class="w-5 h-5"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<p class="text-slate mb-3">{% trans "The password has been reset for" %} <strong class="text-navy" id="successUserName"></strong>.</p>
|
||||||
|
<div class="bg-slate-50 rounded-xl border border-slate-200 p-4 mb-4">
|
||||||
|
<p class="text-xs text-slate mb-2 uppercase font-bold">{% trans "New Temporary Password" %}</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code class="text-lg font-mono font-bold text-navy bg-white px-3 py-1.5 rounded-lg border border-slate-200 flex-1 break-all" id="newPasswordDisplay"></code>
|
||||||
|
<button type="button" class="p-2 text-navy hover:bg-navy/10 rounded-lg transition" onclick="copyPassword()" title="{% trans 'Copy' %}">
|
||||||
|
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 bg-yellow-50 border-l-4 border-yellow-400 rounded-r-lg">
|
||||||
|
<p class="text-xs text-yellow-800">
|
||||||
|
<strong>{% trans "Note:" %}</strong> {% trans "This password has been sent to the user's email. Please share it securely if needed." %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4 bg-slate-50 flex justify-end">
|
||||||
|
<button type="button" class="px-4 py-2 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition" onclick="closeModal('resetSuccessModal')">{% trans "Done" %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
let currentUserId = null;
|
||||||
|
|
||||||
|
function closeModal(modalId) {
|
||||||
|
document.getElementById(modalId).classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showModal(modalId) {
|
||||||
|
document.getElementById(modalId).classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openResetModal(userId, userName, userEmail) {
|
||||||
|
currentUserId = userId;
|
||||||
|
document.getElementById('resetUserName').textContent = userName;
|
||||||
|
document.getElementById('resetUserEmail').textContent = userEmail;
|
||||||
|
showModal('resetPasswordModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmResetPassword() {
|
||||||
|
const btn = document.getElementById('confirmResetBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i data-lucide="loader-2" class="w-4 h-4 animate-spin inline"></i> {% trans "Resetting..." %}';
|
||||||
|
|
||||||
|
fetch(`/config/users/${currentUserId}/reset-password/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': getCookie('csrftoken')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
closeModal('resetPasswordModal');
|
||||||
|
if (data.success) {
|
||||||
|
document.getElementById('successUserName').textContent = data.user_name;
|
||||||
|
document.getElementById('newPasswordDisplay').textContent = data.password;
|
||||||
|
showModal('resetSuccessModal');
|
||||||
|
lucide.createIcons();
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'An error occurred.');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert('Error: ' + error.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '{% trans "Reset Password" %}';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyPassword() {
|
||||||
|
const password = document.getElementById('newPasswordDisplay').textContent;
|
||||||
|
navigator.clipboard.writeText(password).then(() => {
|
||||||
|
const btn = event.currentTarget;
|
||||||
|
const icon = btn.querySelector('i');
|
||||||
|
icon.setAttribute('data-lucide', 'check');
|
||||||
|
lucide.createIcons();
|
||||||
|
setTimeout(() => {
|
||||||
|
icon.setAttribute('data-lucide', 'copy');
|
||||||
|
lucide.createIcons();
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
if (typeof lucide !== 'undefined') {
|
||||||
|
lucide.createIcons();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@ -10,12 +10,6 @@ header.glass-card {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Match login page background */
|
|
||||||
body.public-bg {
|
|
||||||
background: linear-gradient(to bottom right, #005696, #007bbd, #eef6fb) !important;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Language switcher styles */
|
/* Language switcher styles */
|
||||||
.lang-switcher {
|
.lang-switcher {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@ -12,198 +12,69 @@
|
|||||||
<meta name="supported-color-schemes" content="light">
|
<meta name="supported-color-schemes" content="light">
|
||||||
<title>{% block title %}Al Hammadi Hospital{% endblock %}</title>
|
<title>{% block title %}Al Hammadi Hospital{% endblock %}</title>
|
||||||
<style>
|
<style>
|
||||||
/* Reset Styles */
|
/* Reset */
|
||||||
html, body { margin: 0; padding: 0; width: 100% !important; height: 100% !important; }
|
html, body { margin: 0; padding: 0; width: 100% !important; height: 100% !important; }
|
||||||
* { -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; }
|
* { -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; }
|
||||||
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||||
img { -ms-interpolation-mode: bicubic; border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; }
|
img { -ms-interpolation-mode: bicubic; border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; }
|
||||||
|
|
||||||
/* Client-specific resets */
|
|
||||||
#outlook a { padding: 0; }
|
#outlook a { padding: 0; }
|
||||||
.ExternalClass { width: 100%; }
|
.ExternalClass { width: 100%; }
|
||||||
.ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div { line-height: 100%; }
|
.ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div { line-height: 100%; }
|
||||||
|
|
||||||
/* Body centering */
|
|
||||||
body, table, td { margin: 0 auto; }
|
body, table, td { margin: 0 auto; }
|
||||||
|
/* Responsive */
|
||||||
/* Responsive styles */
|
|
||||||
@media only screen and (max-width: 600px) {
|
@media only screen and (max-width: 600px) {
|
||||||
.email-container { width: 100% !important; max-width: 100% !important; }
|
.email-container { width: 100% !important; }
|
||||||
.fluid { width: 100% !important; height: auto !important; }
|
|
||||||
.stack-column { display: block !important; width: 100% !important; padding-bottom: 20px; }
|
|
||||||
.padding-mobile { padding-left: 20px !important; padding-right: 20px !important; }
|
|
||||||
.heading-mobile { font-size: 22px !important; }
|
|
||||||
.button-full { width: 100% !important; display: block !important; }
|
|
||||||
.content-padding { padding-left: 20px !important; padding-right: 20px !important; }
|
.content-padding { padding-left: 20px !important; padding-right: 20px !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode support */
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.bg-light { background-color: #1e293b !important; }
|
|
||||||
.text-dark { color: #f8fafc !important; }
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<!-- Block for additional custom styles -->
|
|
||||||
{% block extra_styles %}{% endblock %}
|
{% block extra_styles %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;">
|
<body style="margin: 0; padding: 20px 0; background-color: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
||||||
|
|
||||||
<!-- Preheader Text (invisible preview text) -->
|
<!-- Preheader -->
|
||||||
<div style="display: none; font-size: 1px; line-height: 1px; max-height: 0px; max-width: 0px; opacity: 0; overflow: hidden; mso-hide: all;">
|
<div style="display: none; font-size: 1px; line-height: 1px; max-height: 0px; max-width: 0px; opacity: 0; overflow: hidden; mso-hide: all;">
|
||||||
{% block preheader %}Al Hammadi Hospital - Patient Experience Management{% endblock %}
|
{% block preheader %}Al Hammadi Hospital - Patient Experience Management{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Email Container - Full width background -->
|
<!-- Container -->
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f8fafc;">
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f8fafc;">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="padding: 20px 10px;">
|
<td align="center" style="padding: 20px 10px;">
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" class="email-container" style="width: 600px; max-width: 600px; background-color: #ffffff; border-radius: 8px;">
|
||||||
|
|
||||||
<!-- Main Email Wrapper - Centered with max-width -->
|
<!-- Logo -->
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" class="email-container" style="width: 600px; max-width: 600px; background-color: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
|
|
||||||
|
|
||||||
<!-- Header Section -->
|
|
||||||
<tr>
|
<tr>
|
||||||
<td style="background: linear-gradient(135deg, #005696 0%, #007bbd 100%); padding: 30px 40px; text-align: center;">
|
<td style="padding: 30px 40px 20px; text-align: center;">
|
||||||
<!-- Logo -->
|
|
||||||
{% email_logo_url as default_logo_url %}
|
{% email_logo_url as default_logo_url %}
|
||||||
<a href="#" style="text-decoration: none;">
|
<img src="{{ logo_url|default:default_logo_url }}"
|
||||||
<img src="{{ logo_url|default:default_logo_url }}"
|
alt="Al Hammadi Hospital"
|
||||||
alt="Al Hammadi Hospital"
|
width="200"
|
||||||
width="400"
|
style="display: block; margin: 0 auto; max-width: 200px; height: auto;">
|
||||||
height="120"
|
|
||||||
style="display: block; margin: 0 auto; max-width: 100%; font-family: sans-serif; color: #ffffff;">
|
|
||||||
</a>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Hero/Title Section -->
|
<!-- Content -->
|
||||||
{% block hero %}
|
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #ffffff;">
|
<td class="content-padding" style="padding: 0 40px 40px;">
|
||||||
<h1 class="heading-mobile" style="margin: 0 0 10px 0; font-size: 26px; font-weight: 700; color: #005696; line-height: 1.4;">
|
{% block content %}{% endblock %}
|
||||||
{% block hero_title %}Welcome to Al Hammadi Hospital{% endblock %}
|
|
||||||
</h1>
|
|
||||||
<p style="margin: 0; font-size: 16px; color: #64748b; line-height: 1.6;">
|
|
||||||
{% block hero_subtitle %}Your health and satisfaction are our priority{% endblock %}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
<!-- Main Content Section -->
|
|
||||||
<tr>
|
|
||||||
<td class="padding-mobile" style="padding: 0 40px 30px 40px; background-color: #ffffff;">
|
|
||||||
{% block content %}
|
|
||||||
<!-- Content goes here -->
|
|
||||||
{% endblock %}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Call-to-Action Section -->
|
<!-- Footer -->
|
||||||
{% block cta_section %}
|
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="padding: 0 40px 40px 40px; background-color: #ffffff;">
|
<td style="padding: 20px 40px; border-top: 1px solid #e2e8f0; text-align: center;">
|
||||||
<!-- Primary Button -->
|
<p style="margin: 0; font-size: 12px; color: #64748b; line-height: 1.5;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
|
{% block footer %}
|
||||||
<tr>
|
|
||||||
<td style="border-radius: 8px; background: linear-gradient(135deg, #005696 0%, #007bbd 100%);">
|
|
||||||
<a href="{% block cta_url %}#{% endblock %}"
|
|
||||||
class="button-full"
|
|
||||||
style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 600; color: #ffffff; text-decoration: none; border-radius: 8px; text-align: center;">
|
|
||||||
{% block cta_text %}Take Action{% endblock %}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
<!-- Info Box Section -->
|
|
||||||
{% block info_box %}
|
|
||||||
<tr>
|
|
||||||
<td class="padding-mobile" style="padding: 0 40px 40px 40px; background-color: #ffffff;">
|
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #eef6fb; border-radius: 8px; border-left: 4px solid #005696;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 20px;">
|
|
||||||
<p style="margin: 0 0 10px 0; font-size: 14px; font-weight: 600; color: #005696;">
|
|
||||||
ℹ️ {% block info_title %}Important Information{% endblock %}
|
|
||||||
</p>
|
|
||||||
<p style="margin: 0; font-size: 14px; color: #64748b; line-height: 1.6;">
|
|
||||||
{% block info_content %}Please read this information carefully.{% endblock %}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
<!-- Divider -->
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 0 40px; background-color: #ffffff;">
|
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td style="border-top: 1px solid #e2e8f0;"></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Footer Section -->
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 30px 40px; background-color: #005696; text-align: center;">
|
|
||||||
<!-- Contact Info -->
|
|
||||||
<p style="margin: 0 0 15px 0; font-size: 14px; color: #ffffff; line-height: 1.6;">
|
|
||||||
<strong>{% trans "Al Hammadi Hospital" %}</strong><br>
|
|
||||||
{% block footer_links %}
|
|
||||||
Patient Experience Management Department
|
|
||||||
{% endblock %}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin-bottom: 15px;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 0 10px;">
|
|
||||||
<a href="#" style="font-size: 13px; color: #ffffff; text-decoration: underline;">{% trans "Website" %}</a>
|
|
||||||
</td>
|
|
||||||
<td style="padding: 0 10px;">
|
|
||||||
<a href="#" style="font-size: 13px; color: #ffffff; text-decoration: underline;">{% trans "Contact Us" %}</a>
|
|
||||||
</td>
|
|
||||||
<td style="padding: 0 10px;">
|
|
||||||
<a href="#" style="font-size: 13px; color: #ffffff; text-decoration: underline;">{% trans "Privacy Policy" %}</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- Copyright -->
|
|
||||||
<p style="margin: 0; font-size: 12px; color: #eef6fb; line-height: 1.5;">
|
|
||||||
{% block copyright %}
|
|
||||||
© {% now "Y" %} {% trans "Al Hammadi Hospital. All rights reserved." %}
|
© {% now "Y" %} {% trans "Al Hammadi Hospital. All rights reserved." %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Powered by -->
|
|
||||||
<p style="margin: 8px 0 0 0; font-size: 11px; color: #eef6fb;">
|
|
||||||
Powered by <a href="https://tenhal.sa" style="color: #ffffff; text-decoration: underline;">tenhal.sa</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Unsubscribe -->
|
|
||||||
{% block unsubscribe %}
|
|
||||||
<p style="margin: 15px 0 0 0; font-size: 12px; color: #eef6fb;">
|
|
||||||
<a href="{{ unsubscribe_url|default:'#' }}" style="color: #eef6fb; text-decoration: underline;">{% trans "Unsubscribe" %}</a> {% trans "from these emails" %}
|
|
||||||
</p>
|
|
||||||
{% endblock %}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
<!-- End Main Email Wrapper -->
|
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<!-- End Email Container -->
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -2,162 +2,79 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block title %}{% trans "Explanation Request - Al Hammadi Hospital" %}{% endblock %}
|
{% block title %}{% trans "Explanation Request - Al Hammadi Hospital" %}{% endblock %}
|
||||||
|
|
||||||
{% block preheader %}{% trans "You have been assigned to provide an explanation for a patient complaint" %}{% endblock %}
|
{% block preheader %}{% trans "You have been assigned to provide an explanation for a patient complaint" %}{% endblock %}
|
||||||
|
|
||||||
{% block hero_title %}{% trans "Explanation Request" %}{% endblock %}
|
|
||||||
|
|
||||||
{% block hero_subtitle %}{% trans "Please review the complaint details and submit your response" %}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Greeting -->
|
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">{% trans "Explanation Request" %}</h1>
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
|
||||||
<tr>
|
<p style="margin: 0 0 15px; font-size: 16px; color: #1e293b; line-height: 1.6;">
|
||||||
<td style="padding-bottom: 20px;">
|
{% trans "Dear" %} <strong>{{ staff_name }}</strong>,
|
||||||
<p style="margin: 0; font-size: 16px; color: #1e293b; line-height: 1.6;">
|
</p>
|
||||||
{% trans "Dear" %} <strong>{{ staff_name }}</strong>,
|
<p style="margin: 0 0 20px; font-size: 16px; color: #475569; line-height: 1.6;">
|
||||||
</p>
|
{% trans "You have been assigned to provide an explanation for the following patient complaint. Please review the details and submit your response using the button below." %}
|
||||||
<p style="margin: 15px 0 0 0; font-size: 16px; color: #64748b; line-height: 1.6;">
|
</p>
|
||||||
{% trans "You have been assigned to provide an explanation for the following patient complaint. Please review the details and submit your response using the button below." %}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{% if custom_message %}
|
{% if custom_message %}
|
||||||
<!-- Custom Message -->
|
<div style="padding: 15px; background-color: #eef6fb; border-radius: 6px; margin-bottom: 20px;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 20px 0;">
|
<p style="margin: 0; font-size: 14px; color: #005696; line-height: 1.6;">
|
||||||
<tr>
|
<strong>{% trans "Note from PX Team:" %}</strong> {{ custom_message }}
|
||||||
<td style="padding: 15px; background-color: #eef6fb; border-left: 4px solid #005696; border-radius: 5px;">
|
</p>
|
||||||
<p style="margin: 0 0 10px 0; font-size: 14px; font-weight: 600; color: #005696;">
|
</div>
|
||||||
{% trans "Note from PX Team:" %}
|
|
||||||
</p>
|
|
||||||
<p style="margin: 0; font-size: 14px; color: #1e293b; line-height: 1.6;">
|
|
||||||
{{ custom_message }}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Complaint Details Card -->
|
<!-- Complaint Details -->
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 25px 0; background-color: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0;">
|
<div style="background-color: #f8fafc; border-radius: 6px; padding: 20px; margin-bottom: 25px;">
|
||||||
<tr>
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
<td style="padding: 20px;">
|
<tr>
|
||||||
<h3 style="margin: 0 0 15px 0; font-size: 18px; font-weight: 600; color: #005696; text-align: center;">
|
<td style="padding: 6px 0; font-size: 14px; color: #64748b;">
|
||||||
{% trans "Complaint Details" %}
|
<strong style="color: #005696;">{% trans "Reference:" %}</strong> #{{ complaint_id }}
|
||||||
</h3>
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 6px 0; font-size: 14px; color: #64748b;">
|
||||||
|
<strong style="color: #005696;">{% trans "Title:" %}</strong> {{ complaint_title }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 6px 0; font-size: 14px; color: #64748b;">
|
||||||
|
<strong style="color: #005696;">{% trans "Patient:" %}</strong> {{ patient_name }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 6px 0; font-size: 14px; color: #64748b;">
|
||||||
|
<strong style="color: #005696;">{% trans "Department:" %}</strong> {{ department_name }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 6px 0; font-size: 14px; color: #64748b;">
|
||||||
|
<strong style="color: #005696;">{% trans "Deadline:" %}</strong> {{ created_date }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% if description %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 6px 0; font-size: 14px; color: #64748b;">
|
||||||
|
<strong style="color: #005696;">{% trans "Description:" %}</strong><br>
|
||||||
|
{{ description }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Detail Row 1 -->
|
<!-- CTA Button -->
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 10px;">
|
|
||||||
<tr>
|
|
||||||
<td width="120" style="padding: 8px 0; font-size: 14px; color: #64748b; font-weight: 500;">
|
|
||||||
{% trans "Reference:" %}
|
|
||||||
</td>
|
|
||||||
<td style="padding: 8px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
|
|
||||||
#{{ complaint_id }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; font-size: 14px; color: #64748b; font-weight: 500;">
|
|
||||||
{% trans "Title:" %}
|
|
||||||
</td>
|
|
||||||
<td style="padding: 8px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
|
|
||||||
{{ complaint_title }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; font-size: 14px; color: #64748b; font-weight: 500;">
|
|
||||||
{% trans "Patient:" %}
|
|
||||||
</td>
|
|
||||||
<td style="padding: 8px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
|
|
||||||
{{ patient_name }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; font-size: 14px; color: #64748b; font-weight: 500;">
|
|
||||||
{% trans "Hospital:" %}
|
|
||||||
</td>
|
|
||||||
<td style="padding: 8px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
|
|
||||||
{{ hospital_name }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; font-size: 14px; color: #64748b; font-weight: 500;">
|
|
||||||
{% trans "Department:" %}
|
|
||||||
</td>
|
|
||||||
<td style="padding: 8px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
|
|
||||||
{{ department_name }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; font-size: 14px; color: #64748b; font-weight: 500;">
|
|
||||||
{% trans "Category:" %}
|
|
||||||
</td>
|
|
||||||
<td style="padding: 8px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
|
|
||||||
{{ category }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; font-size: 14px; color: #64748b; font-weight: 500;">
|
|
||||||
{% trans "Status:" %}
|
|
||||||
</td>
|
|
||||||
<td style="padding: 8px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
|
|
||||||
{{ status }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; font-size: 14px; color: #64748b; font-weight: 500;">
|
|
||||||
{% trans "Date:" %}
|
|
||||||
</td>
|
|
||||||
<td style="padding: 8px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
|
|
||||||
{{ created_date }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
{% if description %}
|
|
||||||
<tr>
|
|
||||||
<td valign="top" style="padding: 8px 0; font-size: 14px; color: #64748b; font-weight: 500;">
|
|
||||||
{% trans "Description:" %}
|
|
||||||
</td>
|
|
||||||
<td style="padding: 8px 0; font-size: 14px; color: #1e293b; line-height: 1.6;">
|
|
||||||
{{ description }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- Important Information -->
|
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 15px; background-color: #eef6fb; border-left: 4px solid #005696; border-radius: 8px;">
|
<td align="center">
|
||||||
<p style="margin: 0 0 10px 0; font-size: 14px; font-weight: 600; color: #005696;">
|
<a href="{{ explanation_url }}"
|
||||||
{% trans "Important Information:" %}
|
style="display: inline-block; padding: 12px 32px; font-size: 16px; font-weight: 600; color: #ffffff; text-decoration: none; border-radius: 6px; background-color: #005696;">
|
||||||
</p>
|
{% trans "Submit Your Explanation" %}
|
||||||
<ul style="margin: 0; padding-left: 20px; font-size: 14px; color: #1e293b; line-height: 1.8;">
|
</a>
|
||||||
<li>{% trans "This link is unique and can only be used once" %}</li>
|
|
||||||
<li>{% trans "You can attach supporting documents to your explanation" %}</li>
|
|
||||||
<li>{% trans "Your response will be reviewed by the PX team" %}</li>
|
|
||||||
<li>{% trans "Please submit your explanation at your earliest convenience" %}</li>
|
|
||||||
</ul>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block cta_url %}{{ explanation_url }}{% endblock %}
|
<p style="margin: 25px 0 0; font-size: 13px; color: #94a3b8; line-height: 1.5;">
|
||||||
{% block cta_text %}{% trans "Submit Your Explanation" %}{% endblock %}
|
{% trans "If you have any questions, please contact the PX team." %}<br>
|
||||||
|
{% trans "This is an automated email. Please do not reply." %}
|
||||||
{% block info_title %}{% trans "Need Assistance?" %}{% endblock %}
|
</p>
|
||||||
{% block info_content %}
|
|
||||||
{% trans "If you have any questions or concerns, please contact the PX team directly." %}<br>
|
|
||||||
<strong>{% trans "Note:" %}</strong> {% trans "This is an automated email. Please do not reply directly to this message." %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block footer_address %}
|
|
||||||
PX360 Complaint Management System<br>
|
|
||||||
Al Hammadi Hospital
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -1,154 +1,56 @@
|
|||||||
{% extends 'emails/base_email_template.html' %}
|
{% extends 'emails/base_email_template.html' %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block title %}{% trans "New Observation Notification - Al Hammadi Hospital" %}{% endblock %}
|
{% block title %}{% trans "New Observation Submitted - Al Hammadi Hospital" %}{% endblock %}
|
||||||
|
{% block preheader %}{% trans "A new observation requires your review and triage" %}{% endblock %}
|
||||||
{% block preheader %}{% trans "A new observation has been submitted and requires review." %}{% endblock %}
|
|
||||||
|
|
||||||
{% block hero_title %}{% trans "New Observation Submitted" %}{% endblock %}
|
|
||||||
|
|
||||||
{% block hero_subtitle %}{% trans "A new observation requires your review and triage" %}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">{% trans "New Observation Submitted" %}</h1>
|
||||||
<tr>
|
|
||||||
<td style="padding-bottom: 20px;">
|
|
||||||
<p style="margin: 0 0 15px 0; font-size: 16px; color: #1e293b; line-height: 1.6;">
|
|
||||||
{% trans "Dear" %} <strong>{{ admin_name|default:'Admin' }}</strong>,
|
|
||||||
</p>
|
|
||||||
<p style="margin: 0; font-size: 16px; color: #64748b; line-height: 1.6;">
|
|
||||||
{% trans "A new observation has been submitted and requires your review. Please assess the details below and take appropriate action." %}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 25px 0;">
|
<p style="margin: 0 0 15px; font-size: 16px; color: #1e293b; line-height: 1.6;">
|
||||||
<tr>
|
{% trans "Dear" %} <strong>{{ recipient_name|default:'Colleague' }}</strong>,
|
||||||
<td style="padding: 20px; background-color: #f8fafc; border-left: 4px solid #005696; border-radius: 8px;">
|
</p>
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
<p style="margin: 0 0 20px; font-size: 16px; color: #475569; line-height: 1.6;">
|
||||||
<tr>
|
{% trans "A new observation has been submitted and requires your review. Please review the details below." %}
|
||||||
<td style="padding: 8px 0;">
|
</p>
|
||||||
<p style="margin: 0; font-size: 14px; color: #64748b;">
|
|
||||||
<strong style="color: #005696;">{% trans "Tracking Code" %}:</strong> {{ observation.tracking_code }}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% if observation.title %}
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0;">
|
|
||||||
<p style="margin: 0; font-size: 14px; color: #1e293b;">
|
|
||||||
<strong style="color: #005696;">{% trans "Title" %}:</strong> {{ observation.title }}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0;">
|
|
||||||
<p style="margin: 0; font-size: 14px; color: #64748b;">
|
|
||||||
<strong style="color: #005696;">{% trans "Category" %}:</strong>
|
|
||||||
{% if observation.category %}{{ observation.category.name_en }}{% else %}N/A{% endif %}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0;">
|
|
||||||
<p style="margin: 0; font-size: 14px; color: #64748b;">
|
|
||||||
<strong style="color: #005696;">{% trans "Severity" %}:</strong>
|
|
||||||
<span style="color: #005696; font-weight: 600;">
|
|
||||||
{{ observation.get_severity_display }}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0;">
|
|
||||||
<p style="margin: 0; font-size: 14px; color: #64748b;">
|
|
||||||
<strong style="color: #005696;">{% trans "Location" %}:</strong>
|
|
||||||
{{ observation.location_text|default:"N/A" }}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% if observation.hospital %}
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0;">
|
|
||||||
<p style="margin: 0; font-size: 14px; color: #64748b;">
|
|
||||||
<strong style="color: #005696;">{% trans "Hospital" %}:</strong> {{ observation.hospital.name }}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0;">
|
|
||||||
<p style="margin: 0; font-size: 14px; color: #64748b;">
|
|
||||||
<strong style="color: #005696;">{% trans "Reporter" %}:</strong> {{ observation.reporter_display }}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0;">
|
|
||||||
<p style="margin: 0; font-size: 14px; color: #64748b;">
|
|
||||||
<strong style="color: #005696;">{% trans "Submitted" %}:</strong>
|
|
||||||
{{ observation.created_at|date:"F d, Y H:i" }}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{% if observation.description %}
|
<!-- Observation Details -->
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 25px 0;">
|
<div style="background-color: #f8fafc; border-radius: 6px; padding: 20px; margin-bottom: 20px;">
|
||||||
<tr>
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
<td style="padding: 20px; background-color: #f8fafc; border-radius: 8px;">
|
<tr>
|
||||||
<p style="margin: 0 0 10px 0; font-size: 14px; font-weight: 600; color: #005696;">
|
<td style="padding: 6px 0; font-size: 14px; color: #64748b;">
|
||||||
{% trans "Description" %}
|
<strong style="color: #005696;">{% trans "Tracking Code:" %}</strong> {{ observation.tracking_code }}
|
||||||
</p>
|
</td>
|
||||||
<p style="margin: 0; font-size: 14px; color: #1e293b; line-height: 1.6; white-space: pre-wrap;">
|
</tr>
|
||||||
{{ observation.description|truncatechars:1000 }}
|
{% if observation.title %}
|
||||||
</p>
|
<tr>
|
||||||
</td>
|
<td style="padding: 6px 0; font-size: 14px; color: #64748b;">
|
||||||
</tr>
|
<strong style="color: #005696;">{% trans "Title:" %}</strong> {{ observation.title }}
|
||||||
</table>
|
</td>
|
||||||
{% endif %}
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 6px 0; font-size: 14px; color: #64748b;">
|
||||||
|
<strong style="color: #005696;">{% trans "Category:" %}</strong>
|
||||||
|
{% if observation.category %}{{ observation.category.name_en }}{% else %}N/A{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 6px 0; font-size: 14px; color: #64748b;">
|
||||||
|
<strong style="color: #005696;">{% trans "Status:" %}</strong> {{ observation.get_status_display }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% if observation.assigned_department %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 6px 0; font-size: 14px; color: #64748b;">
|
||||||
|
<strong style="color: #005696;">{% trans "Department:" %}</strong> {{ observation.assigned_department.name }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 25px 0;">
|
<p style="margin: 0; font-size: 13px; color: #94a3b8; line-height: 1.5;">
|
||||||
<tr>
|
{% trans "This is an automated notification. Please log in to PX360 for full details." %}
|
||||||
<td style="padding: 15px 20px; background-color: #eef6fb; border-radius: 8px;">
|
</p>
|
||||||
<p style="margin: 0; font-size: 14px; color: #005696; line-height: 1.6;">
|
|
||||||
{% trans "Please review this observation and assign it to the appropriate team member for further action." %}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-top: 25px;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 10px 0; font-size: 11px; color: #94a3b8; border-top: 1px solid #e2e8f0; padding-top: 20px;">
|
|
||||||
<p style="margin: 0 0 5px 0; font-size: 13px; color: #64748b; font-weight: 600; direction: rtl; text-align: right;">
|
|
||||||
تم إرسال ملاحظة جديدة - {{ observation.tracking_code }}
|
|
||||||
</p>
|
|
||||||
<p style="margin: 0; font-size: 12px; color: #94a3b8; direction: rtl; text-align: right; line-height: 1.8;">
|
|
||||||
تم إرسال ملاحظة جديدة وتتطلب مراجعتكم. يرجى الاطلاع على التفاصيل أدناه واتخاذ الإجراء المناسب.<br>
|
|
||||||
{% if observation.category %}التصنيف: {{ observation.category.name_ar }}{% endif %}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block cta_url %}{{ observation_url }}{% endblock %}
|
|
||||||
{% block cta_text %}{% trans "View Observation" %}{% endblock %}
|
|
||||||
|
|
||||||
{% block info_title %}{% trans "Notification Details" %}{% endblock %}
|
|
||||||
{% block info_content %}
|
|
||||||
<strong>{% trans "Type:" %}</strong> New Observation<br>
|
|
||||||
<strong>{% trans "Time:" %}</strong> {{ current_time }}<br>
|
|
||||||
{% trans "This is an automated notification from the PX 360 system." %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block footer_address %}
|
|
||||||
PX360 Observation Management System<br>
|
|
||||||
Al Hammadi Hospital
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
8
templates/emails/simple_email.html
Normal file
8
templates/emails/simple_email.html
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{% extends 'emails/base_email_template.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{{ subject|default:"Al Hammadi Hospital" }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{{ content_html|safe }}
|
||||||
|
{% endblock %}
|
||||||
@ -2,94 +2,42 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block title %}{% trans "Patient Survey Invitation - Al Hammadi Hospital" %}{% endblock %}
|
{% block title %}{% trans "Patient Survey Invitation - Al Hammadi Hospital" %}{% endblock %}
|
||||||
|
|
||||||
{% block preheader %}{% trans "We value your feedback! Please share your experience with us." %}{% endblock %}
|
{% block preheader %}{% trans "We value your feedback! Please share your experience with us." %}{% endblock %}
|
||||||
|
|
||||||
{% block hero_title %}{% trans "We Value Your Feedback" %}{% endblock %}
|
|
||||||
|
|
||||||
{% block hero_subtitle %}{% trans "Help us improve our services by sharing your recent experience at Al Hammadi Hospital" %}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">{% trans "We Value Your Feedback" %}</h1>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 15px; font-size: 16px; color: #1e293b; line-height: 1.6;">
|
||||||
|
{% trans "Dear" %} <strong>{{ patient_name|default:_("Valued Patient") }}</strong>,
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0 0 20px; font-size: 16px; color: #475569; line-height: 1.6;">
|
||||||
|
{% blocktrans with visit=visit_date|default:_("your recent visit") %}Thank you for choosing Al Hammadi Hospital for your healthcare needs. We hope your recent visit on {{ visit }} met your expectations.{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0 0 25px; font-size: 16px; color: #475569; line-height: 1.6;">
|
||||||
|
{% trans "We would greatly appreciate it if you could take a few minutes to complete our satisfaction survey. Your feedback helps us improve our services." %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Survey Info -->
|
||||||
|
<div style="background-color: #f8fafc; border-radius: 6px; padding: 15px; margin-bottom: 25px;">
|
||||||
|
<p style="margin: 0; font-size: 14px; color: #64748b; line-height: 1.6;">
|
||||||
|
⏱ {% trans "Takes only 3-5 minutes" %}<br>
|
||||||
|
🔒 {% trans "Your responses are confidential" %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding-bottom: 20px;">
|
<td align="center">
|
||||||
<p style="margin: 0; font-size: 16px; color: #1e293b; line-height: 1.6;">
|
<a href="{{ survey_url }}"
|
||||||
{% trans "Dear" %} <strong>{{ patient_name|default:_("Valued Patient") }}</strong>,
|
style="display: inline-block; padding: 12px 32px; font-size: 16px; font-weight: 600; color: #ffffff; text-decoration: none; border-radius: 6px; background-color: #005696;">
|
||||||
</p>
|
{% trans "Take Survey" %}
|
||||||
<p style="margin: 15px 0 0 0; font-size: 16px; color: #64748b; line-height: 1.6;">
|
</a>
|
||||||
{% blocktrans with visit=visit_date|default:_("your recent visit") %}Thank you for choosing Al Hammadi Hospital for your healthcare needs. We hope your recent visit on {{ visit }} met your expectations.{% endblocktrans %}
|
|
||||||
</p>
|
|
||||||
<p style="margin: 15px 0 0 0; font-size: 16px; color: #64748b; line-height: 1.6;">
|
|
||||||
{% blocktrans with duration=survey_duration|default:_("3-5") %}Your feedback is invaluable in helping us maintain and improve the quality of care we provide. Would you mind taking {{ duration }} minutes to complete our patient experience survey?{% endblocktrans %}
|
|
||||||
</p>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-top: 25px;">
|
<p style="margin: 25px 0 0; font-size: 13px; color: #94a3b8; line-height: 1.5;">
|
||||||
<tr>
|
{% trans "Thank you for your time and feedback." %}
|
||||||
<td>
|
</p>
|
||||||
<h3 style="margin: 0 0 15px 0; font-size: 18px; font-weight: 600; color: #005696;">
|
|
||||||
{% trans "Why Your Feedback Matters:" %}
|
|
||||||
</h3>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;">
|
|
||||||
<tr>
|
|
||||||
<td width="40" valign="top" style="padding-right: 10px;">
|
|
||||||
<span style="display: inline-block; width: 32px; height: 32px; background-color: #eef6fb; border-radius: 50%; text-align: center; line-height: 32px; font-size: 18px;">✓</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
|
|
||||||
<strong>{% trans "Improve Patient Care:" %}</strong> {% trans "Your insights help us enhance our services" %}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;">
|
|
||||||
<tr>
|
|
||||||
<td width="40" valign="top" style="padding-right: 10px;">
|
|
||||||
<span style="display: inline-block; width: 32px; height: 32px; background-color: #eef6fb; border-radius: 50%; text-align: center; line-height: 32px; font-size: 18px;">★</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
|
|
||||||
<strong>{% trans "Better Experience:" %}</strong> {% trans "Help us create a better experience for all patients" %}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td width="40" valign="top" style="padding-right: 10px;">
|
|
||||||
<span style="display: inline-block; width: 32px; height: 32px; background-color: #eef6fb; border-radius: 50%; text-align: center; line-height: 32px; font-size: 18px;">❤</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
|
|
||||||
<strong>{% trans "Quality Standards:" %}</strong> {% trans "Contribute to our commitment to excellence" %}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block cta_url %}{{ survey_link|default:'#' }}{% endblock %}
|
|
||||||
{% block cta_text %}{% trans "Start Survey" %}{% endblock %}
|
|
||||||
|
|
||||||
{% block info_title %}{% trans "Survey Information" %}{% endblock %}
|
|
||||||
{% block info_content %}
|
|
||||||
<strong>{% trans "Duration:" %}</strong> {% trans "Approximately" %} {{ survey_duration|default:_("3-5") }} {% trans "minutes" %}<br>
|
|
||||||
<strong>{% trans "Confidentiality:" %}</strong> {% trans "Your responses are completely confidential" %}<br>
|
|
||||||
<strong>{% trans "Deadline:" %}</strong> {% trans "Please complete by" %} {{ deadline|default:_("the end of this week") }}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block footer_address %}
|
|
||||||
{% trans "Patient Experience Management Department" %}<br>
|
|
||||||
{% trans "Al Hammadi Hospital" %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -353,6 +353,11 @@
|
|||||||
<span class="sidebar-text whitespace-nowrap">{% trans "Leaderboard" %}</span>
|
<span class="sidebar-text whitespace-nowrap">{% trans "Leaderboard" %}</span>
|
||||||
</a>
|
</a>
|
||||||
{% if user.is_px_admin or user.is_hospital_admin %}
|
{% if user.is_px_admin or user.is_hospital_admin %}
|
||||||
|
<a href="{% url 'physicians:doctor_rating_fetch' %}"
|
||||||
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if request.resolver_match.url_name == 'doctor_rating_fetch' %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
||||||
|
<i data-lucide="cloud-download" class="w-4 h-4 flex-shrink-0"></i>
|
||||||
|
<span class="sidebar-text whitespace-nowrap">{% trans "Fetch Ratings" %}</span>
|
||||||
|
</a>
|
||||||
<a href="{% url 'physicians:doctor_rating_import' %}"
|
<a href="{% url 'physicians:doctor_rating_import' %}"
|
||||||
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'import' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'import' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
||||||
<i data-lucide="upload" class="w-4 h-4 flex-shrink-0"></i>
|
<i data-lucide="upload" class="w-4 h-4 flex-shrink-0"></i>
|
||||||
|
|||||||
@ -55,7 +55,7 @@
|
|||||||
|
|
||||||
/* Background Pattern */
|
/* Background Pattern */
|
||||||
.public-bg {
|
.public-bg {
|
||||||
background: linear-gradient(135deg, #005696 0%, #007bbd 50%, #00a8e8 100%);
|
background: linear-gradient(135deg, #007bbd 0%, #005696 100%);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
@ -63,15 +63,28 @@
|
|||||||
|
|
||||||
.public-bg::before {
|
.public-bg::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: 0;
|
top: -100px;
|
||||||
left: 0;
|
right: -100px;
|
||||||
right: 0;
|
width: 200px;
|
||||||
bottom: 0;
|
height: 200px;
|
||||||
background-image:
|
background: rgba(255,255,255,0.05);
|
||||||
radial-gradient(circle at 20% 50%, rgba(255,255,255,0.1) 0%, transparent 50%),
|
border-radius: 9999px;
|
||||||
radial-gradient(circle at 80% 80%, rgba(255,255,255,0.1) 0%, transparent 50%);
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-bg::after {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
bottom: -75px;
|
||||||
|
left: -75px;
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border-radius: 9999px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Glass morphism effect */
|
/* Glass morphism effect */
|
||||||
@ -206,16 +219,16 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<footer class="glass-card mt-12 py-6">
|
<footer class="mt-12 py-6 bg-white/5 backdrop-blur-sm">
|
||||||
<div class="container mx-auto px-4 text-center">
|
<div class="container mx-auto px-4 text-center">
|
||||||
<p class="text-slate text-sm">
|
<p class="text-white/70 text-sm">
|
||||||
© {% now "Y" %} <span class="font-semibold text-navy">Al Hammadi Hospital</span> - PX360. {% trans "All rights reserved." %}
|
© {% now "Y" %} <span class="font-semibold text-white">Al Hammadi Hospital</span> - PX360. {% trans "All rights reserved." %}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-slate text-xs mt-2">
|
<p class="text-white/50 text-xs mt-2">
|
||||||
{% trans "Your feedback helps us improve our services" %}
|
{% trans "Your feedback helps us improve our services" %}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-slate text-xs mt-1">
|
<p class="text-white/50 text-xs mt-1">
|
||||||
Powered by <a href="https://tenhal.sa" target="_blank" class="text-navy hover:underline font-medium">tenhal.sa</a>
|
Powered by <a href="https://tenhal.sa" target="_blank" class="text-white hover:text-white/80 hover:underline font-medium">tenhal.sa</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@ -90,9 +90,9 @@
|
|||||||
<a href="{% url 'observations:observation_create' %}" class="bg-white text-[#005696] px-5 py-2.5 rounded-xl text-sm font-bold hover:bg-gray-100 flex items-center gap-2 transition">
|
<a href="{% url 'observations:observation_create' %}" class="bg-white text-[#005696] px-5 py-2.5 rounded-xl text-sm font-bold hover:bg-gray-100 flex items-center gap-2 transition">
|
||||||
<i data-lucide="plus-circle" class="w-4 h-4"></i> {% trans "New Observation" %}
|
<i data-lucide="plus-circle" class="w-4 h-4"></i> {% trans "New Observation" %}
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'observations:category_list' %}" class="bg-white text-[#005696] px-5 py-2.5 rounded-xl text-sm font-bold hover:bg-gray-100 flex items-center gap-2 transition">
|
{% comment %} <a href="{% url 'observations:category_list' %}" class="bg-white text-[#005696] px-5 py-2.5 rounded-xl text-sm font-bold hover:bg-gray-100 flex items-center gap-2 transition">
|
||||||
<i data-lucide="tags" class="w-4 h-4"></i> {% trans "Categories" %}
|
<i data-lucide="tags" class="w-4 h-4"></i> {% trans "Categories" %}
|
||||||
</a>
|
</a> {% endcomment %}
|
||||||
<a href="{% url 'core:public_submit_landing' %}" class="bg-white/20 text-white border-2 border-white/50 px-5 py-2.5 rounded-xl text-sm font-bold hover:bg-white/30 flex items-center gap-2 transition" target="_blank">
|
<a href="{% url 'core:public_submit_landing' %}" class="bg-white/20 text-white border-2 border-white/50 px-5 py-2.5 rounded-xl text-sm font-bold hover:bg-white/30 flex items-center gap-2 transition" target="_blank">
|
||||||
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Public Form" %}
|
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Public Form" %}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@ -42,7 +42,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="min-h-screen bg-gradient-to-br from-light via-blue-50 to-light py-8 px-4 sm:px-6 lg:px-8">
|
<div class="min-h-screen py-8 px-4 sm:px-6 lg:px-8">
|
||||||
<div class="max-w-2xl mx-auto">
|
<div class="max-w-2xl mx-auto">
|
||||||
<!-- Main Card -->
|
<!-- Main Card -->
|
||||||
<div class="glass-card rounded-3xl shadow-2xl p-8 md:p-10 animate-fade-in">
|
<div class="glass-card rounded-3xl shadow-2xl p-8 md:p-10 animate-fade-in">
|
||||||
|
|||||||
@ -55,7 +55,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="min-h-screen bg-gradient-to-br from-light via-blue-50 to-light py-12 px-4 sm:px-6 lg:px-8">
|
<div class="min-h-screen py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div class="max-w-3xl mx-auto">
|
<div class="max-w-3xl mx-auto">
|
||||||
<!-- Success Icon -->
|
<!-- Success Icon -->
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
|
|||||||
@ -9,11 +9,6 @@ header.glass-card {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.public-bg {
|
|
||||||
background: linear-gradient(to bottom right, #005696, #007bbd, #eef6fb) !important;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-switcher {
|
.lang-switcher {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 1rem;
|
top: 1rem;
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
{% extends 'layouts/base.html' %}
|
{% extends 'layouts/base.html' %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
{% load national_id_tags %}
|
||||||
|
|
||||||
{% block title %}{{ patient.get_full_name }} - {% trans "Patient Details" %}{% endblock %}
|
{% block title %}{{ patient.get_full_name }} - {% trans "Patient Details" %}{% endblock %}
|
||||||
|
|
||||||
@ -161,10 +162,16 @@
|
|||||||
<span class="font-mono font-bold text-navy">{{ patient.mrn }}</span>
|
<span class="font-mono font-bold text-navy">{{ patient.mrn }}</span>
|
||||||
</span>
|
</span>
|
||||||
{% if patient.national_id %}
|
{% if patient.national_id %}
|
||||||
<span class="flex items-center gap-2 bg-slate-50 px-3 py-1.5 rounded-lg border border-slate-200">
|
<span class="flex items-center gap-2 bg-slate-50 px-3 py-1.5 rounded-lg border border-slate-200" x-data="{ revealed: false }">
|
||||||
<i data-lucide="credit-card" class="w-4 h-4 text-blue"></i>
|
<i data-lucide="credit-card" class="w-4 h-4 text-blue"></i>
|
||||||
<span class="text-slate text-xs font-semibold uppercase">{% trans "SSN" %}:</span>
|
<span class="text-slate text-xs font-semibold uppercase">{% trans "SSN" %}:</span>
|
||||||
<span class="font-mono font-bold text-navy">{{ patient.national_id }}</span>
|
<span class="font-mono font-bold text-navy" x-text="revealed ? '{{ patient.national_id }}' : '{{ patient.national_id|mask_id }}'">{{ patient.national_id|mask_id }}</span>
|
||||||
|
{% if user.is_superuser %}
|
||||||
|
<button @click="revealed = !revealed" class="text-blue hover:text-navy" title="{% trans 'Toggle' %}">
|
||||||
|
<i x-show="!revealed" data-lucide="eye" class="w-3.5 h-3.5 inline"></i>
|
||||||
|
<i x-show="revealed" data-lucide="eye-off" class="w-3.5 h-3.5 inline"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if patient.phone %}
|
{% if patient.phone %}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
{% extends "layouts/base.html" %}
|
{% extends "layouts/base.html" %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
{% load national_id_tags %}
|
||||||
|
|
||||||
{% block title %}{% trans "Patients" %} - PX360{% endblock %}
|
{% block title %}{% trans "Patients" %} - PX360{% endblock %}
|
||||||
|
|
||||||
@ -262,7 +263,6 @@
|
|||||||
<th class="px-4 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "National ID" %}</th>
|
<th class="px-4 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "National ID" %}</th>
|
||||||
<th class="px-4 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Contact" %}</th>
|
<th class="px-4 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Contact" %}</th>
|
||||||
<th class="px-4 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Nationality" %}</th>
|
<th class="px-4 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Nationality" %}</th>
|
||||||
<th class="px-4 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Hospital" %}</th>
|
|
||||||
<th class="px-4 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Status" %}</th>
|
<th class="px-4 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Status" %}</th>
|
||||||
<th class="px-4 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Actions" %}</th>
|
<th class="px-4 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Actions" %}</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -287,7 +287,19 @@
|
|||||||
<span class="font-mono text-sm font-medium text-navy bg-slate-50 px-2 py-1 rounded">{{ patient.mrn }}</span>
|
<span class="font-mono text-sm font-medium text-navy bg-slate-50 px-2 py-1 rounded">{{ patient.mrn }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-4">
|
<td class="px-4 py-4">
|
||||||
<span class="font-mono text-sm text-slate">{{ patient.national_id|default:"-" }}</span>
|
{% if patient.national_id %}
|
||||||
|
<span class="font-mono text-sm text-slate" data-masked="{{ patient.national_id|mask_id }}" data-full="{{ patient.national_id }}" x-data="{ revealed: false }">
|
||||||
|
<span x-text="revealed ? $el.closest('[data-full]').dataset.full : $el.closest('[data-masked]').dataset.masked">{{ patient.national_id|mask_id }}</span>
|
||||||
|
{% if user.is_superuser %}
|
||||||
|
<button @click="revealed = !revealed" class="ml-1 text-blue hover:text-navy" title="{% trans 'Toggle' %}">
|
||||||
|
<i x-show="!revealed" data-lucide="eye" class="w-3.5 h-3.5 inline"></i>
|
||||||
|
<i x-show="revealed" data-lucide="eye-off" class="w-3.5 h-3.5 inline"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="font-mono text-sm text-slate">-</span>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-4">
|
<td class="px-4 py-4">
|
||||||
{% if patient.phone %}
|
{% if patient.phone %}
|
||||||
@ -306,12 +318,6 @@
|
|||||||
<td class="px-4 py-4">
|
<td class="px-4 py-4">
|
||||||
<span class="text-sm text-slate">{{ patient.nationality|default:"-" }}</span>
|
<span class="text-sm text-slate">{{ patient.nationality|default:"-" }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-4">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<i data-lucide="building-2" class="w-3.5 h-3.5 text-slate"></i>
|
|
||||||
<span class="text-sm text-slate">{{ patient.primary_hospital.name|default:"-" }}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-4 text-center">
|
<td class="px-4 py-4 text-center">
|
||||||
<span class="status-badge {% if patient.status == 'active' %}bg-green-100 text-green-700{% else %}bg-slate-100 text-slate-700{% endif %}">
|
<span class="status-badge {% if patient.status == 'active' %}bg-green-100 text-green-700{% else %}bg-slate-100 text-slate-700{% endif %}">
|
||||||
{{ patient.get_status_display }}
|
{{ patient.get_status_display }}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
{% extends 'layouts/base.html' %}
|
{% extends 'layouts/base.html' %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load national_id_tags %}
|
||||||
|
|
||||||
{% block title %}{% trans "Visit Journey" %} - {{ visit.admission_id }} - {{ patient.get_full_name }}{% endblock %}
|
{% block title %}{% trans "Visit Journey" %} - {{ visit.admission_id }} - {{ patient.get_full_name }}{% endblock %}
|
||||||
|
|
||||||
@ -166,8 +167,8 @@
|
|||||||
{% if event.visit_category %}
|
{% if event.visit_category %}
|
||||||
<span class="text-[10px] font-bold bg-white text-slate-500 px-1.5 py-0.5 rounded border border-slate-200">{{ event.visit_category }}</span>
|
<span class="text-[10px] font-bold bg-white text-slate-500 px-1.5 py-0.5 rounded border border-slate-200">{{ event.visit_category }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if event.type %}
|
{% if event.event_type %}
|
||||||
<span class="text-xs font-semibold text-navy">{{ event.type }}</span>
|
<span class="text-xs font-semibold text-navy">{{ event.event_type }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if event.parsed_date or event.bill_date %}
|
{% if event.parsed_date or event.bill_date %}
|
||||||
@ -241,7 +242,7 @@
|
|||||||
{% if patient.national_id %}
|
{% if patient.national_id %}
|
||||||
<div class="flex items-center gap-2 text-sm text-slate">
|
<div class="flex items-center gap-2 text-sm text-slate">
|
||||||
<i data-lucide="credit-card" class="w-3.5 h-3.5 text-blue"></i>
|
<i data-lucide="credit-card" class="w-3.5 h-3.5 text-blue"></i>
|
||||||
<span class="font-mono">{{ patient.national_id }}</span>
|
<span class="font-mono">{{ patient.national_id|mask_id }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
221
templates/physicians/doctor_rating_fetch.html
Normal file
221
templates/physicians/doctor_rating_fetch.html
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Fetch Doctor Ratings from HIS" %} - PX360{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.field-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="p-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="mb-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-navy flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 bg-blue-100 rounded-xl flex items-center justify-center">
|
||||||
|
<i data-lucide="cloud-download" class="w-5 h-5 text-blue-600"></i>
|
||||||
|
</div>
|
||||||
|
{% trans "Fetch Doctor Ratings from HIS" %}
|
||||||
|
</h1>
|
||||||
|
<p class="text-slate mt-2 text-sm">{% trans "Fetch doctor ratings directly from HIS API by date range" %}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<a href="{% url 'physicians:doctor_rating_import' %}"
|
||||||
|
class="px-5 py-2.5 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-slate-50 hover:border-slate-300 transition flex items-center gap-2">
|
||||||
|
<i data-lucide="upload" class="w-4 h-4"></i>
|
||||||
|
{% trans "CSV Import" %}
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'physicians:doctor_rating_job_list' %}"
|
||||||
|
class="px-5 py-2.5 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-slate-50 hover:border-slate-300 transition flex items-center gap-2">
|
||||||
|
<i data-lucide="clock" class="w-4 h-4"></i>
|
||||||
|
{% trans "Import History" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<!-- Instructions & Form -->
|
||||||
|
<div class="lg:col-span-2 space-y-6">
|
||||||
|
<!-- Instructions Card -->
|
||||||
|
<section class="bg-white rounded-2xl shadow-sm border border-slate-100">
|
||||||
|
<div class="p-6 border-b border-slate-200">
|
||||||
|
<h3 class="text-lg font-bold text-navy flex items-center gap-2">
|
||||||
|
<i data-lucide="info" class="w-5 h-5 text-blue"></i>
|
||||||
|
{% trans "How It Works" %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold text-navy mb-3">{% trans "Fetch Process:" %}</h4>
|
||||||
|
<p class="text-slate text-sm mb-3">
|
||||||
|
{% trans "Ratings are fetched from the HIS FetchDoctorRatingMAPI endpoint for the selected date range." %}
|
||||||
|
</p>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li class="flex items-start gap-2 text-sm text-slate">
|
||||||
|
<i data-lucide="check-circle" class="w-4 h-4 text-green-600 mt-0.5 flex-shrink-0"></i>
|
||||||
|
{% trans "Select a date range and submit" %}
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start gap-2 text-sm text-slate">
|
||||||
|
<i data-lucide="check-circle" class="w-4 h-4 text-green-600 mt-0.5 flex-shrink-0"></i>
|
||||||
|
{% trans "A background job fetches and processes ratings" %}
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start gap-2 text-sm text-slate">
|
||||||
|
<i data-lucide="check-circle" class="w-4 h-4 text-green-600 mt-0.5 flex-shrink-0"></i>
|
||||||
|
{% trans "Track progress on the job status page" %}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold text-navy mb-3">{% trans "HIS Response Fields:" %}</h4>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li class="flex items-start gap-2 text-sm text-slate">
|
||||||
|
<i data-lucide="asterisk" class="w-4 h-4 text-red-600 mt-0.5 flex-shrink-0"></i>
|
||||||
|
<span><strong class="text-navy">DoctorID</strong> / <strong class="text-navy">DoctorName</strong></span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start gap-2 text-sm text-slate">
|
||||||
|
<i data-lucide="asterisk" class="w-4 h-4 text-red-600 mt-0.5 flex-shrink-0"></i>
|
||||||
|
<span><strong class="text-navy">HospitalName</strong> - {% trans "Matched to PX360 hospital" %}</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start gap-2 text-sm text-slate">
|
||||||
|
<i data-lucide="asterisk" class="w-4 h-4 text-red-600 mt-0.5 flex-shrink-0"></i>
|
||||||
|
<span><strong class="text-navy">Rating</strong> - {% trans "1-5 rating value" %}</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start gap-2 text-sm text-slate">
|
||||||
|
<i data-lucide="circle" class="w-4 h-4 text-slate-400 mt-0.5 flex-shrink-0"></i>
|
||||||
|
<span><strong class="text-navy">RatingDate, Department</strong> - {% trans "Optional" %}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Fetch Form -->
|
||||||
|
<section class="bg-white rounded-2xl shadow-sm border border-slate-100">
|
||||||
|
<div class="p-6 border-b border-slate-200">
|
||||||
|
<h3 class="text-lg font-bold text-navy">{% trans "Select Date Range" %}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<form method="post" class="space-y-6" data-loading data-loading-text="{% trans 'Fetching...' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.hospital }}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label for="{{ form.from_date.id_for_label }}" class="field-label block mb-2">
|
||||||
|
{% trans "From Date" %} <span class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
{{ form.from_date }}
|
||||||
|
{% if form.from_date.errors %}
|
||||||
|
<div class="text-red-600 text-sm mt-1">{{ form.from_date.errors.0 }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<p class="text-slate text-xs mt-1">{% trans "Start date for fetching ratings" %}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="{{ form.to_date.id_for_label }}" class="field-label block mb-2">
|
||||||
|
{% trans "To Date" %} <span class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
{{ form.to_date }}
|
||||||
|
{% if form.to_date.errors %}
|
||||||
|
<div class="text-red-600 text-sm mt-1">{{ form.to_date.errors.0 }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<p class="text-slate text-xs mt-1">{% trans "End date for fetching ratings" %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="bg-red-50 border border-red-200 rounded-xl p-4">
|
||||||
|
{% for error in form.non_field_errors %}
|
||||||
|
<p class="text-red-600 text-sm">{{ error }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3 pt-4 border-t border-slate-200">
|
||||||
|
<button type="submit" class="px-6 py-2.5 bg-navy text-white rounded-xl font-semibold shadow-md hover:bg-blue transition flex items-center gap-2">
|
||||||
|
<i data-lucide="cloud-download" class="w-4 h-4"></i>
|
||||||
|
{% trans "Fetch Ratings" %}
|
||||||
|
</button>
|
||||||
|
<a href="{% url 'physicians:physician_list' %}" class="px-6 py-2.5 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-slate-50 hover:border-slate-300 transition flex items-center gap-2">
|
||||||
|
<i data-lucide="x-circle" class="w-4 h-4"></i>
|
||||||
|
{% trans "Cancel" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="lg:col-span-1">
|
||||||
|
<section class="bg-white rounded-2xl shadow-sm border border-slate-100 sticky top-8">
|
||||||
|
<div class="p-6 border-b border-slate-200">
|
||||||
|
<h3 class="text-lg font-bold text-navy flex items-center gap-2">
|
||||||
|
<i data-lucide="zap" class="w-5 h-5 text-yellow-600"></i>
|
||||||
|
{% trans "About HIS Fetch" %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<p class="text-slate text-sm mb-4">
|
||||||
|
{% trans "This fetches ratings directly from the HIS system via the FetchDoctorRatingMAPI API endpoint." %}
|
||||||
|
</p>
|
||||||
|
<div class="bg-slate-900 text-white p-4 rounded-xl font-mono text-sm space-y-2 mb-4">
|
||||||
|
<div class="text-slate-400 text-xs">{% trans "# HIS API Endpoint" %}</div>
|
||||||
|
<div class="text-green-400 break-all">GET ?FromDate=...&ToDate=...</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-slate text-sm">
|
||||||
|
<i data-lucide="info" class="w-4 h-4 inline mr-1 text-blue"></i>
|
||||||
|
{% trans "The monthly scheduled task uses the same process." %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Tips -->
|
||||||
|
<div class="p-6 border-t border-slate-200">
|
||||||
|
<h4 class="font-bold text-navy mb-3 flex items-center gap-2">
|
||||||
|
<i data-lucide="lightbulb" class="w-4 h-4 text-yellow-600"></i>
|
||||||
|
{% trans "Quick Tips" %}
|
||||||
|
</h4>
|
||||||
|
<ul class="space-y-3">
|
||||||
|
<li class="flex items-start gap-2 text-sm text-slate">
|
||||||
|
<i data-lucide="check" class="w-4 h-4 text-green-600 mt-0.5 flex-shrink-0"></i>
|
||||||
|
{% trans "Use narrow date ranges for faster results" %}
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start gap-2 text-sm text-slate">
|
||||||
|
<i data-lucide="check" class="w-4 h-4 text-green-600 mt-0.5 flex-shrink-0"></i>
|
||||||
|
{% trans "Hospitals are matched by name from HIS data" %}
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start gap-2 text-sm text-slate">
|
||||||
|
<i data-lucide="check" class="w-4 h-4 text-green-600 mt-0.5 flex-shrink-0"></i>
|
||||||
|
{% trans "Duplicate ratings for same doctor/date are skipped" %}
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start gap-2 text-sm text-slate">
|
||||||
|
<i data-lucide="check" class="w-4 h-4 text-green-600 mt-0.5 flex-shrink-0"></i>
|
||||||
|
{% trans "Job runs in background — you can leave the page" %}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
lucide.createIcons();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@ -30,7 +30,12 @@
|
|||||||
</h1>
|
</h1>
|
||||||
<p class="text-slate mt-2 text-sm">{% trans "Import doctor ratings from HIS CSV export" %}</p>
|
<p class="text-slate mt-2 text-sm">{% trans "Import doctor ratings from HIS CSV export" %}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="flex items-center gap-3">
|
||||||
|
<a href="{% url 'physicians:doctor_rating_fetch' %}"
|
||||||
|
class="px-5 py-2.5 bg-blue text-white rounded-xl font-semibold hover:bg-navy transition flex items-center gap-2">
|
||||||
|
<i data-lucide="cloud-download" class="w-4 h-4"></i>
|
||||||
|
{% trans "Fetch from HIS" %}
|
||||||
|
</a>
|
||||||
<a href="{% url 'physicians:doctor_rating_job_list' %}"
|
<a href="{% url 'physicians:doctor_rating_job_list' %}"
|
||||||
class="px-5 py-2.5 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-slate-50 hover:border-slate-300 transition flex items-center gap-2">
|
class="px-5 py-2.5 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-slate-50 hover:border-slate-300 transition flex items-center gap-2">
|
||||||
<i data-lucide="clock" class="w-4 h-4"></i>
|
<i data-lucide="clock" class="w-4 h-4"></i>
|
||||||
|
|||||||
@ -73,11 +73,18 @@
|
|||||||
<p class="text-white/80 text-sm">{% trans "Track doctor rating import jobs" %}</p>
|
<p class="text-white/80 text-sm">{% trans "Track doctor rating import jobs" %}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a href="{% url 'physicians:doctor_rating_import' %}"
|
<div class="flex items-center gap-3">
|
||||||
class="px-6 py-3 bg-white text-[#005696] rounded-xl font-semibold hover:bg-white/90 transition flex items-center gap-2 shadow-lg">
|
<a href="{% url 'physicians:doctor_rating_fetch' %}"
|
||||||
<i data-lucide="plus-circle" class="w-5 h-5"></i>
|
class="px-6 py-3 bg-white/20 text-white rounded-xl font-semibold hover:bg-white/30 transition flex items-center gap-2 border border-white/30">
|
||||||
{% trans "New Import" %}
|
<i data-lucide="cloud-download" class="w-5 h-5"></i>
|
||||||
</a>
|
{% trans "Fetch from HIS" %}
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'physicians:doctor_rating_import' %}"
|
||||||
|
class="px-6 py-3 bg-white text-[#005696] rounded-xl font-semibold hover:bg-white/90 transition flex items-center gap-2 shadow-lg">
|
||||||
|
<i data-lucide="plus-circle" class="w-5 h-5"></i>
|
||||||
|
{% trans "New Import" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -200,7 +200,7 @@
|
|||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="grid grid-cols-3 gap-4">
|
<div class="grid grid-cols-3 gap-4">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-value">{{ usage_stats.total_usage }}</div>
|
<div class="stat-value">{{ usage_stats.total }}</div>
|
||||||
<div class="stat-label">{% trans "Total Usage" %}</div>
|
<div class="stat-label">{% trans "Total Usage" %}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card" style="background: linear-gradient(135deg, #10b981, #34d399);">
|
<div class="stat-card" style="background: linear-gradient(135deg, #10b981, #34d399);">
|
||||||
@ -215,6 +215,162 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Complaints & Inquiries Tabs -->
|
||||||
|
<div class="info-card animate-in">
|
||||||
|
<div class="card-header flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
|
||||||
|
<i data-lucide="layout-list" class="w-5 h-5"></i>
|
||||||
|
{% trans "Related Items" %}
|
||||||
|
</h2>
|
||||||
|
<div class="flex gap-1 bg-white rounded-lg p-1 border border-slate-200">
|
||||||
|
<button type="button" onclick="switchTab('complaints')"
|
||||||
|
id="tab-btn-complaints"
|
||||||
|
class="tab-btn px-4 py-2 rounded-md text-sm font-semibold transition-all bg-navy text-white">
|
||||||
|
<i data-lucide="file-text" class="w-4 h-4 inline mr-1"></i>
|
||||||
|
{% trans "Complaints" %} ({{ complaints_count }})
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="switchTab('inquiries')"
|
||||||
|
id="tab-btn-inquiries"
|
||||||
|
class="tab-btn px-4 py-2 rounded-md text-sm font-semibold transition-all text-slate hover:bg-slate-50">
|
||||||
|
<i data-lucide="help-circle" class="w-4 h-4 inline mr-1"></i>
|
||||||
|
{% trans "Inquiries" %} ({{ inquiries_count }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-0">
|
||||||
|
<!-- Complaints Tab -->
|
||||||
|
<div id="tab-complaints" class="tab-panel">
|
||||||
|
{% if source_complaints %}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b-2 border-slate-200">
|
||||||
|
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Reference" %}</th>
|
||||||
|
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Description" %}</th>
|
||||||
|
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Status" %}</th>
|
||||||
|
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Priority" %}</th>
|
||||||
|
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Hospital" %}</th>
|
||||||
|
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Date" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
{% for complaint in source_complaints %}
|
||||||
|
<tr class="hover:bg-slate-50 transition cursor-pointer" onclick="window.location.href='{% url 'complaints:complaint_detail' complaint.pk %}'">
|
||||||
|
<td class="py-3 px-4 text-sm font-bold text-navy font-mono">{{ complaint.reference_number }}</td>
|
||||||
|
<td class="py-3 px-4 text-sm text-slate max-w-xs truncate">
|
||||||
|
{% if complaint.ai_brief_en %}
|
||||||
|
{{ complaint.ai_brief_en|truncatewords:8 }}
|
||||||
|
{% else %}
|
||||||
|
{{ complaint.description|truncatewords:8 }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-4">
|
||||||
|
<span class="px-2.5 py-1 rounded-full text-[10px] font-bold uppercase
|
||||||
|
{% if complaint.status == 'open' %}bg-yellow-100 text-yellow-700
|
||||||
|
{% elif complaint.status == 'in_progress' %}bg-blue-100 text-blue-700
|
||||||
|
{% elif complaint.status == 'partially_resolved' %}bg-amber-100 text-amber-700
|
||||||
|
{% elif complaint.status == 'resolved' %}bg-green-100 text-green-700
|
||||||
|
{% elif complaint.status == 'closed' %}bg-slate-100 text-slate-600
|
||||||
|
{% elif complaint.status == 'cancelled' %}bg-red-100 text-red-700
|
||||||
|
{% elif complaint.status == 'contacted' %}bg-purple-100 text-purple-700
|
||||||
|
{% elif complaint.status == 'contacted_no_response' %}bg-slate-100 text-slate-600
|
||||||
|
{% else %}bg-slate-100 text-slate-600{% endif %}">
|
||||||
|
{{ complaint.get_status_display }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-4">
|
||||||
|
<span class="px-2.5 py-1 rounded-full text-[10px] font-bold uppercase
|
||||||
|
{% if complaint.priority == 'critical' %}bg-red-500 text-white
|
||||||
|
{% elif complaint.priority == 'high' %}bg-orange-100 text-orange-700
|
||||||
|
{% elif complaint.priority == 'medium' %}bg-yellow-100 text-yellow-700
|
||||||
|
{% else %}bg-green-100 text-green-700{% endif %}">
|
||||||
|
{{ complaint.get_priority_display }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-4 text-sm text-slate">{{ complaint.hospital.name|default:"-" }}</td>
|
||||||
|
<td class="py-3 px-4 text-sm text-slate">{{ complaint.created_at|date:"Y-m-d" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<i data-lucide="file-text" class="w-8 h-8 text-slate-400"></i>
|
||||||
|
</div>
|
||||||
|
<p class="text-slate font-medium">{% trans "No complaints from this source" %}</p>
|
||||||
|
<p class="text-slate text-sm mt-1">{% trans "Complaints will appear here when submitted through this source" %}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Inquiries Tab -->
|
||||||
|
<div id="tab-inquiries" class="tab-panel hidden">
|
||||||
|
{% if source_inquiries %}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b-2 border-slate-200">
|
||||||
|
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Reference" %}</th>
|
||||||
|
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Subject" %}</th>
|
||||||
|
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Category" %}</th>
|
||||||
|
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Status" %}</th>
|
||||||
|
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Priority" %}</th>
|
||||||
|
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Hospital" %}</th>
|
||||||
|
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Date" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
{% for inquiry in source_inquiries %}
|
||||||
|
<tr class="hover:bg-slate-50 transition cursor-pointer" onclick="window.location.href='{% url 'inquiries:inquiry_detail' inquiry.pk %}'">
|
||||||
|
<td class="py-3 px-4 text-sm font-bold text-navy font-mono">{{ inquiry.reference_number }}</td>
|
||||||
|
<td class="py-3 px-4 text-sm text-slate max-w-xs truncate">{{ inquiry.subject|truncatewords:8 }}</td>
|
||||||
|
<td class="py-3 px-4">
|
||||||
|
<span class="px-2.5 py-1 bg-slate-100 rounded-full text-[10px] font-bold text-slate">
|
||||||
|
{{ inquiry.get_category_display }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-4">
|
||||||
|
<span class="px-2.5 py-1 rounded-full text-[10px] font-bold uppercase
|
||||||
|
{% if inquiry.status == 'open' %}bg-yellow-100 text-yellow-700
|
||||||
|
{% elif inquiry.status == 'in_progress' %}bg-blue-100 text-blue-700
|
||||||
|
{% elif inquiry.status == 'resolved' %}bg-green-100 text-green-700
|
||||||
|
{% elif inquiry.status == 'closed' %}bg-slate-100 text-slate-600
|
||||||
|
{% elif inquiry.status == 'contacted' %}bg-purple-100 text-purple-700
|
||||||
|
{% elif inquiry.status == 'contacted_no_response' %}bg-slate-100 text-slate-600
|
||||||
|
{% else %}bg-slate-100 text-slate-600{% endif %}">
|
||||||
|
{{ inquiry.get_status_display }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-4">
|
||||||
|
<span class="px-2.5 py-1 rounded-full text-[10px] font-bold uppercase
|
||||||
|
{% if inquiry.priority == 'high' %}bg-orange-100 text-orange-700
|
||||||
|
{% elif inquiry.priority == 'medium' %}bg-yellow-100 text-yellow-700
|
||||||
|
{% else %}bg-green-100 text-green-700{% endif %}">
|
||||||
|
{{ inquiry.get_priority_display }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-4 text-sm text-slate">{{ inquiry.hospital.name|default:"-" }}</td>
|
||||||
|
<td class="py-3 px-4 text-sm text-slate">{{ inquiry.created_at|date:"Y-m-d" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<i data-lucide="help-circle" class="w-8 h-8 text-slate-400"></i>
|
||||||
|
</div>
|
||||||
|
<p class="text-slate font-medium">{% trans "No inquiries from this source" %}</p>
|
||||||
|
<p class="text-slate text-sm mt-1">{% trans "Inquiries will appear here when submitted through this source" %}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Usage Records -->
|
<!-- Usage Records -->
|
||||||
<div class="info-card animate-in">
|
<div class="info-card animate-in">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@ -359,5 +515,17 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function switchTab(tab) {
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(p => p.classList.add('hidden'));
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(b => {
|
||||||
|
b.classList.remove('bg-navy', 'text-white');
|
||||||
|
b.classList.add('text-slate', 'hover:bg-slate-50');
|
||||||
|
});
|
||||||
|
document.getElementById('tab-' + tab).classList.remove('hidden');
|
||||||
|
var btn = document.getElementById('tab-btn-' + tab);
|
||||||
|
btn.classList.add('bg-navy', 'text-white');
|
||||||
|
btn.classList.remove('text-slate', 'hover:bg-slate-50');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user