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/
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# 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
|
||||
# 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!
|
||||
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!
|
||||
DEBUG = True
|
||||
@ -31,63 +34,62 @@ ALLOWED_HOSTS = []
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
# Apps
|
||||
'apps.core',
|
||||
'apps.accounts',
|
||||
'apps.dashboard',
|
||||
'apps.social',
|
||||
'django_celery_beat',
|
||||
"apps.core",
|
||||
"apps.accounts",
|
||||
"apps.dashboard",
|
||||
"apps.social",
|
||||
"django_celery_beat",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
# PX Source User Access Control
|
||||
'apps.px_sources.middleware.SourceUserRestrictionMiddleware',
|
||||
'apps.px_sources.middleware.SourceUserSessionMiddleware',
|
||||
"apps.px_sources.middleware.SourceUserRestrictionMiddleware",
|
||||
"apps.px_sources.middleware.SourceUserSessionMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'PX360.urls'
|
||||
ROOT_URLCONF = "PX360.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [BASE_DIR / 'templates']
|
||||
,
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'apps.core.context_processors.hospital_context',
|
||||
'apps.core.context_processors.sidebar_counts',
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [BASE_DIR / "templates"],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"apps.core.context_processors.hospital_context",
|
||||
"apps.core.context_processors.sidebar_counts",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'PX360.wsgi.application'
|
||||
WSGI_APPLICATION = "PX360.wsgi.application"
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
}
|
||||
}
|
||||
|
||||
@ -97,16 +99,16 @@ DATABASES = {
|
||||
|
||||
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
|
||||
# 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
|
||||
|
||||
@ -126,35 +128,34 @@ USE_TZ = True
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/6.0/howto/static-files/
|
||||
|
||||
STATIC_URL = 'static/'
|
||||
STATIC_URL = "static/"
|
||||
|
||||
# Celery Configuration
|
||||
CELERY_BROKER_URL = 'redis://localhost:6379/0'
|
||||
CELERY_RESULT_BACKEND = 'redis://localhost:6379/0'
|
||||
CELERY_ACCEPT_CONTENT = ['json']
|
||||
CELERY_TASK_SERIALIZER = 'json'
|
||||
CELERY_RESULT_SERIALIZER = 'json'
|
||||
CELERY_BROKER_URL = "redis://localhost:6379/0"
|
||||
CELERY_RESULT_BACKEND = "redis://localhost:6379/0"
|
||||
CELERY_ACCEPT_CONTENT = ["json"]
|
||||
CELERY_TASK_SERIALIZER = "json"
|
||||
CELERY_RESULT_SERIALIZER = "json"
|
||||
CELERY_TIMEZONE = TIME_ZONE
|
||||
CELERY_ENABLE_UTC = True
|
||||
|
||||
# Django Celery Beat Scheduler
|
||||
CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler'
|
||||
LINKEDIN_CLIENT_SECRET ='WPL_AP1.Ek4DeQDXuv4INg1K.mGo4CQ=='
|
||||
LINKEDIN_REDIRECT_URI = 'http://127.0.0.1:8000/social/callback/LI/'
|
||||
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
|
||||
LINKEDIN_CLIENT_SECRET = "WPL_AP1.Ek4DeQDXuv4INg1K.mGo4CQ=="
|
||||
LINKEDIN_REDIRECT_URI = "http://127.0.0.1:8000/social/callback/LI/"
|
||||
LINKEDIN_WEBHOOK_VERIFY_TOKEN = "your_random_secret_string_123"
|
||||
|
||||
|
||||
# YOUTUBE API CREDENTIALS
|
||||
# Ensure this matches your Google Cloud Console settings
|
||||
YOUTUBE_CLIENT_SECRETS_FILE = BASE_DIR / 'secrets' / 'yt_client_secrets.json'
|
||||
YOUTUBE_REDIRECT_URI = 'http://127.0.0.1:8000/social/callback/YT/'
|
||||
|
||||
YOUTUBE_CLIENT_SECRETS_FILE = BASE_DIR / "secrets" / "yt_client_secrets.json"
|
||||
YOUTUBE_REDIRECT_URI = "http://127.0.0.1:8000/social/callback/YT/"
|
||||
|
||||
|
||||
# Google REVIEWS Configuration
|
||||
# Ensure you have your client_secrets.json file at this location
|
||||
GMB_CLIENT_SECRETS_FILE = BASE_DIR / 'secrets' / 'gmb_client_secrets.json'
|
||||
GMB_REDIRECT_URI = 'http://127.0.0.1:8000/social/callback/GO/'
|
||||
GMB_CLIENT_SECRETS_FILE = BASE_DIR / "secrets" / "gmb_client_secrets.json"
|
||||
GMB_REDIRECT_URI = "http://127.0.0.1:8000/social/callback/GO/"
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
|
||||
# X API Configuration
|
||||
X_CLIENT_ID = 'your_client_id'
|
||||
X_CLIENT_SECRET = 'your_client_secret'
|
||||
X_REDIRECT_URI = 'http://127.0.0.1:8000/social/callback/X/'
|
||||
X_CLIENT_ID = "your_client_id"
|
||||
X_CLIENT_SECRET = "your_client_secret"
|
||||
X_REDIRECT_URI = "http://127.0.0.1:8000/social/callback/X/"
|
||||
# TIER CONFIGURATION
|
||||
# Set to True if you have Enterprise Access
|
||||
# Set to False for Free/Basic/Pro
|
||||
@ -174,16 +174,15 @@ X_USE_ENTERPRISE = False
|
||||
|
||||
|
||||
# --- TIKTOK CONFIG ---
|
||||
TIKTOK_CLIENT_KEY = 'your_client_key'
|
||||
TIKTOK_CLIENT_SECRET = 'your_client_secret'
|
||||
TIKTOK_REDIRECT_URI = 'http://127.0.0.1:8000/social/callback/TT/'
|
||||
|
||||
TIKTOK_CLIENT_KEY = "your_client_key"
|
||||
TIKTOK_CLIENT_SECRET = "your_client_secret"
|
||||
TIKTOK_REDIRECT_URI = "http://127.0.0.1:8000/social/callback/TT/"
|
||||
|
||||
|
||||
# --- META API CONFIG ---
|
||||
META_APP_ID = '1229882089053768'
|
||||
META_APP_SECRET = 'b80750bd12ab7f1c21d7d0ca891ba5ab'
|
||||
META_REDIRECT_URI = 'https://micha-nonparabolic-lovie.ngrok-free.dev/social/callback/META/'
|
||||
META_WEBHOOK_VERIFY_TOKEN = 'random_secret_string_khanfaheed123456'
|
||||
META_APP_ID = "1229882089053768"
|
||||
META_APP_SECRET = "b80750bd12ab7f1c21d7d0ca891ba5ab"
|
||||
META_REDIRECT_URI = "https://micha-nonparabolic-lovie.ngrok-free.dev/social/callback/META/"
|
||||
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
|
||||
content_list = get_wizard_content_for_user(user)
|
||||
|
||||
if not content_list:
|
||||
return redirect("/accounts/onboarding/wizard/checklist/")
|
||||
|
||||
# Get current step content
|
||||
try:
|
||||
current_content = content_list[step - 1]
|
||||
@ -288,6 +291,12 @@ def onboarding_step_content(request, step):
|
||||
# Step doesn't exist, go to 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
|
||||
completed_steps = user.wizard_completed_steps or []
|
||||
|
||||
@ -638,14 +647,16 @@ def bulk_invite_users(request):
|
||||
return redirect("accounts:bulk-invite-users")
|
||||
|
||||
for row in reader:
|
||||
rows_to_process.append({
|
||||
"email": row.get("email", "").strip(),
|
||||
"first_name": row.get("first_name", "").strip(),
|
||||
"last_name": row.get("last_name", "").strip(),
|
||||
"role": row.get("role", "").strip(),
|
||||
"hospital_id": row.get("hospital_id", "").strip(),
|
||||
"department_id": row.get("department_id", "").strip()
|
||||
})
|
||||
rows_to_process.append(
|
||||
{
|
||||
"email": row.get("email", "").strip(),
|
||||
"first_name": row.get("first_name", "").strip(),
|
||||
"last_name": row.get("last_name", "").strip(),
|
||||
"role": row.get("role", "").strip(),
|
||||
"hospital_id": row.get("hospital_id", "").strip(),
|
||||
"department_id": row.get("department_id", "").strip(),
|
||||
}
|
||||
)
|
||||
except Exception as 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"
|
||||
l_name = " ".join(parts[1:]) if len(parts) > 1 else "Member"
|
||||
|
||||
rows_to_process.append({
|
||||
"email": email,
|
||||
"first_name": f_name,
|
||||
"last_name": l_name,
|
||||
"role": role_name,
|
||||
"hospital_id": hospital_id,
|
||||
"department_id": ""
|
||||
})
|
||||
rows_to_process.append(
|
||||
{
|
||||
"email": email,
|
||||
"first_name": f_name,
|
||||
"last_name": l_name,
|
||||
"role": role_name,
|
||||
"hospital_id": hospital_id,
|
||||
"department_id": "",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
messages.error(request, f"Error processing manual entries: {str(e)}")
|
||||
|
||||
@ -775,7 +788,6 @@ def bulk_invite_users(request):
|
||||
if results["errors"]:
|
||||
messages.warning(request, f"Failed to invite {len(results['errors'])} users. See details below.")
|
||||
|
||||
|
||||
# Get data for template
|
||||
roles = Role.objects.all()
|
||||
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
|
||||
# Check PX Admin first to avoid logic issues when user has multiple roles
|
||||
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:
|
||||
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):
|
||||
"""
|
||||
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 (
|
||||
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
|
||||
@login_required
|
||||
def kpi_list(request):
|
||||
|
||||
@ -16,6 +16,7 @@ urlpatterns = [
|
||||
|
||||
# AI Analytics API
|
||||
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'),
|
||||
|
||||
# KPI Reports
|
||||
|
||||
@ -556,42 +556,12 @@ class ComplaintForm(HospitalFieldMixin, forms.ModelForm):
|
||||
self.fields["main_section"].queryset = MainSection.objects.none()
|
||||
self.fields["subsection"].queryset = SubSection.objects.none()
|
||||
|
||||
# Load all locations (no filtering needed)
|
||||
self.fields["location"].queryset = Location.objects.all().order_by("name_en")
|
||||
# Load locations: Inpatient, Outpatient Clinics, Emergency, Others
|
||||
self.fields["location"].queryset = Location.objects.filter(id__in=[48, 49, 82, 110]).order_by("name_en")
|
||||
|
||||
# Load active PX sources for optional selection
|
||||
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
|
||||
# Now filter departments and staff based on hospital
|
||||
hospital_id = None
|
||||
|
||||
@ -16,6 +16,7 @@ from django.db import models
|
||||
from django.utils import timezone
|
||||
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
|
||||
|
||||
|
||||
@ -213,9 +214,10 @@ class Complaint(UUIDModel, TimeStampedModel):
|
||||
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"
|
||||
)
|
||||
national_id_hash = models.CharField(max_length=64, blank=True, db_index=True)
|
||||
|
||||
incident_date = models.DateField(
|
||||
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})
|
||||
|
||||
def get_masked_national_id(self):
|
||||
return mask_national_id(self.national_id)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Calculate SLA due date on creation, generate reference number, and sync complaint_type from metadata"""
|
||||
# Track status change for signals
|
||||
@ -543,6 +548,11 @@ class Complaint(UUIDModel, TimeStampedModel):
|
||||
if self.complaint_type == "complaint" and ai_complaint_type != "complaint":
|
||||
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)
|
||||
|
||||
def calculate_sla_due_date(self):
|
||||
@ -658,6 +668,45 @@ class Complaint(UUIDModel, TimeStampedModel):
|
||||
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
|
||||
def short_description_en(self):
|
||||
"""Get AI-generated short description (English) from metadata"""
|
||||
@ -2225,8 +2274,7 @@ class OnCallAdmin(UUIDModel, TimeStampedModel):
|
||||
"accounts.User",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="on_call_schedules",
|
||||
help_text="PX Admin user who is on-call",
|
||||
limit_choices_to={"groups__name": "PX Admin"},
|
||||
help_text="User who is on-call (PX Admin, PX Coordinator, or Hospital Admin)",
|
||||
)
|
||||
|
||||
# Optional: date range for this on-call assignment
|
||||
|
||||
@ -1977,6 +1977,8 @@ def public_complaint_submit(request):
|
||||
severity="medium", # Default, AI will update
|
||||
priority="medium", # Default, AI will update
|
||||
status="open", # Start as open
|
||||
complaint_source_type=ComplaintSourceType.INTERNAL,
|
||||
source=PXSource.objects.filter(name_en="Public Form").first(),
|
||||
reference_number=reference_number,
|
||||
# Location hierarchy (FK relationships)
|
||||
location=location,
|
||||
@ -2112,15 +2114,39 @@ def public_complaint_track(request):
|
||||
except Complaint.DoesNotExist:
|
||||
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 = []
|
||||
if complaint:
|
||||
public_updates = complaint.updates.filter(
|
||||
update_type__in=["status_change", "resolution", "communication"]
|
||||
).order_by("-created_at")
|
||||
public_status = complaint.public_status
|
||||
|
||||
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 = {
|
||||
"complaint": complaint,
|
||||
"public_status": public_status,
|
||||
"public_updates": public_updates,
|
||||
"error_message": error_message,
|
||||
"reference_number": reference_number,
|
||||
@ -2288,7 +2314,10 @@ def api_lookup_patient(request):
|
||||
lookup_method = None
|
||||
|
||||
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"
|
||||
|
||||
if not patient and phone:
|
||||
|
||||
@ -28,8 +28,8 @@ logger = logging.getLogger(__name__)
|
||||
def check_px_admin(request):
|
||||
"""Check if user is PX Admin, return redirect if not."""
|
||||
if not request.user.is_px_admin():
|
||||
messages.error(request, _('You do not have permission to access this page.'))
|
||||
return redirect('dashboard')
|
||||
messages.error(request, _("You do not have permission to access this page."))
|
||||
return redirect("dashboard")
|
||||
return None
|
||||
|
||||
|
||||
@ -42,14 +42,14 @@ def oncall_schedule_list(request):
|
||||
if redirect_response:
|
||||
return redirect_response
|
||||
|
||||
schedules = OnCallAdminSchedule.objects.select_related('hospital').all()
|
||||
schedules = OnCallAdminSchedule.objects.select_related("hospital").all()
|
||||
|
||||
context = {
|
||||
'schedules': schedules,
|
||||
'title': _('On-Call Admin Schedules'),
|
||||
"schedules": 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
|
||||
@ -61,27 +61,27 @@ def oncall_schedule_create(request):
|
||||
if 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:
|
||||
# Parse working days from checkboxes
|
||||
working_days = []
|
||||
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)
|
||||
|
||||
if not working_days:
|
||||
working_days = [0, 1, 2, 3, 4] # Default to Mon-Fri
|
||||
|
||||
# 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
|
||||
|
||||
work_start_time = request.POST.get('work_start_time', '08:00')
|
||||
work_end_time = request.POST.get('work_end_time', '17:00')
|
||||
timezone_str = request.POST.get('timezone', 'Asia/Riyadh')
|
||||
is_active = request.POST.get('is_active') == 'on'
|
||||
work_start_time = request.POST.get("work_start_time", "08:00")
|
||||
work_end_time = request.POST.get("work_end_time", "17:00")
|
||||
timezone_str = request.POST.get("timezone", "Asia/Riyadh")
|
||||
is_active = request.POST.get("is_active") == "on"
|
||||
|
||||
# Create schedule
|
||||
schedule = OnCallAdminSchedule.objects.create(
|
||||
@ -90,40 +90,48 @@ def oncall_schedule_create(request):
|
||||
work_start_time=work_start_time,
|
||||
work_end_time=work_end_time,
|
||||
timezone=timezone_str,
|
||||
is_active=is_active
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
# Log audit
|
||||
AuditService.log_event(
|
||||
event_type='oncall_schedule_created',
|
||||
event_type="oncall_schedule_created",
|
||||
description=f"On-call schedule created: {schedule}",
|
||||
user=request.user,
|
||||
content_object=schedule,
|
||||
metadata={
|
||||
'hospital': str(hospital) if hospital else 'system-wide',
|
||||
'working_days': working_days,
|
||||
'work_hours': f"{work_start_time}-{work_end_time}"
|
||||
}
|
||||
"hospital": str(hospital) if hospital else "system-wide",
|
||||
"working_days": working_days,
|
||||
"work_hours": f"{work_start_time}-{work_end_time}",
|
||||
},
|
||||
)
|
||||
|
||||
messages.success(request, _('On-call schedule created successfully.'))
|
||||
return redirect('complaints:oncall_schedule_detail', pk=schedule.id)
|
||||
messages.success(request, _("On-call schedule created successfully."))
|
||||
return redirect("complaints:oncall_schedule_detail", pk=schedule.id)
|
||||
|
||||
except Exception as 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 = {
|
||||
'hospitals': hospitals,
|
||||
'timezones': [
|
||||
'Asia/Riyadh', 'Asia/Dubai', 'Asia/Kuwait', 'Asia/Qatar',
|
||||
'Asia/Bahrain', 'Asia/Muscat', 'Asia/Amman', 'Asia/Beirut',
|
||||
'Asia/Cairo', 'Asia/Jerusalem', 'Asia/Baghdad'
|
||||
"hospitals": hospitals,
|
||||
"timezones": [
|
||||
"Asia/Riyadh",
|
||||
"Asia/Dubai",
|
||||
"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
|
||||
@ -135,24 +143,21 @@ def oncall_schedule_detail(request, pk):
|
||||
if redirect_response:
|
||||
return redirect_response
|
||||
|
||||
schedule = get_object_or_404(
|
||||
OnCallAdminSchedule.objects.select_related('hospital'),
|
||||
pk=pk
|
||||
)
|
||||
schedule = get_object_or_404(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
|
||||
is_working_hours = schedule.is_working_time()
|
||||
|
||||
context = {
|
||||
'schedule': schedule,
|
||||
'on_call_admins': on_call_admins,
|
||||
'is_working_hours': is_working_hours,
|
||||
'title': _('On-Call Schedule Details'),
|
||||
"schedule": schedule,
|
||||
"on_call_admins": on_call_admins,
|
||||
"is_working_hours": is_working_hours,
|
||||
"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
|
||||
@ -165,63 +170,71 @@ def oncall_schedule_edit(request, pk):
|
||||
return redirect_response
|
||||
|
||||
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:
|
||||
# Parse working days from checkboxes
|
||||
working_days = []
|
||||
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)
|
||||
|
||||
if not working_days:
|
||||
working_days = [0, 1, 2, 3, 4] # Default to Mon-Fri
|
||||
|
||||
# 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.working_days = working_days
|
||||
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.timezone = request.POST.get('timezone', 'Asia/Riyadh')
|
||||
schedule.is_active = request.POST.get('is_active') == 'on'
|
||||
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.timezone = request.POST.get("timezone", "Asia/Riyadh")
|
||||
schedule.is_active = request.POST.get("is_active") == "on"
|
||||
|
||||
schedule.save()
|
||||
|
||||
# Log audit
|
||||
AuditService.log_event(
|
||||
event_type='oncall_schedule_updated',
|
||||
event_type="oncall_schedule_updated",
|
||||
description=f"On-call schedule updated: {schedule}",
|
||||
user=request.user,
|
||||
content_object=schedule,
|
||||
metadata={
|
||||
'hospital': str(schedule.hospital) if schedule.hospital else 'system-wide',
|
||||
'working_days': working_days,
|
||||
'is_active': schedule.is_active
|
||||
}
|
||||
"hospital": str(schedule.hospital) if schedule.hospital else "system-wide",
|
||||
"working_days": working_days,
|
||||
"is_active": schedule.is_active,
|
||||
},
|
||||
)
|
||||
|
||||
messages.success(request, _('On-call schedule updated successfully.'))
|
||||
return redirect('complaints:oncall_schedule_detail', pk=schedule.id)
|
||||
messages.success(request, _("On-call schedule updated successfully."))
|
||||
return redirect("complaints:oncall_schedule_detail", pk=schedule.id)
|
||||
|
||||
except Exception as 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 = {
|
||||
'schedule': schedule,
|
||||
'hospitals': hospitals,
|
||||
'timezones': [
|
||||
'Asia/Riyadh', 'Asia/Dubai', 'Asia/Kuwait', 'Asia/Qatar',
|
||||
'Asia/Bahrain', 'Asia/Muscat', 'Asia/Amman', 'Asia/Beirut',
|
||||
'Asia/Cairo', 'Asia/Jerusalem', 'Asia/Baghdad'
|
||||
"schedule": schedule,
|
||||
"hospitals": hospitals,
|
||||
"timezones": [
|
||||
"Asia/Riyadh",
|
||||
"Asia/Dubai",
|
||||
"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
|
||||
@ -239,23 +252,23 @@ def oncall_schedule_delete(request, pk):
|
||||
try:
|
||||
# Log before deletion
|
||||
AuditService.log_event(
|
||||
event_type='oncall_schedule_deleted',
|
||||
event_type="oncall_schedule_deleted",
|
||||
description=f"On-call schedule deleted: {schedule}",
|
||||
user=request.user,
|
||||
metadata={
|
||||
'hospital': str(schedule.hospital) if schedule.hospital else 'system-wide',
|
||||
'schedule_id': str(pk)
|
||||
}
|
||||
"hospital": str(schedule.hospital) if schedule.hospital else "system-wide",
|
||||
"schedule_id": str(pk),
|
||||
},
|
||||
)
|
||||
|
||||
schedule.delete()
|
||||
messages.success(request, _('On-call schedule deleted successfully.'))
|
||||
messages.success(request, _("On-call schedule deleted successfully."))
|
||||
|
||||
except Exception as 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
|
||||
@ -270,24 +283,28 @@ def oncall_admin_add(request, schedule_pk):
|
||||
schedule = get_object_or_404(OnCallAdminSchedule, pk=schedule_pk)
|
||||
|
||||
# Get all PX Admins not already on this schedule
|
||||
existing_admin_ids = schedule.on_call_admins.values_list('admin_user_id', flat=True)
|
||||
available_admins = User.objects.filter(
|
||||
groups__name='PX Admin',
|
||||
is_active=True
|
||||
).exclude(id__in=existing_admin_ids)
|
||||
existing_admin_ids = schedule.on_call_admins.values_list("admin_user_id", flat=True)
|
||||
available_admins = (
|
||||
User.objects.filter(
|
||||
Q(groups__name="PX Admin") | Q(groups__name="PX Coordinator") | Q(groups__name="Hospital Admin"),
|
||||
is_active=True,
|
||||
)
|
||||
.exclude(id__in=existing_admin_ids)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
if request.method == 'POST':
|
||||
if request.method == "POST":
|
||||
try:
|
||||
admin_user_id = request.POST.get('admin_user')
|
||||
admin_user_id = request.POST.get("admin_user")
|
||||
if not admin_user_id:
|
||||
messages.error(request, _('Please select an admin user.'))
|
||||
return redirect('complaints:oncall_admin_add', schedule_pk=schedule_pk)
|
||||
messages.error(request, _("Please select an admin user."))
|
||||
return redirect("complaints:oncall_admin_add", schedule_pk=schedule_pk)
|
||||
|
||||
admin_user = User.objects.get(id=admin_user_id)
|
||||
|
||||
# Parse dates
|
||||
start_date = request.POST.get('start_date') or None
|
||||
end_date = request.POST.get('end_date') or None
|
||||
start_date = request.POST.get("start_date") or None
|
||||
end_date = request.POST.get("end_date") or None
|
||||
|
||||
# Create on-call admin assignment
|
||||
on_call_admin = OnCallAdmin.objects.create(
|
||||
@ -295,41 +312,44 @@ def oncall_admin_add(request, schedule_pk):
|
||||
admin_user=admin_user,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
notification_priority=int(request.POST.get('notification_priority', 1)),
|
||||
is_active=request.POST.get('is_active') == 'on',
|
||||
notify_email=request.POST.get('notify_email') == 'on',
|
||||
notify_sms=request.POST.get('notify_sms') == 'on',
|
||||
sms_phone=request.POST.get('sms_phone', '')
|
||||
notification_priority=int(request.POST.get("notification_priority", 1)),
|
||||
is_active=request.POST.get("is_active") == "on",
|
||||
notify_email=request.POST.get("notify_email") == "on",
|
||||
notify_sms=request.POST.get("notify_sms") == "on",
|
||||
sms_phone=request.POST.get("sms_phone", ""),
|
||||
)
|
||||
|
||||
# Log audit
|
||||
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",
|
||||
user=request.user,
|
||||
content_object=on_call_admin,
|
||||
metadata={
|
||||
'schedule': str(schedule),
|
||||
'admin_user': str(admin_user),
|
||||
'start_date': start_date,
|
||||
'end_date': end_date
|
||||
}
|
||||
"schedule": str(schedule),
|
||||
"admin_user": str(admin_user),
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
},
|
||||
)
|
||||
|
||||
messages.success(request, _('On-call admin added successfully.'))
|
||||
return redirect('complaints:oncall_schedule_detail', pk=schedule_pk)
|
||||
messages.success(request, _("On-call admin added successfully."))
|
||||
return redirect("complaints:oncall_schedule_detail", pk=schedule_pk)
|
||||
|
||||
except Exception as 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 = {
|
||||
'schedule': schedule,
|
||||
'available_admins': available_admins,
|
||||
'title': _('Add On-Call Admin'),
|
||||
"schedule": schedule,
|
||||
"available_admins": available_admins,
|
||||
"available_px_admins": available_admins.filter(groups__name="PX Admin"),
|
||||
"available_coordinators": available_admins.filter(groups__name="PX Coordinator"),
|
||||
"available_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
|
||||
@ -341,55 +361,52 @@ def oncall_admin_edit(request, pk):
|
||||
if redirect_response:
|
||||
return redirect_response
|
||||
|
||||
on_call_admin = get_object_or_404(
|
||||
OnCallAdmin.objects.select_related('schedule', 'admin_user'),
|
||||
pk=pk
|
||||
)
|
||||
on_call_admin = get_object_or_404(OnCallAdmin.objects.select_related("schedule", "admin_user"), pk=pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
if request.method == "POST":
|
||||
try:
|
||||
# Parse dates
|
||||
start_date = request.POST.get('start_date') or None
|
||||
end_date = request.POST.get('end_date') or None
|
||||
start_date = request.POST.get("start_date") or None
|
||||
end_date = request.POST.get("end_date") or None
|
||||
|
||||
# Update fields
|
||||
on_call_admin.start_date = start_date
|
||||
on_call_admin.end_date = end_date
|
||||
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.notify_email = request.POST.get('notify_email') == '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.notification_priority = int(request.POST.get("notification_priority", 1))
|
||||
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_sms = request.POST.get("notify_sms") == "on"
|
||||
on_call_admin.sms_phone = request.POST.get("sms_phone", "")
|
||||
|
||||
on_call_admin.save()
|
||||
|
||||
# Log audit
|
||||
AuditService.log_event(
|
||||
event_type='oncall_admin_updated',
|
||||
event_type="oncall_admin_updated",
|
||||
description=f"On-call admin updated: {on_call_admin}",
|
||||
user=request.user,
|
||||
content_object=on_call_admin,
|
||||
metadata={
|
||||
'schedule': str(on_call_admin.schedule),
|
||||
'admin_user': str(on_call_admin.admin_user),
|
||||
'is_active': on_call_admin.is_active
|
||||
}
|
||||
"schedule": str(on_call_admin.schedule),
|
||||
"admin_user": str(on_call_admin.admin_user),
|
||||
"is_active": on_call_admin.is_active,
|
||||
},
|
||||
)
|
||||
|
||||
messages.success(request, _('On-call admin updated successfully.'))
|
||||
return redirect('complaints:oncall_schedule_detail', pk=on_call_admin.schedule.id)
|
||||
messages.success(request, _("On-call admin updated successfully."))
|
||||
return redirect("complaints:oncall_schedule_detail", pk=on_call_admin.schedule.id)
|
||||
|
||||
except Exception as 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 = {
|
||||
'on_call_admin': on_call_admin,
|
||||
'schedule': on_call_admin.schedule,
|
||||
'title': _('Edit On-Call Admin'),
|
||||
"on_call_admin": on_call_admin,
|
||||
"schedule": on_call_admin.schedule,
|
||||
"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
|
||||
@ -402,33 +419,30 @@ def oncall_admin_delete(request, pk):
|
||||
if redirect_response:
|
||||
return redirect_response
|
||||
|
||||
on_call_admin = get_object_or_404(
|
||||
OnCallAdmin.objects.select_related('schedule', 'admin_user'),
|
||||
pk=pk
|
||||
)
|
||||
on_call_admin = get_object_or_404(OnCallAdmin.objects.select_related("schedule", "admin_user"), pk=pk)
|
||||
schedule_pk = on_call_admin.schedule.id
|
||||
|
||||
try:
|
||||
# Log before deletion
|
||||
AuditService.log_event(
|
||||
event_type='oncall_admin_removed',
|
||||
event_type="oncall_admin_removed",
|
||||
description=f"Admin removed from on-call schedule: {on_call_admin}",
|
||||
user=request.user,
|
||||
metadata={
|
||||
'schedule': str(on_call_admin.schedule),
|
||||
'admin_user': str(on_call_admin.admin_user),
|
||||
'oncall_admin_id': str(pk)
|
||||
}
|
||||
"schedule": str(on_call_admin.schedule),
|
||||
"admin_user": str(on_call_admin.admin_user),
|
||||
"oncall_admin_id": str(pk),
|
||||
},
|
||||
)
|
||||
|
||||
on_call_admin.delete()
|
||||
messages.success(request, _('On-call admin removed successfully.'))
|
||||
messages.success(request, _("On-call admin removed successfully."))
|
||||
|
||||
except Exception as 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
|
||||
@ -441,18 +455,18 @@ def oncall_dashboard(request):
|
||||
return redirect_response
|
||||
|
||||
# Get all schedules
|
||||
schedules = OnCallAdminSchedule.objects.select_related('hospital').all()
|
||||
schedules = OnCallAdminSchedule.objects.select_related("hospital").all()
|
||||
|
||||
# Get currently active on-call admins
|
||||
now = timezone.now()
|
||||
today = now.date()
|
||||
|
||||
active_on_call_admins = OnCallAdmin.objects.filter(
|
||||
is_active=True,
|
||||
schedule__is_active=True
|
||||
).select_related('admin_user', 'schedule', 'schedule__hospital').filter(
|
||||
Q(start_date__isnull=True) | Q(start_date__lte=today),
|
||||
Q(end_date__isnull=True) | Q(end_date__gte=today)
|
||||
active_on_call_admins = (
|
||||
OnCallAdmin.objects.filter(is_active=True, schedule__is_active=True)
|
||||
.select_related("admin_user", "schedule", "schedule__hospital")
|
||||
.filter(
|
||||
Q(start_date__isnull=True) | Q(start_date__lte=today), Q(end_date__isnull=True) | Q(end_date__gte=today)
|
||||
)
|
||||
)
|
||||
|
||||
# Check each schedule's current status
|
||||
@ -461,19 +475,21 @@ def oncall_dashboard(request):
|
||||
is_working = schedule.is_working_time()
|
||||
schedule_oncall = active_on_call_admins.filter(schedule=schedule)
|
||||
|
||||
schedule_statuses.append({
|
||||
'schedule': schedule,
|
||||
'is_working_hours': is_working,
|
||||
'on_call_count': schedule_oncall.count(),
|
||||
'on_call_admins': schedule_oncall
|
||||
})
|
||||
schedule_statuses.append(
|
||||
{
|
||||
"schedule": schedule,
|
||||
"is_working_hours": is_working,
|
||||
"on_call_count": schedule_oncall.count(),
|
||||
"on_call_admins": schedule_oncall,
|
||||
}
|
||||
)
|
||||
|
||||
context = {
|
||||
'schedule_statuses': schedule_statuses,
|
||||
'total_schedules': schedules.count(),
|
||||
'total_active_oncall': active_on_call_admins.count(),
|
||||
'current_time': now,
|
||||
'title': _('On-Call Dashboard'),
|
||||
"schedule_statuses": schedule_statuses,
|
||||
"total_schedules": schedules.count(),
|
||||
"total_active_oncall": active_on_call_admins.count(),
|
||||
"current_time": now,
|
||||
"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
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
from . import config_views
|
||||
|
||||
app_name = 'config'
|
||||
app_name = "config"
|
||||
|
||||
urlpatterns = [
|
||||
path('', config_views.config_dashboard, name='dashboard'),
|
||||
path('sla/', config_views.sla_config_list, name='sla_config_list'),
|
||||
path('routing/', config_views.routing_rules_list, name='routing_rules_list'),
|
||||
path('test/',config_views.test, name='test'),
|
||||
path("", config_views.config_dashboard, name="dashboard"),
|
||||
path("sla/", config_views.sla_config_list, name="sla_config_list"),
|
||||
path("routing/", config_views.routing_rules_list, name="routing_rules_list"),
|
||||
path("users/", config_views.hospital_users_list, name="hospital_users_list"),
|
||||
path("users/<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
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
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.complaints.models import OnCallAdminSchedule
|
||||
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
|
||||
|
||||
|
||||
@ -25,6 +35,7 @@ def config_dashboard(request):
|
||||
oncall_schedules_count = OnCallAdminSchedule.objects.filter(is_active=True).count()
|
||||
call_records_count = CallRecord.objects.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 = {
|
||||
"sla_configs_count": sla_configs_count,
|
||||
@ -33,6 +44,7 @@ def config_dashboard(request):
|
||||
"oncall_schedules_count": oncall_schedules_count,
|
||||
"call_records_count": call_records_count,
|
||||
"provisional_users_count": provisional_users_count,
|
||||
"active_users_count": active_users_count,
|
||||
}
|
||||
|
||||
return render(request, "config/dashboard.html", context)
|
||||
@ -116,6 +128,136 @@ def routing_rules_list(request):
|
||||
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 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):
|
||||
"""Example complaint notification (currently inline - no template)"""
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<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; }}
|
||||
.info-box {{ background: #eef6fb; border-left: 4px solid #005696; padding: 15px; margin: 20px 0; }}
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Complaint Notification</h1>
|
||||
</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>
|
||||
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">Complaint Notification</h1>
|
||||
<p style="margin: 0 0 15px; font-size: 16px; color: #1e293b; line-height: 1.6;">Dear Staff Member,</p>
|
||||
<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>
|
||||
<div style="background-color: #f8fafc; border-radius: 6px; padding: 20px; margin-bottom: 20px;">
|
||||
<p style="margin: 0; font-size: 14px; color: #64748b; line-height: 1.8;">
|
||||
<strong style="color: #005696;">Reference:</strong> #COMP-2026-0050<br>
|
||||
<strong style="color: #005696;">Title:</strong> Cleanliness Issue in Ward 3B<br>
|
||||
<strong style="color: #005696;">Patient:</strong> Mrs. Layla Ahmed<br>
|
||||
<strong style="color: #005696;">Department:</strong> Inpatient Ward 3B<br>
|
||||
<strong style="color: #005696;">Status:</strong> New
|
||||
</p>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 14px; color: #475569; line-height: 1.6;">Please review and take appropriate action.</p>
|
||||
"""
|
||||
text_content = """
|
||||
Complaint Notification
|
||||
@ -624,34 +608,18 @@ class Command(BaseCommand):
|
||||
def _send_complaint_resolution_inline(self):
|
||||
"""Example complaint resolution notification (currently inline - no template)"""
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<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; }}
|
||||
.success-box {{ background: #eef6fb; border-left: 4px solid #005696; padding: 15px; margin: 20px 0; }}
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<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>
|
||||
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">Complaint Resolved</h1>
|
||||
<p style="margin: 0 0 15px; font-size: 16px; color: #1e293b; line-height: 1.6;">Dear Patient,</p>
|
||||
<p style="margin: 0 0 20px; font-size: 16px; color: #475569; line-height: 1.6;">Your complaint has been resolved!</p>
|
||||
<div style="background-color: #f8fafc; border-radius: 6px; padding: 20px; margin-bottom: 20px;">
|
||||
<p style="margin: 0; font-size: 14px; color: #64748b; line-height: 1.8;">
|
||||
<strong style="color: #005696;">Reference:</strong> #COMP-2026-0045<br>
|
||||
<strong style="color: #005696;">Title:</strong> Appointment Scheduling Issue<br>
|
||||
<strong style="color: #005696;">Resolution:</strong> We have improved our scheduling system and scheduled your appointment for next week.
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
<p style="margin: 10px 0 0; font-size: 14px; color: #475569; line-height: 1.6;">Thank you for your feedback.</p>
|
||||
"""
|
||||
text_content = """
|
||||
Complaint Resolved
|
||||
@ -673,21 +641,7 @@ class Command(BaseCommand):
|
||||
def _send_admin_new_complaint_inline(self):
|
||||
"""Example admin new complaint notification (currently inline - no template)"""
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<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">
|
||||
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">🚨 New Complaint Alert</h1>
|
||||
<p>Dear Administrator,</p>
|
||||
<div class="alert-box">
|
||||
<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
|
||||
</div>
|
||||
<p>Immediate attention required. Please review and assign to appropriate staff.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
"""
|
||||
text_content = """
|
||||
🚨 New Complaint Alert
|
||||
@ -727,21 +678,7 @@ class Command(BaseCommand):
|
||||
def _send_escalation_inline(self):
|
||||
"""Example escalation notification (currently inline - no template)"""
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<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">
|
||||
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">⚠️ Complaint Escalated</h1>
|
||||
<p>Dear Manager,</p>
|
||||
<div class="warning-box">
|
||||
<strong>Complaint has been escalated to your attention:</strong><br><br>
|
||||
@ -752,10 +689,7 @@ class Command(BaseCommand):
|
||||
<strong>Date:</strong> April 7, 2026
|
||||
</div>
|
||||
<p>Please review this complaint and provide your intervention to ensure resolution.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
"""
|
||||
text_content = """
|
||||
⚠️ Complaint Escalated
|
||||
@ -935,21 +869,7 @@ class Command(BaseCommand):
|
||||
def _send_survey_invitation(self):
|
||||
"""Example survey invitation (currently inline - no HTML template)"""
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<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">
|
||||
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">Share Your Experience</h1>
|
||||
<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>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>
|
||||
</p>
|
||||
<p>Your feedback is important to us!</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
"""
|
||||
text_content = """
|
||||
Share Your Experience
|
||||
@ -1004,21 +921,7 @@ class Command(BaseCommand):
|
||||
def _send_appreciation_notification(self):
|
||||
"""Example appreciation notification (currently inline - no template)"""
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<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">
|
||||
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">🌟 Staff Appreciation</h1>
|
||||
<p>Dear Team,</p>
|
||||
<div class="appreciation-box">
|
||||
<strong>Excellent work recognized!</strong><br><br>
|
||||
@ -1028,10 +931,7 @@ class Command(BaseCommand):
|
||||
<strong>From:</strong> Patient Family Member
|
||||
</div>
|
||||
<p>Congratulations on this recognition! Your dedication to patient care is truly appreciated.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
"""
|
||||
text_content = """
|
||||
🌟 Staff Appreciation
|
||||
@ -1086,28 +986,12 @@ class Command(BaseCommand):
|
||||
def _send_explanation_requested(self):
|
||||
"""Example explanation requested from settings service (currently inline)"""
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<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">
|
||||
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">Explanation Requested</h1>
|
||||
<p>Dear Staff Member,</p>
|
||||
<p>An explanation has been requested for complaint #COMP-2026-0060.</p>
|
||||
<p><strong>Deadline:</strong> April 14, 2026</p>
|
||||
<p>Please submit your explanation through the system.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
"""
|
||||
text_content = """
|
||||
Explanation Requested
|
||||
@ -1125,29 +1009,13 @@ class Command(BaseCommand):
|
||||
def _send_complaint_assigned(self):
|
||||
"""Example complaint assigned from settings service (currently inline)"""
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<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">
|
||||
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">Complaint Assigned to You</h1>
|
||||
<p>Dear Dr. Khalid,</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>Department:</strong> Outpatient Clinic</p>
|
||||
<p>Please review and provide your explanation.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
"""
|
||||
text_content = """
|
||||
Complaint Assigned to You
|
||||
@ -1166,29 +1034,13 @@ class Command(BaseCommand):
|
||||
def _send_complaint_status_changed(self):
|
||||
"""Example complaint status changed from settings service (currently inline)"""
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<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">
|
||||
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">Complaint Status Updated</h1>
|
||||
<p>Dear Stakeholder,</p>
|
||||
<p>The status of complaint #COMP-2026-0058 has been updated.</p>
|
||||
<p><strong>Previous Status:</strong> Under Review</p>
|
||||
<p><strong>New Status:</strong> Resolved</p>
|
||||
<p><strong>Resolution:</strong> Issue has been addressed with staff member. Apology issued to patient.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
"""
|
||||
text_content = """
|
||||
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"""
|
||||
patient_id = patient_data.get("PatientID")
|
||||
mrn = patient_id
|
||||
national_id = patient_data.get("SSN")
|
||||
phone = patient_data.get("MobileNo")
|
||||
email = patient_data.get("Email")
|
||||
national_id = patient_data.get("SSN") or ""
|
||||
phone = patient_data.get("MobileNo") or ""
|
||||
email = patient_data.get("Email") or ""
|
||||
full_name = patient_data.get("PatientName")
|
||||
nationality = patient_data.get("PatientNationality", "")
|
||||
|
||||
@ -249,8 +249,8 @@ class HISAdapter:
|
||||
if patient:
|
||||
patient.first_name = first_name
|
||||
patient.last_name = last_name
|
||||
patient.national_id = national_id
|
||||
patient.phone = phone
|
||||
patient.national_id = national_id or ""
|
||||
patient.phone = phone or ""
|
||||
if email is not None:
|
||||
patient.email = email
|
||||
patient.date_of_birth = date_of_birth
|
||||
@ -262,7 +262,10 @@ class HISAdapter:
|
||||
mrn_taken = Patient.objects.filter(mrn=mrn).exists()
|
||||
|
||||
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:
|
||||
patient.mrn = mrn
|
||||
patient.primary_hospital = hospital
|
||||
@ -288,8 +291,8 @@ class HISAdapter:
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
national_id=national_id,
|
||||
phone=phone,
|
||||
email=email if email else "",
|
||||
phone=phone or "",
|
||||
email=email or "",
|
||||
date_of_birth=date_of_birth,
|
||||
gender=gender,
|
||||
nationality=nationality,
|
||||
|
||||
@ -41,8 +41,8 @@ class HISClient:
|
||||
self.session = requests.Session()
|
||||
|
||||
# Load credentials from Django settings (which reads .env)
|
||||
self.username = getattr(settings, 'HIS_API_USERNAME', '')
|
||||
self.password = getattr(settings, 'HIS_API_PASSWORD', '')
|
||||
self.username = getattr(settings, "HIS_API_USERNAME", "")
|
||||
self.password = getattr(settings, "HIS_API_PASSWORD", "")
|
||||
|
||||
def _get_default_config(self) -> Optional[IntegrationConfig]:
|
||||
"""Get default active HIS configuration from database."""
|
||||
@ -62,7 +62,7 @@ class HISClient:
|
||||
}
|
||||
|
||||
# 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:
|
||||
headers["X-API-Key"] = api_key
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
@ -78,7 +78,7 @@ class HISClient:
|
||||
from django.conf import settings
|
||||
|
||||
# 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:
|
||||
return settings_url
|
||||
|
||||
@ -159,18 +159,18 @@ class HISClient:
|
||||
|
||||
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:
|
||||
from_date: Start date for ratings
|
||||
to_date: End date for ratings
|
||||
|
||||
Returns:
|
||||
HIS response dict with FetchDoctorRatingMAPI1List or None on error
|
||||
HIS response dict with FetchDoctorRatingMAPIList or None on error
|
||||
"""
|
||||
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:
|
||||
logger.error("HIS_RATINGS_API_URL not configured in Django settings")
|
||||
return None
|
||||
@ -192,11 +192,17 @@ class HISClient:
|
||||
)
|
||||
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()
|
||||
|
||||
if isinstance(data, dict):
|
||||
logger.info(f"HIS doctor ratings response keys: {list(data.keys())}")
|
||||
rating_count = len(data.get("FetchDoctorRatingMAPI1List", []))
|
||||
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
|
||||
else:
|
||||
logger.error(f"Unexpected HIS response type: {type(data)}")
|
||||
@ -392,7 +398,7 @@ class HISClientFactory:
|
||||
from django.conf import settings
|
||||
|
||||
# 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)")
|
||||
return [HISClient()]
|
||||
|
||||
|
||||
@ -1,209 +1,20 @@
|
||||
"""
|
||||
Integrations Celery tasks
|
||||
|
||||
This module contains the core event processing logic that:
|
||||
1. Processes inbound events from external systems
|
||||
2. Finds matching journey instances
|
||||
3. Completes journey stages
|
||||
4. Triggers survey creation
|
||||
5. Fetches surveys from HIS systems (every 5 minutes)
|
||||
This module contains tasks for:
|
||||
1. Fetching surveys from HIS systems (every 25 minutes)
|
||||
2. Testing HIS connection
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from celery import shared_task
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
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
|
||||
# =============================================================================
|
||||
|
||||
@ -1,107 +1,141 @@
|
||||
"""
|
||||
Organizations 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)
|
||||
class OrganizationAdmin(admin.ModelAdmin):
|
||||
"""Organization admin"""
|
||||
list_display = ['name', 'code', 'city', 'status', 'created_at']
|
||||
list_filter = ['status', 'city']
|
||||
search_fields = ['name', 'name_ar', 'code', 'license_number']
|
||||
ordering = ['name']
|
||||
|
||||
list_display = ["name", "code", "city", "status", "created_at"]
|
||||
list_filter = ["status", "city"]
|
||||
search_fields = ["name", "name_ar", "code"]
|
||||
ordering = ["name"]
|
||||
|
||||
fieldsets = (
|
||||
(None, {'fields': ('name', 'name_ar', 'code')}),
|
||||
('Contact Information', {'fields': ('address', 'city', 'phone', 'email', 'website')}),
|
||||
('Details', {'fields': ('license_number', 'status', 'logo')}),
|
||||
('Metadata', {'fields': ('created_at', 'updated_at')}),
|
||||
(None, {"fields": ("name", "name_ar", "code")}),
|
||||
("Contact Information", {"fields": ("address", "city", "phone", "email")}),
|
||||
("Details", {"fields": ("status", "preferred_language")}),
|
||||
("Metadata", {"fields": ("created_at", "updated_at")}),
|
||||
)
|
||||
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
readonly_fields = ["created_at", "updated_at"]
|
||||
|
||||
|
||||
@admin.register(Hospital)
|
||||
class HospitalAdmin(admin.ModelAdmin):
|
||||
"""Hospital admin"""
|
||||
list_display = ['name', 'code', 'city', 'ceo', 'status', 'capacity', 'created_at']
|
||||
list_filter = ['status', 'city']
|
||||
search_fields = ['name', 'name_ar', 'code', 'license_number']
|
||||
ordering = ['name']
|
||||
|
||||
list_display = ["name", "code", "city", "ceo", "status", "capacity", "created_at"]
|
||||
list_filter = ["status", "city"]
|
||||
search_fields = ["name", "name_ar", "code", "license_number"]
|
||||
ordering = ["name"]
|
||||
|
||||
fieldsets = (
|
||||
(None, {'fields': ('organization', 'name', 'name_ar', 'code')}),
|
||||
('Contact Information', {'fields': ('address', 'city', 'phone', 'email')}),
|
||||
('Executive Leadership', {'fields': ('ceo', 'medical_director', 'coo', 'cfo')}),
|
||||
('Details', {'fields': ('license_number', 'capacity', 'status')}),
|
||||
('Metadata', {'fields': ('created_at', 'updated_at')}),
|
||||
(None, {"fields": ("organization", "name", "name_ar", "code")}),
|
||||
("Contact Information", {"fields": ("address", "city", "phone", "email")}),
|
||||
("Executive Leadership", {"fields": ("ceo", "medical_director", "coo", "cfo")}),
|
||||
("Details", {"fields": ("license_number", "capacity", "status")}),
|
||||
("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)
|
||||
class DepartmentAdmin(admin.ModelAdmin):
|
||||
"""Department admin"""
|
||||
list_display = ['name', 'hospital', 'code', 'manager', 'status', 'created_at']
|
||||
list_filter = ['status', 'hospital']
|
||||
search_fields = ['name', 'name_ar', 'code']
|
||||
ordering = ['hospital', 'name']
|
||||
autocomplete_fields = ['hospital', 'parent', 'manager']
|
||||
|
||||
list_display = ["name", "hospital", "code", "manager", "status", "created_at"]
|
||||
list_filter = ["status", "hospital"]
|
||||
search_fields = ["name", "name_ar", "code"]
|
||||
ordering = ["hospital", "name"]
|
||||
autocomplete_fields = ["hospital", "parent", "manager"]
|
||||
|
||||
fieldsets = (
|
||||
(None, {'fields': ('hospital', 'name', 'name_ar', 'code')}),
|
||||
('Hierarchy', {'fields': ('parent', 'manager')}),
|
||||
('Contact', {'fields': ('phone', 'email', 'location')}),
|
||||
('Status', {'fields': ('status',)}),
|
||||
('Metadata', {'fields': ('created_at', 'updated_at')}),
|
||||
(None, {"fields": ("hospital", "name", "name_ar", "code")}),
|
||||
("Hierarchy", {"fields": ("parent", "manager")}),
|
||||
("Contact", {"fields": ("phone", "email", "location")}),
|
||||
("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):
|
||||
qs = super().get_queryset(request)
|
||||
return qs.select_related('hospital', 'manager', 'parent')
|
||||
return qs.select_related("hospital", "manager", "parent")
|
||||
|
||||
|
||||
@admin.register(Staff)
|
||||
class StaffAdmin(admin.ModelAdmin):
|
||||
"""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']
|
||||
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']
|
||||
|
||||
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"]
|
||||
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 = (
|
||||
(None, {'fields': ('name', 'first_name', 'last_name', 'first_name_ar', 'last_name_ar')}),
|
||||
('Role', {'fields': ('staff_type', 'job_title')}),
|
||||
('Professional', {'fields': ('license_number', 'specialization', 'employee_id', 'email', 'phone')}),
|
||||
('Organization', {'fields': ('hospital', 'department', 'department_name', 'section', 'subsection', 'location')}),
|
||||
('Hierarchy', {'fields': ('report_to',)}),
|
||||
('Personal Information', {'fields': ('country', 'gender')}),
|
||||
('Account', {'fields': ('user',)}),
|
||||
('Status', {'fields': ('status',)}),
|
||||
('Metadata', {'fields': ('created_at', 'updated_at')}),
|
||||
(None, {"fields": ("name", "first_name", "last_name", "first_name_ar", "last_name_ar")}),
|
||||
("Role", {"fields": ("staff_type", "job_title")}),
|
||||
("Professional", {"fields": ("license_number", "specialization", "employee_id", "email", "phone")}),
|
||||
(
|
||||
"Organization",
|
||||
{"fields": ("hospital", "department", "department_name", "section", "subsection", "location")},
|
||||
),
|
||||
("Hierarchy", {"fields": ("report_to",)}),
|
||||
("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):
|
||||
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):
|
||||
"""Display user account status"""
|
||||
if obj.user:
|
||||
return '<span style="color: green;">✓ Yes</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
|
||||
|
||||
def create_user_accounts(self, request, queryset):
|
||||
@ -114,11 +148,7 @@ class StaffAdmin(admin.ModelAdmin):
|
||||
if not staff.user and staff.email:
|
||||
try:
|
||||
role = StaffService.get_staff_type_role(staff.staff_type)
|
||||
user, was_created, password = StaffService.create_user_for_staff(
|
||||
staff,
|
||||
role=role,
|
||||
request=request
|
||||
)
|
||||
user, was_created, password = StaffService.create_user_for_staff(staff, role=role, request=request)
|
||||
if was_created and password:
|
||||
StaffService.send_credentials_email(staff, password, request)
|
||||
created += 1
|
||||
@ -126,11 +156,10 @@ class StaffAdmin(admin.ModelAdmin):
|
||||
failed += 1
|
||||
|
||||
self.message_user(
|
||||
request,
|
||||
f'Created {created} user accounts. Failed: {failed}',
|
||||
level='success' if failed == 0 else 'warning'
|
||||
request, 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):
|
||||
"""Admin action to send credential emails to selected staff"""
|
||||
@ -150,38 +179,51 @@ class StaffAdmin(admin.ModelAdmin):
|
||||
failed += 1
|
||||
|
||||
self.message_user(
|
||||
request,
|
||||
f'Sent {sent} credential emails. Failed: {failed}',
|
||||
level='success' if failed == 0 else 'warning'
|
||||
request, 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)
|
||||
class PatientAdmin(admin.ModelAdmin):
|
||||
"""Patient admin"""
|
||||
list_display = ['get_full_name', 'mrn', 'national_id', 'phone', 'primary_hospital', 'status']
|
||||
list_filter = ['status', 'gender', 'primary_hospital', 'city']
|
||||
search_fields = ['mrn', 'national_id', 'first_name', 'last_name', 'first_name_ar', 'last_name_ar', 'phone', 'email']
|
||||
ordering = ['last_name', 'first_name']
|
||||
autocomplete_fields = ['primary_hospital']
|
||||
|
||||
list_display = ["get_full_name", "mrn", "get_masked_national_id", "phone", "primary_hospital", "status"]
|
||||
list_filter = ["status", "gender", "primary_hospital", "city"]
|
||||
search_fields = [
|
||||
"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 = (
|
||||
(None, {'fields': ('mrn', 'national_id')}),
|
||||
('Personal Information', {'fields': ('first_name', 'last_name', 'first_name_ar', 'last_name_ar')}),
|
||||
('Demographics', {'fields': ('date_of_birth', 'gender')}),
|
||||
('Contact', {'fields': ('phone', 'email', 'address', 'city')}),
|
||||
('Hospital', {'fields': ('primary_hospital',)}),
|
||||
('Status', {'fields': ('status',)}),
|
||||
('Metadata', {'fields': ('created_at', 'updated_at')}),
|
||||
(None, {"fields": ("mrn", "national_id")}),
|
||||
("Personal Information", {"fields": ("first_name", "last_name", "first_name_ar", "last_name_ar")}),
|
||||
("Demographics", {"fields": ("date_of_birth", "gender")}),
|
||||
("Contact", {"fields": ("phone", "email", "address", "city")}),
|
||||
("Hospital", {"fields": ("primary_hospital",)}),
|
||||
("Status", {"fields": ("status",)}),
|
||||
("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):
|
||||
qs = super().get_queryset(request)
|
||||
return qs.select_related('primary_hospital')
|
||||
|
||||
return qs.select_related("primary_hospital")
|
||||
|
||||
|
||||
admin.site.register(Location)
|
||||
|
||||
@ -5,6 +5,7 @@ Organizations models - Hospital, Department, Physician, Employee, Patient
|
||||
from django.db import models
|
||||
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
|
||||
|
||||
|
||||
@ -412,7 +413,8 @@ class Patient(UUIDModel, TimeStampedModel):
|
||||
|
||||
# Basic information
|
||||
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)
|
||||
last_name = models.CharField(max_length=100)
|
||||
@ -449,6 +451,17 @@ class Patient(UUIDModel, TimeStampedModel):
|
||||
def get_full_name(self):
|
||||
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
|
||||
def generate_mrn():
|
||||
"""
|
||||
|
||||
@ -268,6 +268,8 @@ class PatientSerializer(serializers.ModelSerializer):
|
||||
primary_hospital_name = serializers.CharField(source="primary_hospital.name", read_only=True)
|
||||
full_name = serializers.CharField(source="get_full_name", read_only=True)
|
||||
age = serializers.SerializerMethodField()
|
||||
national_id = serializers.SerializerMethodField()
|
||||
national_id_masked = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Patient
|
||||
@ -275,6 +277,7 @@ class PatientSerializer(serializers.ModelSerializer):
|
||||
"id",
|
||||
"mrn",
|
||||
"national_id",
|
||||
"national_id_masked",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"first_name_ar",
|
||||
@ -295,6 +298,15 @@ class PatientSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
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):
|
||||
"""Calculate patient age"""
|
||||
if obj.date_of_birth:
|
||||
@ -314,10 +326,11 @@ class PatientListSerializer(serializers.ModelSerializer):
|
||||
|
||||
full_name = serializers.CharField(source="get_full_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:
|
||||
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):
|
||||
|
||||
@ -324,11 +324,14 @@ def patient_list(request):
|
||||
# Search
|
||||
search_query = request.GET.get("search")
|
||||
if search_query:
|
||||
from apps.core.encryption import compute_national_id_hash
|
||||
|
||||
nid_hash = compute_national_id_hash(search_query)
|
||||
queryset = queryset.filter(
|
||||
Q(mrn__icontains=search_query)
|
||||
| Q(first_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)
|
||||
)
|
||||
|
||||
@ -375,7 +378,7 @@ def patient_list(request):
|
||||
p.mrn,
|
||||
p.first_name,
|
||||
p.last_name,
|
||||
p.national_id,
|
||||
p.get_masked_national_id(),
|
||||
p.get_gender_display(),
|
||||
p.nationality,
|
||||
p.phone,
|
||||
|
||||
@ -610,7 +610,7 @@ class PatientViewSet(viewsets.ModelViewSet):
|
||||
queryset = Patient.objects.all()
|
||||
permission_classes = [IsAuthenticated]
|
||||
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 = ["last_name", "first_name"]
|
||||
|
||||
@ -647,15 +647,27 @@ class PatientViewSet(viewsets.ModelViewSet):
|
||||
q = request.query_params.get("q", "").strip()
|
||||
queryset = self.get_queryset().filter(status="active")
|
||||
if q:
|
||||
from apps.core.encryption import compute_national_id_hash
|
||||
|
||||
nid_hash = compute_national_id_hash(q)
|
||||
queryset = queryset.filter(
|
||||
models.Q(mrn__icontains=q)
|
||||
| models.Q(first_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)
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
@ -706,7 +718,7 @@ class SubSectionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
@permission_classes([])
|
||||
def api_location_list(request):
|
||||
"""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)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
@ -81,14 +81,19 @@ class DoctorRatingAdapter:
|
||||
|
||||
formats = [
|
||||
"%d-%b-%Y %H:%M:%S",
|
||||
"%d-%b-%Y %H:%M",
|
||||
"%d-%b-%Y",
|
||||
"%d-%b-%y %H:%M:%S",
|
||||
"%d-%b-%y %H:%M",
|
||||
"%d-%b-%y",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
"%Y-%m-%d %H:%M",
|
||||
"%Y-%m-%d",
|
||||
"%d/%m/%Y %H:%M:%S",
|
||||
"%d/%m/%Y %H:%M",
|
||||
"%d/%m/%Y",
|
||||
"%m/%d/%Y %H:%M:%S",
|
||||
"%m/%d/%Y %H:%M",
|
||||
"%m/%d/%Y",
|
||||
]
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ Physicians Forms
|
||||
|
||||
Forms for doctor rating imports and filtering.
|
||||
"""
|
||||
|
||||
from django import forms
|
||||
|
||||
from apps.organizations.models import Hospital
|
||||
@ -17,16 +18,17 @@ class DoctorRatingImportForm(HospitalFieldMixin, forms.Form):
|
||||
- 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'),
|
||||
queryset=Hospital.objects.filter(status="active"),
|
||||
label="Hospital",
|
||||
help_text="Select the hospital these ratings belong to"
|
||||
help_text="Select the hospital these ratings belong to",
|
||||
)
|
||||
|
||||
csv_file = forms.FileField(
|
||||
label="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(
|
||||
@ -34,14 +36,14 @@ class DoctorRatingImportForm(HospitalFieldMixin, forms.Form):
|
||||
initial=6,
|
||||
min_value=0,
|
||||
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):
|
||||
csv_file = self.cleaned_data['csv_file']
|
||||
csv_file = self.cleaned_data["csv_file"]
|
||||
|
||||
# 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)")
|
||||
|
||||
# Check file size (max 10MB)
|
||||
@ -51,26 +53,69 @@ class DoctorRatingImportForm(HospitalFieldMixin, forms.Form):
|
||||
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):
|
||||
"""
|
||||
Form for filtering individual doctor ratings.
|
||||
"""
|
||||
|
||||
hospital = forms.ModelChoiceField(
|
||||
queryset=Hospital.objects.filter(status='active'),
|
||||
required=False,
|
||||
label="Hospital"
|
||||
queryset=Hospital.objects.filter(status="active"), required=False, label="Hospital"
|
||||
)
|
||||
|
||||
doctor_id = forms.CharField(
|
||||
required=False,
|
||||
label="Doctor ID",
|
||||
widget=forms.TextInput(attrs={'placeholder': 'e.g., 10738'})
|
||||
required=False, label="Doctor ID", widget=forms.TextInput(attrs={"placeholder": "e.g., 10738"})
|
||||
)
|
||||
|
||||
doctor_name = forms.CharField(
|
||||
required=False,
|
||||
label="Doctor Name",
|
||||
widget=forms.TextInput(attrs={'placeholder': 'Search by doctor name'})
|
||||
required=False, label="Doctor Name", widget=forms.TextInput(attrs={"placeholder": "Search by doctor name"})
|
||||
)
|
||||
|
||||
rating_min = forms.IntegerField(
|
||||
@ -78,7 +123,7 @@ class DoctorRatingFilterForm(forms.Form):
|
||||
min_value=1,
|
||||
max_value=5,
|
||||
label="Min Rating",
|
||||
widget=forms.NumberInput(attrs={'placeholder': '1-5'})
|
||||
widget=forms.NumberInput(attrs={"placeholder": "1-5"}),
|
||||
)
|
||||
|
||||
rating_max = forms.IntegerField(
|
||||
@ -86,29 +131,18 @@ class DoctorRatingFilterForm(forms.Form):
|
||||
min_value=1,
|
||||
max_value=5,
|
||||
label="Max Rating",
|
||||
widget=forms.NumberInput(attrs={'placeholder': '1-5'})
|
||||
widget=forms.NumberInput(attrs={"placeholder": "1-5"}),
|
||||
)
|
||||
|
||||
date_from = forms.DateField(
|
||||
required=False,
|
||||
label="From Date",
|
||||
widget=forms.DateInput(attrs={'type': 'date'})
|
||||
)
|
||||
date_from = forms.DateField(required=False, label="From Date", widget=forms.DateInput(attrs={"type": "date"}))
|
||||
|
||||
date_to = forms.DateField(
|
||||
required=False,
|
||||
label="To Date",
|
||||
widget=forms.DateInput(attrs={'type': 'date'})
|
||||
)
|
||||
date_to = forms.DateField(required=False, label="To Date", widget=forms.DateInput(attrs={"type": "date"}))
|
||||
|
||||
source = forms.ChoiceField(
|
||||
required=False,
|
||||
label="Source",
|
||||
choices=[('', 'All Sources')] + [
|
||||
('his_api', 'HIS API'),
|
||||
('csv_import', 'CSV Import'),
|
||||
('manual', 'Manual Entry')
|
||||
]
|
||||
choices=[("", "All Sources")]
|
||||
+ [("his_api", "HIS API"), ("csv_import", "CSV Import"), ("manual", "Manual Entry")],
|
||||
)
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
@ -116,9 +150,9 @@ class DoctorRatingFilterForm(forms.Form):
|
||||
|
||||
# Filter hospital choices based on user role
|
||||
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:
|
||||
self.fields['hospital'].queryset = Hospital.objects.filter(id=user.hospital.id)
|
||||
self.fields['hospital'].initial = user.hospital
|
||||
self.fields["hospital"].queryset = Hospital.objects.filter(id=user.hospital.id)
|
||||
self.fields["hospital"].initial = user.hospital
|
||||
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 .adapter import DoctorRatingAdapter
|
||||
from .forms import DoctorRatingImportForm
|
||||
from .forms import DoctorRatingFetchForm, DoctorRatingImportForm
|
||||
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__)
|
||||
|
||||
@ -226,6 +226,82 @@ def doctor_rating_import(request):
|
||||
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
|
||||
def doctor_rating_review(request):
|
||||
"""
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"""
|
||||
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,
|
||||
multiple months, or full historical data.
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ Background tasks for:
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
from celery import shared_task
|
||||
from django.utils import timezone
|
||||
@ -249,84 +250,81 @@ def cleanup_old_import_jobs(days: int = 30):
|
||||
return {"cleaned_count": count}
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3, default_retry_delay=300)
|
||||
def fetch_his_doctor_ratings_monthly(self):
|
||||
def _fetch_and_process_his_doctor_ratings(job_id: str, from_date_iso: str, to_date_iso: str) -> Dict:
|
||||
"""
|
||||
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.
|
||||
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.
|
||||
Can be called synchronously (from a view) or wrapped in a Celery task.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from calendar import monthrange
|
||||
|
||||
from apps.integrations.services.his_client import HISClient
|
||||
|
||||
try:
|
||||
# Calculate previous month
|
||||
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
|
||||
job = DoctorRatingImportJob.objects.get(id=job_id)
|
||||
except DoctorRatingImportJob.DoesNotExist:
|
||||
logger.error(f"Doctor rating import job {job_id} not found")
|
||||
return {"error": "Job not found"}
|
||||
|
||||
month_label = f"{target_year}-{target_month:02d}"
|
||||
logger.info(f"Starting monthly HIS doctor rating fetch for {month_label}")
|
||||
try:
|
||||
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
|
||||
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, 23, 59, 59)
|
||||
date_label = f"{from_date_iso} to {to_date_iso}"
|
||||
logger.info(f"Starting HIS doctor rating fetch for {date_label}")
|
||||
|
||||
# Initialize HIS client
|
||||
from apps.integrations.services.his_client import HISClient
|
||||
job.status = DoctorRatingImportJob.JobStatus.PROCESSING
|
||||
job.started_at = timezone.now()
|
||||
job.save()
|
||||
|
||||
client = HISClient()
|
||||
|
||||
# Fetch ratings from HIS
|
||||
his_data = client.fetch_doctor_ratings(from_date, to_date)
|
||||
|
||||
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")
|
||||
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:
|
||||
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}")
|
||||
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", [])
|
||||
|
||||
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 {
|
||||
"success": True,
|
||||
"month": month_label,
|
||||
"date_range": date_label,
|
||||
"total_ratings": 0,
|
||||
"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
|
||||
first_hospital = Hospital.objects.first()
|
||||
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")
|
||||
job.total_records = len(ratings_list)
|
||||
job.save()
|
||||
|
||||
# Process ratings
|
||||
stats = {
|
||||
"total": len(ratings_list),
|
||||
"success": 0,
|
||||
@ -337,7 +335,6 @@ def fetch_his_doctor_ratings_monthly(self):
|
||||
|
||||
for idx, rating_data in enumerate(ratings_list, 1):
|
||||
try:
|
||||
# Find hospital by name
|
||||
hospital_name = rating_data.get("HospitalName", "")
|
||||
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}")
|
||||
continue
|
||||
|
||||
# Process the rating
|
||||
result = DoctorRatingAdapter.process_his_rating_record(rating_data, hospital)
|
||||
|
||||
if result["is_duplicate"]:
|
||||
@ -362,8 +358,7 @@ def fetch_his_doctor_ratings_monthly(self):
|
||||
stats["failed"] += 1
|
||||
logger.warning(f"Failed to process rating: {result.get('message')}")
|
||||
|
||||
# Update job progress every 100 records
|
||||
if job and idx % 100 == 0:
|
||||
if idx % 100 == 0:
|
||||
job.processed_count = idx
|
||||
job.success_count = stats["success"]
|
||||
job.failed_count = stats["failed"]
|
||||
@ -374,31 +369,29 @@ def fetch_his_doctor_ratings_monthly(self):
|
||||
stats["failed"] += 1
|
||||
logger.error(f"Error processing rating {idx}: {e}", exc_info=True)
|
||||
|
||||
# Finalize job
|
||||
if job:
|
||||
job.processed_count = stats["total"]
|
||||
job.success_count = stats["success"]
|
||||
job.failed_count = stats["failed"]
|
||||
job.completed_at = timezone.now()
|
||||
job.processed_count = stats["total"]
|
||||
job.success_count = stats["success"]
|
||||
job.failed_count = stats["failed"]
|
||||
job.completed_at = timezone.now()
|
||||
|
||||
if stats["failed"] == 0:
|
||||
job.status = DoctorRatingImportJob.JobStatus.COMPLETED
|
||||
elif stats["success"] == 0:
|
||||
job.status = DoctorRatingImportJob.JobStatus.FAILED
|
||||
else:
|
||||
job.status = DoctorRatingImportJob.JobStatus.PARTIAL
|
||||
if stats["failed"] == 0:
|
||||
job.status = DoctorRatingImportJob.JobStatus.COMPLETED
|
||||
elif stats["success"] == 0:
|
||||
job.status = DoctorRatingImportJob.JobStatus.FAILED
|
||||
else:
|
||||
job.status = DoctorRatingImportJob.JobStatus.PARTIAL
|
||||
|
||||
job.results = {"stats": stats}
|
||||
job.save()
|
||||
job.results = {"stats": stats}
|
||||
job.save()
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"month": month_label,
|
||||
"date_range": date_label,
|
||||
"total_ratings": stats["total"],
|
||||
"success_count": stats["success"],
|
||||
"failed_count": stats["failed"],
|
||||
@ -407,6 +400,117 @@ def fetch_his_doctor_ratings_monthly(self):
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Error in monthly HIS doctor rating fetch: {exc}", exc_info=True)
|
||||
# Retry the task
|
||||
logger.error(f"Error in HIS doctor rating fetch: {exc}", exc_info=True)
|
||||
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)
|
||||
|
||||
@ -34,6 +34,8 @@ urlpatterns = [
|
||||
path("individual-ratings/", import_views.individual_ratings_list, name="individual_ratings_list"),
|
||||
# Doctor Rating Import (CSV Upload)
|
||||
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/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"),
|
||||
|
||||
@ -99,17 +99,31 @@ def source_detail(request, pk):
|
||||
from datetime import timedelta
|
||||
|
||||
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 = {
|
||||
"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 = {
|
||||
"source": source,
|
||||
"usage_records": usage_records,
|
||||
"source_users": source_users,
|
||||
"available_users": available_users,
|
||||
"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)
|
||||
|
||||
@ -214,7 +214,11 @@ def survey_instance_detail(request, pk):
|
||||
@login_required
|
||||
def survey_template_list(request):
|
||||
"""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
|
||||
if user.is_px_admin():
|
||||
|
||||
@ -29,17 +29,12 @@ app.autodiscover_tasks()
|
||||
|
||||
# Celery Beat schedule for periodic tasks
|
||||
app.conf.beat_schedule = {
|
||||
# Process unprocessed integration events every 1 minute
|
||||
"process-integration-events": {
|
||||
"task": "apps.integrations.tasks.process_pending_events",
|
||||
"schedule": crontab(minute="*/1"),
|
||||
},
|
||||
# Fetch patient data from HIS every 5 minutes
|
||||
# Fetch patient data from HIS every 25 minutes
|
||||
"fetch-his-surveys": {
|
||||
"task": "apps.integrations.tasks.fetch_his_surveys",
|
||||
"schedule": crontab(minute="*/5"),
|
||||
"schedule": crontab(minute="*/25"),
|
||||
"options": {
|
||||
"expires": 300,
|
||||
"expires": 1500,
|
||||
},
|
||||
},
|
||||
# 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",
|
||||
"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-ratings": {
|
||||
"task": "apps.physicians.tasks.calculate_monthly_ratings",
|
||||
@ -183,10 +183,10 @@ app.conf.beat_schedule = {
|
||||
"task": "apps.surveys.tasks.process_survey_text_analysis",
|
||||
"schedule": crontab(minute="*/30"),
|
||||
},
|
||||
# Pre-compute analytics dashboard cache every 5 minutes
|
||||
# Pre-compute analytics dashboard cache daily at 3 AM
|
||||
"precompute-analytics-cache": {
|
||||
"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-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
|
||||
annotated-types==0.7.0
|
||||
anyio==4.12.0
|
||||
asgiref==3.11.0
|
||||
attrs==25.4.0
|
||||
billiard==4.2.4
|
||||
brotli==1.2.0
|
||||
cachetools==6.2.4
|
||||
celery==5.6.2
|
||||
certifi==2026.1.4
|
||||
cffi==2.0.0
|
||||
charset-normalizer==3.4.4
|
||||
click==8.3.1
|
||||
click-didyoumean==0.3.1
|
||||
click-plugins==1.1.1.2
|
||||
click-repl==0.3.0
|
||||
cron_descriptor==2.0.6
|
||||
Django==5.2.10
|
||||
django-celery-beat==2.8.1
|
||||
django-crontab==0.7.1
|
||||
cron-descriptor==1.4.5
|
||||
cssselect2==0.8.0
|
||||
distro==1.9.0
|
||||
django==6.0.1
|
||||
django-celery-beat==2.9.0
|
||||
django-environ==0.12.0
|
||||
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
|
||||
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-python-client==2.188.0
|
||||
google-auth==2.47.0
|
||||
google-api-python-client==2.187.0
|
||||
google-auth==2.41.1
|
||||
google-auth-httplib2==0.3.0
|
||||
google-auth-oauthlib==1.2.4
|
||||
google-auth-oauthlib==1.2.3
|
||||
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
|
||||
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
|
||||
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
|
||||
packaging==26.0
|
||||
prompt_toolkit==3.0.52
|
||||
openai==2.14.0
|
||||
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
|
||||
protobuf==6.33.4
|
||||
pyasn1==0.6.2
|
||||
pyasn1_modules==0.4.2
|
||||
pyparsing==3.3.2
|
||||
protobuf==6.33.3
|
||||
psycopg2-binary==2.9.11
|
||||
-e file:///home/ismail/projects/HH
|
||||
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-dateutil==2.9.0.post0
|
||||
python-dotenv==1.2.1
|
||||
pytz==2025.2
|
||||
pyyaml==6.0.3
|
||||
redis==7.1.0
|
||||
referencing==0.37.0
|
||||
regex==2025.11.3
|
||||
reportlab==4.4.7
|
||||
requests==2.32.5
|
||||
requests-oauthlib==2.0.0
|
||||
rich==14.2.0
|
||||
rpds-py==0.30.0
|
||||
rsa==4.9.1
|
||||
shellingham==1.5.4
|
||||
six==1.17.0
|
||||
sniffio==1.3.1
|
||||
sqlparse==0.5.5
|
||||
stack-data==0.6.3
|
||||
traitlets==5.14.3
|
||||
trio==0.32.0
|
||||
trio-websocket==0.12.2
|
||||
tiktoken==0.12.0
|
||||
tinycss2==1.5.1
|
||||
tinyhtml5==2.0.0
|
||||
tokenizers==0.22.2
|
||||
tqdm==4.67.1
|
||||
tweepy==4.16.0
|
||||
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
|
||||
typing_extensions==4.15.0
|
||||
typing-extensions==4.15.0
|
||||
typing-inspection==0.4.2
|
||||
tzdata==2025.3
|
||||
tzlocal==5.3.1
|
||||
ua-parser==1.0.1
|
||||
ua-parser-builtins==202601
|
||||
unidecode==1.4.0
|
||||
uritemplate==4.2.0
|
||||
urllib3==2.6.3
|
||||
urllib3==2.6.2
|
||||
user-agents==2.2.0
|
||||
vine==5.1.0
|
||||
wcwidth==0.3.1
|
||||
rich==13.9.4
|
||||
watchdog==6.0.0
|
||||
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 %}
|
||||
|
||||
{% 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 hero_title %}{% trans "Welcome to PX360!" %}{% endblock %}
|
||||
|
||||
{% block hero_subtitle %}{% trans "Your comprehensive Patient Experience management platform" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Greeting -->
|
||||
<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>
|
||||
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">{% trans "Welcome to PX360!" %}</h1>
|
||||
|
||||
<!-- What You'll Do -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-top: 25px;">
|
||||
<tr>
|
||||
<td>
|
||||
<h3 style="margin: 0 0 15px 0; font-size: 18px; font-weight: 600; color: #005696;">
|
||||
{% trans "During the onboarding process, you will:" %}
|
||||
</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>
|
||||
<p style="margin: 0 0 15px; 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: 0 0 20px; font-size: 16px; color: #475569; 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>
|
||||
|
||||
<!-- Item 2 -->
|
||||
<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 "Set up your profile and preferences" %}
|
||||
</p>
|
||||
</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>
|
||||
<!-- Onboarding Steps -->
|
||||
<div style="margin-bottom: 25px;">
|
||||
<p style="margin: 0 0 12px; font-size: 15px; font-weight: 600; color: #005696;">{% trans "During onboarding, you will:" %}</p>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr><td style="padding: 6px 0; font-size: 15px; color: #475569;">✓ {% trans "Learn about PX360 features and your role" %}</td></tr>
|
||||
<tr><td style="padding: 6px 0; font-size: 15px; color: #475569;">✓ {% trans "Set up your profile and preferences" %}</td></tr>
|
||||
<tr><td style="padding: 6px 0; font-size: 15px; color: #475569;">✓ {% trans "Complete required training" %}</td></tr>
|
||||
<tr><td style="padding: 6px 0; font-size: 15px; color: #475569;">✓ {% trans "Activate your account" %}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<td align="center">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
|
||||
<tr>
|
||||
<td align="center" style="border-radius: 6px;" bgcolor="#005696">
|
||||
<a href="{{ invitation_url }}" target="_blank"
|
||||
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>
|
||||
<a href="{{ invitation_url }}"
|
||||
style="display: inline-block; padding: 12px 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>
|
||||
|
||||
<!-- Security Notice -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-top: 30px;">
|
||||
<tr>
|
||||
<td style="background-color: #fffbeb; border-left: 4px solid #f59e0b; padding: 15px; border-radius: 4px;">
|
||||
<p style="margin: 0; font-size: 14px; color: #92400e; line-height: 1.5;">
|
||||
<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>
|
||||
<div style="padding: 15px; background-color: #fef3c7; border-radius: 6px; margin-top: 20px;">
|
||||
<p style="margin: 0; font-size: 14px; color: #92400e; line-height: 1.5;">
|
||||
<strong>{% trans "Important:" %}</strong> {% trans "This invitation link will expire in 7 days." %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Help Section -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-top: 25px;">
|
||||
<tr>
|
||||
<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>
|
||||
<p style="margin: 25px 0 0; font-size: 13px; color: #94a3b8; line-height: 1.5;">
|
||||
{% trans "Need help? Contact support@alhammadi.com or call +966 11 123 4567." %}
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
@ -12,64 +12,99 @@
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-8">
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h1 class="text-xl md:text-2xl font-bold text-navy mb-1">
|
||||
{% trans "Review Onboarding Material" %}
|
||||
{{ current_content.get_localized_title }}
|
||||
</h1>
|
||||
{% if current_content.get_localized_description %}
|
||||
<p class="text-sm text-slate">
|
||||
{% trans "Please review the following important information" %}
|
||||
{{ current_content.get_localized_description }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Progress -->
|
||||
<div class="mb-8">
|
||||
<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-navy">100%</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">{{ progress_percentage }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 h-2 rounded-full overflow-hidden">
|
||||
<div class="bg-navy h-full w-full"></div>
|
||||
<div class="w-full bg-gray-200 h-2.5 rounded-full overflow-hidden">
|
||||
<div class="bg-navy h-full rounded-full transition-all duration-500" style="width: {{ progress_percentage }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="space-y-6 mb-8">
|
||||
{% for content in content_items %}
|
||||
<div class="bg-slate-50 rounded-xl p-6 border border-slate-200">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="flex items-center justify-center w-12 h-12 bg-light rounded-xl flex-shrink-0">
|
||||
<i data-lucide="file-text" class="w-6 h-6 text-navy"></i>
|
||||
</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>
|
||||
<!-- Step Indicators -->
|
||||
<div class="flex items-center justify-center gap-2 mb-8">
|
||||
{% for item in content %}
|
||||
<div class="flex items-center">
|
||||
{% if item.id == current_content.id %}
|
||||
<div class="w-8 h-8 rounded-full bg-navy text-white flex items-center justify-center text-sm font-bold">
|
||||
{{ forloop.counter }}
|
||||
</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>
|
||||
{% endfor %}
|
||||
</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 -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<form method="post">
|
||||
<form method="post" id="stepForm">
|
||||
{% 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">
|
||||
{% trans "Complete Onboarding" %}
|
||||
<i data-lucide="check-circle" class="w-5 h-5 inline ml-2"></i>
|
||||
</button>
|
||||
<input type="hidden" name="step" value="{{ step }}">
|
||||
<div class="flex gap-3">
|
||||
{% if previous_step %}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -78,6 +113,16 @@
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
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>
|
||||
{% endblock %}
|
||||
@ -826,9 +826,32 @@
|
||||
function refreshDashboard() {
|
||||
const icon = document.querySelector('[data-lucide="refresh-cw"]');
|
||||
icon.classList.add('animate-spin');
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 500);
|
||||
|
||||
// Call API to trigger cache refresh
|
||||
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() {
|
||||
|
||||
@ -357,7 +357,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
<small class="text-muted">
|
||||
MRN: ${patient.mrn} |
|
||||
Phone: ${patient.phone || 'N/A'} |
|
||||
ID: ${patient.national_id || 'N/A'}
|
||||
ID: ${patient.national_id_masked || 'N/A'}
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@ -302,7 +302,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
<small class="text-muted">
|
||||
MRN: ${patient.mrn} |
|
||||
Phone: ${patient.phone || 'N/A'} |
|
||||
ID: ${patient.national_id || 'N/A'}
|
||||
ID: ${patient.national_id_masked || 'N/A'}
|
||||
</small>
|
||||
</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
|
||||
complaintTypeCards.forEach(card => {
|
||||
card.addEventListener('click', function() {
|
||||
@ -807,6 +875,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
this.classList.add('active');
|
||||
complaintTypeInput.value = this.dataset.value;
|
||||
});
|
||||
});
|
||||
|
||||
// Patient auto-lookup by national_id
|
||||
|
||||
@ -138,22 +138,45 @@
|
||||
|
||||
<div>
|
||||
<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>
|
||||
<select name="admin_user" required class="form-select">
|
||||
<option value="">{% trans "Select an admin..." %}</option>
|
||||
{% for admin in available_admins %}
|
||||
<option value="{{ admin.id }}">
|
||||
{{ admin.get_full_name|default:admin.email }} ({{ admin.email }})
|
||||
</option>
|
||||
{% empty %}
|
||||
<option value="" disabled>{% trans "No available PX Admins" %}</option>
|
||||
{% endfor %}
|
||||
<option value="">{% trans "Select a user..." %}</option>
|
||||
{% if available_px_admins %}
|
||||
<optgroup label="{% trans 'PX Admins' %}">
|
||||
{% for admin in available_px_admins %}
|
||||
<option value="{{ admin.id }}">
|
||||
{{ admin.get_full_name|default:admin.email }} ({{ admin.email }})
|
||||
</option>
|
||||
{% 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>
|
||||
{% if not available_admins %}
|
||||
<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>
|
||||
{% trans "All PX Admins are already assigned to this schedule." %}
|
||||
{% trans "All eligible users are already assigned to this schedule." %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@ -5,14 +5,16 @@
|
||||
<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>
|
||||
{% 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 %}
|
||||
<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 "Analyze" %}
|
||||
{% if complaint.emotion or complaint.short_description_en or complaint.suggested_actions %}
|
||||
{% trans "Reanalyze" %}
|
||||
{% else %}
|
||||
{% trans "Analyze" %}
|
||||
{% endif %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if complaint.emotion %}
|
||||
@ -118,12 +120,9 @@
|
||||
{% if not complaint.emotion and not complaint.short_description_en and not complaint.suggested_actions %}
|
||||
<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>
|
||||
<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 %}
|
||||
<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">
|
||||
<i data-lucide="sparkles" class="w-4 h-4"></i>
|
||||
{% trans "Run AI Analysis" %}
|
||||
</button>
|
||||
<p class="text-slate text-sm mt-1">{% trans "Click \"Analyze\" above to run AI analysis" %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@ -189,7 +188,7 @@ function reanalyzeComplaintAI() {
|
||||
})
|
||||
.then(data => {
|
||||
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') {
|
||||
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 %}
|
||||
|
||||
{% 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">
|
||||
<!-- Success Icon Animation -->
|
||||
<div class="text-center mb-8">
|
||||
@ -184,7 +184,7 @@
|
||||
|
||||
<!-- Footer Note -->
|
||||
<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" %}
|
||||
<i data-lucide="heart" class="w-4 h-4 inline-block text-red-500 mx-1"></i>
|
||||
</p>
|
||||
|
||||
@ -9,11 +9,6 @@ header.glass-card {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.public-bg {
|
||||
background: linear-gradient(to bottom right, #005696, #007bbd, #eef6fb) !important;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.lang-switcher {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
@ -166,21 +161,22 @@ body.public-bg {
|
||||
<div class="flex items-center gap-3">
|
||||
<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>
|
||||
<p class="font-bold text-navy">{{ complaint.get_status_display }}</p>
|
||||
<p class="font-bold text-navy">{{ public_status.label }}</p>
|
||||
</div>
|
||||
<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
|
||||
{% elif complaint.status == 'in_progress' %}bg-blue-50 text-blue-700 border-blue-200
|
||||
{% elif complaint.status == 'resolved' %}bg-emerald-50 text-emerald-700 border-emerald-200
|
||||
{% if public_status.css == 'amber' %}bg-amber-50 text-amber-700 border-amber-200
|
||||
{% elif public_status.css == 'blue' %}bg-blue-50 text-blue-700 border-blue-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 %}">
|
||||
{{ complaint.status }}
|
||||
{{ public_status.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
style="width: {% if complaint.status == 'resolved' %}100%{% elif complaint.status == 'in_progress' %}50%{% else %}15%{% endif %}">
|
||||
style="width: {{ public_status.progress }}%">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -39,7 +39,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<i data-lucide="trending-up" class="w-8 h-8 text-orange-600"></i>
|
||||
</div>
|
||||
@ -62,7 +62,7 @@
|
||||
<i data-lucide="settings-2" class="w-4 h-4"></i>
|
||||
{% trans "Manage Thresholds" %}
|
||||
</a>
|
||||
</div>
|
||||
</div> {% endcomment %}
|
||||
|
||||
<!-- On-Call Schedules -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6 text-center card-hover">
|
||||
@ -80,7 +80,7 @@
|
||||
</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">
|
||||
<i data-lucide="zap" class="w-5 h-5 text-yellow-500"></i>
|
||||
{% trans "PX Actions" %}
|
||||
@ -112,7 +112,7 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section> {% endcomment %}
|
||||
|
||||
<!-- User Management Section -->
|
||||
<section class="mb-8">
|
||||
@ -127,8 +127,8 @@
|
||||
<i data-lucide="users" class="w-8 h-8 text-green-600"></i>
|
||||
</div>
|
||||
<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>
|
||||
<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">
|
||||
<p class="text-sm text-slate mb-4">{{ active_users_count }} {% trans "active users" %}</p>
|
||||
<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>
|
||||
{% trans "Manage Users" %}
|
||||
</a>
|
||||
@ -170,7 +170,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<i data-lucide="bell-ring" class="w-8 h-8 text-pink-600"></i>
|
||||
</div>
|
||||
@ -180,7 +180,7 @@
|
||||
<i data-lucide="bell" class="w-4 h-4"></i>
|
||||
{% trans "Configure" %}
|
||||
</a>
|
||||
</div>
|
||||
</div> {% endcomment %}
|
||||
|
||||
<!-- Send SMS -->
|
||||
<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;
|
||||
}
|
||||
|
||||
/* Match login page background */
|
||||
body.public-bg {
|
||||
background: linear-gradient(to bottom right, #005696, #007bbd, #eef6fb) !important;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Language switcher styles */
|
||||
.lang-switcher {
|
||||
position: fixed;
|
||||
|
||||
@ -12,198 +12,69 @@
|
||||
<meta name="supported-color-schemes" content="light">
|
||||
<title>{% block title %}Al Hammadi Hospital{% endblock %}</title>
|
||||
<style>
|
||||
/* Reset Styles */
|
||||
/* Reset */
|
||||
html, body { margin: 0; padding: 0; width: 100% !important; height: 100% !important; }
|
||||
* { -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; }
|
||||
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; }
|
||||
|
||||
/* Client-specific resets */
|
||||
#outlook a { padding: 0; }
|
||||
.ExternalClass { width: 100%; }
|
||||
.ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div { line-height: 100%; }
|
||||
|
||||
/* Body centering */
|
||||
body, table, td { margin: 0 auto; }
|
||||
|
||||
/* Responsive styles */
|
||||
/* Responsive */
|
||||
@media only screen and (max-width: 600px) {
|
||||
.email-container { width: 100% !important; max-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; }
|
||||
.email-container { width: 100% !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>
|
||||
|
||||
<!-- Block for additional custom styles -->
|
||||
{% block extra_styles %}{% endblock %}
|
||||
</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;">
|
||||
{% block preheader %}Al Hammadi Hospital - Patient Experience Management{% endblock %}
|
||||
</div>
|
||||
|
||||
<!-- Email Container - Full width background -->
|
||||
<!-- Container -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f8fafc;">
|
||||
<tr>
|
||||
<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 -->
|
||||
<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 -->
|
||||
<!-- Logo -->
|
||||
<tr>
|
||||
<td style="background: linear-gradient(135deg, #005696 0%, #007bbd 100%); padding: 30px 40px; text-align: center;">
|
||||
<!-- Logo -->
|
||||
<td style="padding: 30px 40px 20px; text-align: center;">
|
||||
{% email_logo_url as default_logo_url %}
|
||||
<a href="#" style="text-decoration: none;">
|
||||
<img src="{{ logo_url|default:default_logo_url }}"
|
||||
alt="Al Hammadi Hospital"
|
||||
width="400"
|
||||
height="120"
|
||||
style="display: block; margin: 0 auto; max-width: 100%; font-family: sans-serif; color: #ffffff;">
|
||||
</a>
|
||||
<img src="{{ logo_url|default:default_logo_url }}"
|
||||
alt="Al Hammadi Hospital"
|
||||
width="200"
|
||||
style="display: block; margin: 0 auto; max-width: 200px; height: auto;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Hero/Title Section -->
|
||||
{% block hero %}
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #ffffff;">
|
||||
<h1 class="heading-mobile" style="margin: 0 0 10px 0; font-size: 26px; font-weight: 700; color: #005696; line-height: 1.4;">
|
||||
{% 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 class="content-padding" style="padding: 0 40px 40px;">
|
||||
{% block content %}{% endblock %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Call-to-Action Section -->
|
||||
{% block cta_section %}
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td align="center" style="padding: 0 40px 40px 40px; background-color: #ffffff;">
|
||||
<!-- Primary Button -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
|
||||
<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 %}
|
||||
<td style="padding: 20px 40px; border-top: 1px solid #e2e8f0; text-align: center;">
|
||||
<p style="margin: 0; font-size: 12px; color: #64748b; line-height: 1.5;">
|
||||
{% block footer %}
|
||||
© {% now "Y" %} {% trans "Al Hammadi Hospital. All rights reserved." %}
|
||||
{% endblock %}
|
||||
</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>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
<!-- End Main Email Wrapper -->
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- End Email Container -->
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -2,162 +2,79 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% 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 hero_title %}{% trans "Explanation Request" %}{% endblock %}
|
||||
|
||||
{% block hero_subtitle %}{% trans "Please review the complaint details and submit your response" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Greeting -->
|
||||
<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>{{ staff_name }}</strong>,
|
||||
</p>
|
||||
<p style="margin: 15px 0 0 0; font-size: 16px; color: #64748b; line-height: 1.6;">
|
||||
{% 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>
|
||||
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">{% trans "Explanation Request" %}</h1>
|
||||
|
||||
<p style="margin: 0 0 15px; font-size: 16px; color: #1e293b; line-height: 1.6;">
|
||||
{% trans "Dear" %} <strong>{{ staff_name }}</strong>,
|
||||
</p>
|
||||
<p style="margin: 0 0 20px; font-size: 16px; color: #475569; line-height: 1.6;">
|
||||
{% 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>
|
||||
|
||||
{% if custom_message %}
|
||||
<!-- Custom Message -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 20px 0;">
|
||||
<tr>
|
||||
<td style="padding: 15px; background-color: #eef6fb; border-left: 4px solid #005696; border-radius: 5px;">
|
||||
<p style="margin: 0 0 10px 0; font-size: 14px; font-weight: 600; color: #005696;">
|
||||
{% 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>
|
||||
<div style="padding: 15px; background-color: #eef6fb; border-radius: 6px; margin-bottom: 20px;">
|
||||
<p style="margin: 0; font-size: 14px; color: #005696; line-height: 1.6;">
|
||||
<strong>{% trans "Note from PX Team:" %}</strong> {{ custom_message }}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Complaint Details Card -->
|
||||
<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;">
|
||||
<tr>
|
||||
<td style="padding: 20px;">
|
||||
<h3 style="margin: 0 0 15px 0; font-size: 18px; font-weight: 600; color: #005696; text-align: center;">
|
||||
{% trans "Complaint Details" %}
|
||||
</h3>
|
||||
<!-- Complaint Details -->
|
||||
<div style="background-color: #f8fafc; border-radius: 6px; padding: 20px; margin-bottom: 25px;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 6px 0; font-size: 14px; color: #64748b;">
|
||||
<strong style="color: #005696;">{% trans "Reference:" %}</strong> #{{ complaint_id }}
|
||||
</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 -->
|
||||
<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 -->
|
||||
<!-- CTA Button -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 15px; background-color: #eef6fb; border-left: 4px solid #005696; border-radius: 8px;">
|
||||
<p style="margin: 0 0 10px 0; font-size: 14px; font-weight: 600; color: #005696;">
|
||||
{% trans "Important Information:" %}
|
||||
</p>
|
||||
<ul style="margin: 0; padding-left: 20px; font-size: 14px; color: #1e293b; line-height: 1.8;">
|
||||
<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 align="center">
|
||||
<a href="{{ explanation_url }}"
|
||||
style="display: inline-block; padding: 12px 32px; font-size: 16px; font-weight: 600; color: #ffffff; text-decoration: none; border-radius: 6px; background-color: #005696;">
|
||||
{% trans "Submit Your Explanation" %}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endblock %}
|
||||
|
||||
{% block cta_url %}{{ explanation_url }}{% endblock %}
|
||||
{% block cta_text %}{% trans "Submit Your Explanation" %}{% endblock %}
|
||||
|
||||
{% block info_title %}{% trans "Need Assistance?" %}{% endblock %}
|
||||
{% 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
|
||||
<p style="margin: 25px 0 0; font-size: 13px; color: #94a3b8; line-height: 1.5;">
|
||||
{% trans "If you have any questions, please contact the PX team." %}<br>
|
||||
{% trans "This is an automated email. Please do not reply." %}
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
@ -1,154 +1,56 @@
|
||||
{% extends 'emails/base_email_template.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "New Observation Notification - Al Hammadi Hospital" %}{% 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 title %}{% trans "New Observation Submitted - Al Hammadi Hospital" %}{% endblock %}
|
||||
{% block preheader %}{% trans "A new observation requires your review and triage" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<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>
|
||||
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">{% trans "New Observation Submitted" %}</h1>
|
||||
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 25px 0;">
|
||||
<tr>
|
||||
<td style="padding: 20px; background-color: #f8fafc; border-left: 4px solid #005696; border-radius: 8px;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 8px 0;">
|
||||
<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>
|
||||
<p style="margin: 0 0 15px; font-size: 16px; color: #1e293b; line-height: 1.6;">
|
||||
{% trans "Dear" %} <strong>{{ recipient_name|default:'Colleague' }}</strong>,
|
||||
</p>
|
||||
<p style="margin: 0 0 20px; font-size: 16px; color: #475569; line-height: 1.6;">
|
||||
{% trans "A new observation has been submitted and requires your review. Please review the details below." %}
|
||||
</p>
|
||||
|
||||
{% if observation.description %}
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 25px 0;">
|
||||
<tr>
|
||||
<td style="padding: 20px; background-color: #f8fafc; border-radius: 8px;">
|
||||
<p style="margin: 0 0 10px 0; font-size: 14px; font-weight: 600; color: #005696;">
|
||||
{% trans "Description" %}
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 14px; color: #1e293b; line-height: 1.6; white-space: pre-wrap;">
|
||||
{{ observation.description|truncatechars:1000 }}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endif %}
|
||||
<!-- Observation Details -->
|
||||
<div style="background-color: #f8fafc; border-radius: 6px; padding: 20px; margin-bottom: 20px;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 6px 0; font-size: 14px; color: #64748b;">
|
||||
<strong style="color: #005696;">{% trans "Tracking Code:" %}</strong> {{ observation.tracking_code }}
|
||||
</td>
|
||||
</tr>
|
||||
{% if observation.title %}
|
||||
<tr>
|
||||
<td style="padding: 6px 0; font-size: 14px; color: #64748b;">
|
||||
<strong style="color: #005696;">{% trans "Title:" %}</strong> {{ observation.title }}
|
||||
</td>
|
||||
</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;">
|
||||
<tr>
|
||||
<td style="padding: 15px 20px; background-color: #eef6fb; border-radius: 8px;">
|
||||
<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
|
||||
<p style="margin: 0; font-size: 13px; color: #94a3b8; line-height: 1.5;">
|
||||
{% trans "This is an automated notification. Please log in to PX360 for full details." %}
|
||||
</p>
|
||||
{% 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 %}
|
||||
|
||||
{% 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 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 %}
|
||||
<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%">
|
||||
<tr>
|
||||
<td style="padding-bottom: 20px;">
|
||||
<p style="margin: 0; font-size: 16px; color: #1e293b; line-height: 1.6;">
|
||||
{% trans "Dear" %} <strong>{{ patient_name|default:_("Valued Patient") }}</strong>,
|
||||
</p>
|
||||
<p style="margin: 15px 0 0 0; font-size: 16px; color: #64748b; 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: 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 align="center">
|
||||
<a href="{{ survey_url }}"
|
||||
style="display: inline-block; padding: 12px 32px; font-size: 16px; font-weight: 600; color: #ffffff; text-decoration: none; border-radius: 6px; background-color: #005696;">
|
||||
{% trans "Take Survey" %}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-top: 25px;">
|
||||
<tr>
|
||||
<td>
|
||||
<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" %}
|
||||
<p style="margin: 25px 0 0; font-size: 13px; color: #94a3b8; line-height: 1.5;">
|
||||
{% trans "Thank you for your time and feedback." %}
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
@ -353,6 +353,11 @@
|
||||
<span class="sidebar-text whitespace-nowrap">{% trans "Leaderboard" %}</span>
|
||||
</a>
|
||||
{% 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' %}"
|
||||
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>
|
||||
|
||||
@ -55,7 +55,7 @@
|
||||
|
||||
/* Background Pattern */
|
||||
.public-bg {
|
||||
background: linear-gradient(135deg, #005696 0%, #007bbd 50%, #00a8e8 100%);
|
||||
background: linear-gradient(135deg, #007bbd 0%, #005696 100%);
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
@ -63,15 +63,28 @@
|
||||
|
||||
.public-bg::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 50%, rgba(255,255,255,0.1) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 80%, rgba(255,255,255,0.1) 0%, transparent 50%);
|
||||
position: fixed;
|
||||
top: -100px;
|
||||
right: -100px;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-radius: 9999px;
|
||||
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 */
|
||||
@ -206,16 +219,16 @@
|
||||
</main>
|
||||
|
||||
<!-- 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">
|
||||
<p class="text-slate text-sm">
|
||||
© {% now "Y" %} <span class="font-semibold text-navy">Al Hammadi Hospital</span> - PX360. {% trans "All rights reserved." %}
|
||||
<p class="text-white/70 text-sm">
|
||||
© {% now "Y" %} <span class="font-semibold text-white">Al Hammadi Hospital</span> - PX360. {% trans "All rights reserved." %}
|
||||
</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" %}
|
||||
</p>
|
||||
<p class="text-slate text-xs mt-1">
|
||||
Powered by <a href="https://tenhal.sa" target="_blank" class="text-navy hover:underline font-medium">tenhal.sa</a>
|
||||
<p class="text-white/50 text-xs mt-1">
|
||||
Powered by <a href="https://tenhal.sa" target="_blank" class="text-white hover:text-white/80 hover:underline font-medium">tenhal.sa</a>
|
||||
</p>
|
||||
</div>
|
||||
</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">
|
||||
<i data-lucide="plus-circle" class="w-4 h-4"></i> {% trans "New Observation" %}
|
||||
</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" %}
|
||||
</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">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Public Form" %}
|
||||
</a>
|
||||
|
||||
@ -42,7 +42,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% 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">
|
||||
<!-- Main Card -->
|
||||
<div class="glass-card rounded-3xl shadow-2xl p-8 md:p-10 animate-fade-in">
|
||||
|
||||
@ -55,7 +55,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% 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">
|
||||
<!-- Success Icon -->
|
||||
<div class="text-center mb-8">
|
||||
|
||||
@ -9,11 +9,6 @@ header.glass-card {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.public-bg {
|
||||
background: linear-gradient(to bottom right, #005696, #007bbd, #eef6fb) !important;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.lang-switcher {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
{% extends 'layouts/base.html' %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load national_id_tags %}
|
||||
|
||||
{% 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>
|
||||
{% 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>
|
||||
<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>
|
||||
{% endif %}
|
||||
{% if patient.phone %}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load national_id_tags %}
|
||||
|
||||
{% 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 "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 "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 "Actions" %}</th>
|
||||
</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>
|
||||
</td>
|
||||
<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 class="px-4 py-4">
|
||||
{% if patient.phone %}
|
||||
@ -306,12 +318,6 @@
|
||||
<td class="px-4 py-4">
|
||||
<span class="text-sm text-slate">{{ patient.nationality|default:"-" }}</span>
|
||||
</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">
|
||||
<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 }}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
{% extends 'layouts/base.html' %}
|
||||
{% load i18n %}
|
||||
{% load national_id_tags %}
|
||||
|
||||
{% block title %}{% trans "Visit Journey" %} - {{ visit.admission_id }} - {{ patient.get_full_name }}{% endblock %}
|
||||
|
||||
@ -166,8 +167,8 @@
|
||||
{% 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>
|
||||
{% endif %}
|
||||
{% if event.type %}
|
||||
<span class="text-xs font-semibold text-navy">{{ event.type }}</span>
|
||||
{% if event.event_type %}
|
||||
<span class="text-xs font-semibold text-navy">{{ event.event_type }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if event.parsed_date or event.bill_date %}
|
||||
@ -241,7 +242,7 @@
|
||||
{% if patient.national_id %}
|
||||
<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>
|
||||
<span class="font-mono">{{ patient.national_id }}</span>
|
||||
<span class="font-mono">{{ patient.national_id|mask_id }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</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>
|
||||
<p class="text-slate mt-2 text-sm">{% trans "Import doctor ratings from HIS CSV export" %}</p>
|
||||
</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' %}"
|
||||
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>
|
||||
|
||||
@ -73,11 +73,18 @@
|
||||
<p class="text-white/80 text-sm">{% trans "Track doctor rating import jobs" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<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 class="flex items-center gap-3">
|
||||
<a href="{% url 'physicians:doctor_rating_fetch' %}"
|
||||
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">
|
||||
<i data-lucide="cloud-download" class="w-5 h-5"></i>
|
||||
{% 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>
|
||||
|
||||
|
||||
@ -200,7 +200,7 @@
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<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>
|
||||
<div class="stat-card" style="background: linear-gradient(135deg, #10b981, #34d399);">
|
||||
@ -215,6 +215,162 @@
|
||||
</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 -->
|
||||
<div class="info-card animate-in">
|
||||
<div class="card-header">
|
||||
@ -359,5 +515,17 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user