This commit is contained in:
ismail 2026-04-09 13:46:34 +03:00
parent 177a7e0f5f
commit bcb9c86541
66 changed files with 4698 additions and 3113 deletions

View File

@ -10,6 +10,7 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/6.0/ref/settings/ https://docs.djangoproject.com/en/6.0/ref/settings/
""" """
import os
from pathlib import Path from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
@ -17,10 +18,12 @@ BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/6.0/howto/deployment-checklist/
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-b!)avw-ibl#m*p-_vw%k4#)*b8a*-(7k4-#6eb8un@=-mksed(' SECRET_KEY = "django-insecure-b!)avw-ibl#m*p-_vw%k4#)*b8a*-(7k4-#6eb8un@=-mksed("
CIPHER_KEY = os.environ.get("CIPHER_KEY", SECRET_KEY)
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = True
@ -31,63 +34,62 @@ ALLOWED_HOSTS = []
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
'django.contrib.admin', "django.contrib.admin",
'django.contrib.auth', "django.contrib.auth",
'django.contrib.contenttypes', "django.contrib.contenttypes",
'django.contrib.sessions', "django.contrib.sessions",
'django.contrib.messages', "django.contrib.messages",
'django.contrib.staticfiles', "django.contrib.staticfiles",
# Apps # Apps
'apps.core', "apps.core",
'apps.accounts', "apps.accounts",
'apps.dashboard', "apps.dashboard",
'apps.social', "apps.social",
'django_celery_beat', "django_celery_beat",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', "django.middleware.security.SecurityMiddleware",
'django.contrib.sessions.middleware.SessionMiddleware', "django.contrib.sessions.middleware.SessionMiddleware",
'django.middleware.common.CommonMiddleware', "django.middleware.common.CommonMiddleware",
'django.middleware.csrf.CsrfViewMiddleware', "django.middleware.csrf.CsrfViewMiddleware",
'django.contrib.auth.middleware.AuthenticationMiddleware', "django.contrib.auth.middleware.AuthenticationMiddleware",
'django.contrib.messages.middleware.MessageMiddleware', "django.contrib.messages.middleware.MessageMiddleware",
'django.middleware.clickjacking.XFrameOptionsMiddleware', "django.middleware.clickjacking.XFrameOptionsMiddleware",
# PX Source User Access Control # PX Source User Access Control
'apps.px_sources.middleware.SourceUserRestrictionMiddleware', "apps.px_sources.middleware.SourceUserRestrictionMiddleware",
'apps.px_sources.middleware.SourceUserSessionMiddleware', "apps.px_sources.middleware.SourceUserSessionMiddleware",
] ]
ROOT_URLCONF = 'PX360.urls' ROOT_URLCONF = "PX360.urls"
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', "BACKEND": "django.template.backends.django.DjangoTemplates",
'DIRS': [BASE_DIR / 'templates'] "DIRS": [BASE_DIR / "templates"],
, "APP_DIRS": True,
'APP_DIRS': True, "OPTIONS": {
'OPTIONS': { "context_processors": [
'context_processors': [ "django.template.context_processors.request",
'django.template.context_processors.request', "django.contrib.auth.context_processors.auth",
'django.contrib.auth.context_processors.auth', "django.contrib.messages.context_processors.messages",
'django.contrib.messages.context_processors.messages', "apps.core.context_processors.hospital_context",
'apps.core.context_processors.hospital_context', "apps.core.context_processors.sidebar_counts",
'apps.core.context_processors.sidebar_counts',
], ],
}, },
}, },
] ]
WSGI_APPLICATION = 'PX360.wsgi.application' WSGI_APPLICATION = "PX360.wsgi.application"
# Database # Database
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases # https://docs.djangoproject.com/en/6.0/ref/settings/#databases
DATABASES = { DATABASES = {
'default': { "default": {
'ENGINE': 'django.db.backends.sqlite3', "ENGINE": "django.db.backends.sqlite3",
'NAME': BASE_DIR / 'db.sqlite3', "NAME": BASE_DIR / "db.sqlite3",
} }
} }
@ -97,16 +99,16 @@ DATABASES = {
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{ {
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
}, },
] ]
@ -114,9 +116,9 @@ AUTH_PASSWORD_VALIDATORS = [
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/6.0/topics/i18n/ # https://docs.djangoproject.com/en/6.0/topics/i18n/
LANGUAGE_CODE = 'en-us' LANGUAGE_CODE = "en-us"
TIME_ZONE = 'UTC' TIME_ZONE = "UTC"
USE_I18N = True USE_I18N = True
@ -126,35 +128,34 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/6.0/howto/static-files/ # https://docs.djangoproject.com/en/6.0/howto/static-files/
STATIC_URL = 'static/' STATIC_URL = "static/"
# Celery Configuration # Celery Configuration
CELERY_BROKER_URL = 'redis://localhost:6379/0' CELERY_BROKER_URL = "redis://localhost:6379/0"
CELERY_RESULT_BACKEND = 'redis://localhost:6379/0' CELERY_RESULT_BACKEND = "redis://localhost:6379/0"
CELERY_ACCEPT_CONTENT = ['json'] CELERY_ACCEPT_CONTENT = ["json"]
CELERY_TASK_SERIALIZER = 'json' CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_SERIALIZER = 'json' CELERY_RESULT_SERIALIZER = "json"
CELERY_TIMEZONE = TIME_ZONE CELERY_TIMEZONE = TIME_ZONE
CELERY_ENABLE_UTC = True CELERY_ENABLE_UTC = True
# Django Celery Beat Scheduler # Django Celery Beat Scheduler
CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler' CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
LINKEDIN_CLIENT_SECRET ='WPL_AP1.Ek4DeQDXuv4INg1K.mGo4CQ==' LINKEDIN_CLIENT_SECRET = "WPL_AP1.Ek4DeQDXuv4INg1K.mGo4CQ=="
LINKEDIN_REDIRECT_URI = 'http://127.0.0.1:8000/social/callback/LI/' LINKEDIN_REDIRECT_URI = "http://127.0.0.1:8000/social/callback/LI/"
LINKEDIN_WEBHOOK_VERIFY_TOKEN = "your_random_secret_string_123" LINKEDIN_WEBHOOK_VERIFY_TOKEN = "your_random_secret_string_123"
# YOUTUBE API CREDENTIALS # YOUTUBE API CREDENTIALS
# Ensure this matches your Google Cloud Console settings # Ensure this matches your Google Cloud Console settings
YOUTUBE_CLIENT_SECRETS_FILE = BASE_DIR / 'secrets' / 'yt_client_secrets.json' YOUTUBE_CLIENT_SECRETS_FILE = BASE_DIR / "secrets" / "yt_client_secrets.json"
YOUTUBE_REDIRECT_URI = 'http://127.0.0.1:8000/social/callback/YT/' YOUTUBE_REDIRECT_URI = "http://127.0.0.1:8000/social/callback/YT/"
# Google REVIEWS Configuration # Google REVIEWS Configuration
# Ensure you have your client_secrets.json file at this location # Ensure you have your client_secrets.json file at this location
GMB_CLIENT_SECRETS_FILE = BASE_DIR / 'secrets' / 'gmb_client_secrets.json' GMB_CLIENT_SECRETS_FILE = BASE_DIR / "secrets" / "gmb_client_secrets.json"
GMB_REDIRECT_URI = 'http://127.0.0.1:8000/social/callback/GO/' GMB_REDIRECT_URI = "http://127.0.0.1:8000/social/callback/GO/"
# Data upload settings # Data upload settings
@ -162,11 +163,10 @@ GMB_REDIRECT_URI = 'http://127.0.0.1:8000/social/callback/GO/'
DATA_UPLOAD_MAX_NUMBER_FIELDS = 20000 DATA_UPLOAD_MAX_NUMBER_FIELDS = 20000
# X API Configuration # X API Configuration
X_CLIENT_ID = 'your_client_id' X_CLIENT_ID = "your_client_id"
X_CLIENT_SECRET = 'your_client_secret' X_CLIENT_SECRET = "your_client_secret"
X_REDIRECT_URI = 'http://127.0.0.1:8000/social/callback/X/' X_REDIRECT_URI = "http://127.0.0.1:8000/social/callback/X/"
# TIER CONFIGURATION # TIER CONFIGURATION
# Set to True if you have Enterprise Access # Set to True if you have Enterprise Access
# Set to False for Free/Basic/Pro # Set to False for Free/Basic/Pro
@ -174,16 +174,15 @@ X_USE_ENTERPRISE = False
# --- TIKTOK CONFIG --- # --- TIKTOK CONFIG ---
TIKTOK_CLIENT_KEY = 'your_client_key' TIKTOK_CLIENT_KEY = "your_client_key"
TIKTOK_CLIENT_SECRET = 'your_client_secret' TIKTOK_CLIENT_SECRET = "your_client_secret"
TIKTOK_REDIRECT_URI = 'http://127.0.0.1:8000/social/callback/TT/' TIKTOK_REDIRECT_URI = "http://127.0.0.1:8000/social/callback/TT/"
# --- META API CONFIG --- # --- META API CONFIG ---
META_APP_ID = '1229882089053768' META_APP_ID = "1229882089053768"
META_APP_SECRET = 'b80750bd12ab7f1c21d7d0ca891ba5ab' META_APP_SECRET = "b80750bd12ab7f1c21d7d0ca891ba5ab"
META_REDIRECT_URI = 'https://micha-nonparabolic-lovie.ngrok-free.dev/social/callback/META/' META_REDIRECT_URI = "https://micha-nonparabolic-lovie.ngrok-free.dev/social/callback/META/"
META_WEBHOOK_VERIFY_TOKEN = 'random_secret_string_khanfaheed123456' META_WEBHOOK_VERIFY_TOKEN = "random_secret_string_khanfaheed123456"
EMAIL_LOGO_URL = 'http://127.0.0.1:8000/static/img/HH_R_H_Logo.png' EMAIL_LOGO_URL = "http://127.0.0.1:8000/static/img/HH_R_H_Logo.png"

View File

@ -281,6 +281,9 @@ def onboarding_step_content(request, step):
# Get content for user's role # Get content for user's role
content_list = get_wizard_content_for_user(user) content_list = get_wizard_content_for_user(user)
if not content_list:
return redirect("/accounts/onboarding/wizard/checklist/")
# Get current step content # Get current step content
try: try:
current_content = content_list[step - 1] current_content = content_list[step - 1]
@ -288,6 +291,12 @@ def onboarding_step_content(request, step):
# Step doesn't exist, go to checklist # Step doesn't exist, go to checklist
return redirect("/accounts/onboarding/wizard/checklist/") return redirect("/accounts/onboarding/wizard/checklist/")
if request.method == "POST":
OnboardingService.save_wizard_step(user, step)
if step < len(content_list):
return redirect("/accounts/onboarding/wizard/step/{}/".format(step + 1))
return redirect("/accounts/onboarding/wizard/checklist/")
# Get completed steps # Get completed steps
completed_steps = user.wizard_completed_steps or [] completed_steps = user.wizard_completed_steps or []
@ -624,28 +633,30 @@ def bulk_invite_users(request):
if not csv_file.name.endswith(".csv"): if not csv_file.name.endswith(".csv"):
messages.error(request, "Please upload a valid CSV file.") messages.error(request, "Please upload a valid CSV file.")
return redirect("accounts:bulk-invite-users") return redirect("accounts:bulk-invite-users")
try: try:
decoded_file = csv_file.read().decode("utf-8") decoded_file = csv_file.read().decode("utf-8")
io_string = io.StringIO(decoded_file) io_string = io.StringIO(decoded_file)
reader = csv.DictReader(io_string) reader = csv.DictReader(io_string)
required_fields = ["email", "first_name", "last_name", "role"] required_fields = ["email", "first_name", "last_name", "role"]
if reader.fieldnames: if reader.fieldnames:
missing_fields = [f for f in required_fields if f not in reader.fieldnames] missing_fields = [f for f in required_fields if f not in reader.fieldnames]
if missing_fields: if missing_fields:
messages.error(request, f"Missing required columns in CSV: {', '.join(missing_fields)}") messages.error(request, f"Missing required columns in CSV: {', '.join(missing_fields)}")
return redirect("accounts:bulk-invite-users") return redirect("accounts:bulk-invite-users")
for row in reader: for row in reader:
rows_to_process.append({ rows_to_process.append(
"email": row.get("email", "").strip(), {
"first_name": row.get("first_name", "").strip(), "email": row.get("email", "").strip(),
"last_name": row.get("last_name", "").strip(), "first_name": row.get("first_name", "").strip(),
"role": row.get("role", "").strip(), "last_name": row.get("last_name", "").strip(),
"hospital_id": row.get("hospital_id", "").strip(), "role": row.get("role", "").strip(),
"department_id": row.get("department_id", "").strip() "hospital_id": row.get("hospital_id", "").strip(),
}) "department_id": row.get("department_id", "").strip(),
}
)
except Exception as e: except Exception as e:
messages.error(request, f"Error processing CSV file: {str(e)}") messages.error(request, f"Error processing CSV file: {str(e)}")
@ -653,14 +664,14 @@ def bulk_invite_users(request):
if emails_text: if emails_text:
role_id = request.POST.get("role_id") role_id = request.POST.get("role_id")
hospital_id = request.POST.get("hospital_id") hospital_id = request.POST.get("hospital_id")
if not role_id: if not role_id:
messages.error(request, "Please select a role for manual email entries.") messages.error(request, "Please select a role for manual email entries.")
else: else:
try: try:
role_obj = Role.objects.get(id=role_id) role_obj = Role.objects.get(id=role_id)
role_name = role_obj.name role_name = role_obj.name
for email in emails_text.splitlines(): for email in emails_text.splitlines():
email = email.strip() email = email.strip()
if email and "@" in email: if email and "@" in email:
@ -669,15 +680,17 @@ def bulk_invite_users(request):
parts = name_part.split() parts = name_part.split()
f_name = parts[0] if parts else "Staff" f_name = parts[0] if parts else "Staff"
l_name = " ".join(parts[1:]) if len(parts) > 1 else "Member" l_name = " ".join(parts[1:]) if len(parts) > 1 else "Member"
rows_to_process.append({ rows_to_process.append(
"email": email, {
"first_name": f_name, "email": email,
"last_name": l_name, "first_name": f_name,
"role": role_name, "last_name": l_name,
"hospital_id": hospital_id, "role": role_name,
"department_id": "" "hospital_id": hospital_id,
}) "department_id": "",
}
)
except Exception as e: except Exception as e:
messages.error(request, f"Error processing manual entries: {str(e)}") messages.error(request, f"Error processing manual entries: {str(e)}")
@ -685,7 +698,7 @@ def bulk_invite_users(request):
for row in rows_to_process: for row in rows_to_process:
results["total"] += 1 results["total"] += 1
email = row.get("email", "").strip() email = row.get("email", "").strip()
try: try:
first_name = row.get("first_name", "").strip() first_name = row.get("first_name", "").strip()
last_name = row.get("last_name", "").strip() last_name = row.get("last_name", "").strip()
@ -775,7 +788,6 @@ def bulk_invite_users(request):
if results["errors"]: if results["errors"]:
messages.warning(request, f"Failed to invite {len(results['errors'])} users. See details below.") messages.warning(request, f"Failed to invite {len(results['errors'])} users. See details below.")
# Get data for template # Get data for template
roles = Role.objects.all() roles = Role.objects.all()
hospitals = Hospital.objects.filter(status="active").order_by("name") hospitals = Hospital.objects.filter(status="active").order_by("name")
@ -1110,7 +1122,9 @@ def provisional_user_list(request):
# Filter by hospital based on user role # Filter by hospital based on user role
# Check PX Admin first to avoid logic issues when user has multiple roles # Check PX Admin first to avoid logic issues when user has multiple roles
if request.user.is_px_admin() and request.tenant_hospital: if request.user.is_px_admin() and request.tenant_hospital:
provisional_users = provisional_users.filter(hospital=request.tenant_hospital) from django.db.models import Q
provisional_users = provisional_users.filter(Q(hospital=request.tenant_hospital) | Q(hospital__isnull=True))
elif request.user.is_hospital_admin() and request.user.hospital: elif request.user.is_hospital_admin() and request.user.hospital:
provisional_users = provisional_users.filter(hospital=request.user.hospital) provisional_users = provisional_users.filter(hospital=request.user.hospital)

View File

@ -110,7 +110,7 @@ def generate_action_recommendations_task(self, user_id=None, hospital_id=None, d
def precompute_dashboard_cache_task(self): def precompute_dashboard_cache_task(self):
""" """
Async task: Pre-compute all cacheable analytics data for all active hospitals. Async task: Pre-compute all cacheable analytics data for all active hospitals.
Run every 5 minutes so the dashboard is always fast. Run daily at 3 AM. Users can trigger on-demand refresh via dashboard button.
""" """
from apps.analytics.services.ai_analytics import ( from apps.analytics.services.ai_analytics import (
ExecutiveSummaryGenerator, ExecutiveSummaryGenerator,

View File

@ -504,6 +504,35 @@ def refresh_ai_analytics(request):
) )
@block_source_user
@login_required
def refresh_dashboard_cache(request):
"""
API endpoint: Trigger dashboard cache refresh on demand.
POST to trigger refresh, returns immediately with task status.
"""
if request.method != "POST":
return JsonResponse({"error": "POST method required"}, status=405)
from .tasks import precompute_dashboard_cache_task
user = request.user
# Trigger async cache refresh
task = precompute_dashboard_cache_task.delay()
# Clear user's dashboard cache so next load gets fresh data
cache.delete(f"analytics_dashboard_{user.id}_all")
if hasattr(request, "tenant_hospital") and request.tenant_hospital:
cache.delete(f"analytics_dashboard_{user.id}_{request.tenant_hospital.id}")
return JsonResponse({
"status": "triggered",
"message": "Dashboard cache refresh queued. Please reload the page in a few seconds.",
"task_id": str(task.id),
})
@block_source_user @block_source_user
@login_required @login_required
def kpi_list(request): def kpi_list(request):

View File

@ -16,6 +16,7 @@ urlpatterns = [
# AI Analytics API # AI Analytics API
path('api/ai-analytics/refresh/', ui_views.refresh_ai_analytics, name='refresh_ai_analytics'), path('api/ai-analytics/refresh/', ui_views.refresh_ai_analytics, name='refresh_ai_analytics'),
path('api/dashboard/refresh-cache/', ui_views.refresh_dashboard_cache, name='refresh_dashboard_cache'),
path('api/ask-data/query/', ask_views.ask_data_query, name='ask_data_query'), path('api/ask-data/query/', ask_views.ask_data_query, name='ask_data_query'),
# KPI Reports # KPI Reports

View File

@ -556,42 +556,12 @@ class ComplaintForm(HospitalFieldMixin, forms.ModelForm):
self.fields["main_section"].queryset = MainSection.objects.none() self.fields["main_section"].queryset = MainSection.objects.none()
self.fields["subsection"].queryset = SubSection.objects.none() self.fields["subsection"].queryset = SubSection.objects.none()
# Load all locations (no filtering needed) # Load locations: Inpatient, Outpatient Clinics, Emergency, Others
self.fields["location"].queryset = Location.objects.all().order_by("name_en") self.fields["location"].queryset = Location.objects.filter(id__in=[48, 49, 82, 110]).order_by("name_en")
# Load active PX sources for optional selection # Load active PX sources for optional selection
self.fields["source"].queryset = PXSource.objects.filter(is_active=True).order_by("name_en") self.fields["source"].queryset = PXSource.objects.filter(is_active=True).order_by("name_en")
# Check both initial data and POST data for location to load sections
location_id = None
if "location" in self.initial:
location_id = self.initial["location"]
elif "location" in self.data:
location_id = self.data["location"]
if location_id:
# Filter sections based on selected location
from apps.organizations.models import SubSection
available_sections = (
SubSection.objects.filter(location_id=location_id).values_list("main_section_id", flat=True).distinct()
)
self.fields["main_section"].queryset = MainSection.objects.filter(id__in=available_sections).order_by(
"name_en"
)
# Load subsections if section is selected
section_id = None
if "main_section" in self.initial:
section_id = self.initial["main_section"]
elif "main_section" in self.data:
section_id = self.data["main_section"]
if section_id:
self.fields["subsection"].queryset = SubSection.objects.filter(
location_id=location_id, main_section_id=section_id
).order_by("name_en")
# Hospital field is configured by HospitalFieldMixin # Hospital field is configured by HospitalFieldMixin
# Now filter departments and staff based on hospital # Now filter departments and staff based on hospital
hospital_id = None hospital_id = None

View File

@ -16,6 +16,7 @@ from django.db import models
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from apps.core.encryption import EncryptedCharField, compute_national_id_hash, mask_national_id
from apps.core.models import PriorityChoices, SeverityChoices, TenantModel, TimeStampedModel, UUIDModel from apps.core.models import PriorityChoices, SeverityChoices, TenantModel, TimeStampedModel, UUIDModel
@ -213,9 +214,10 @@ class Complaint(UUIDModel, TimeStampedModel):
max_length=200, blank=True, verbose_name="Patient Name", help_text="Name of the patient involved" max_length=200, blank=True, verbose_name="Patient Name", help_text="Name of the patient involved"
) )
national_id = models.CharField( national_id = EncryptedCharField(
max_length=20, blank=True, verbose_name="National ID/Iqama No.", help_text="Saudi National ID or Iqama number" max_length=20, blank=True, verbose_name="National ID/Iqama No.", help_text="Saudi National ID or Iqama number"
) )
national_id_hash = models.CharField(max_length=64, blank=True, db_index=True)
incident_date = models.DateField( incident_date = models.DateField(
null=True, blank=True, verbose_name="Incident Date", help_text="Date when the incident occurred" null=True, blank=True, verbose_name="Incident Date", help_text="Date when the incident occurred"
@ -512,6 +514,9 @@ class Complaint(UUIDModel, TimeStampedModel):
return reverse("complaints:complaint_detail", kwargs={"pk": self.pk}) return reverse("complaints:complaint_detail", kwargs={"pk": self.pk})
def get_masked_national_id(self):
return mask_national_id(self.national_id)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""Calculate SLA due date on creation, generate reference number, and sync complaint_type from metadata""" """Calculate SLA due date on creation, generate reference number, and sync complaint_type from metadata"""
# Track status change for signals # Track status change for signals
@ -543,6 +548,11 @@ class Complaint(UUIDModel, TimeStampedModel):
if self.complaint_type == "complaint" and ai_complaint_type != "complaint": if self.complaint_type == "complaint" and ai_complaint_type != "complaint":
self.complaint_type = ai_complaint_type self.complaint_type = ai_complaint_type
if self.national_id:
self.national_id_hash = compute_national_id_hash(self.national_id)
else:
self.national_id_hash = ""
super().save(*args, **kwargs) super().save(*args, **kwargs)
def calculate_sla_due_date(self): def calculate_sla_due_date(self):
@ -658,6 +668,45 @@ class Complaint(UUIDModel, TimeStampedModel):
ComplaintStatus.CONTACTED_NO_RESPONSE, ComplaintStatus.CONTACTED_NO_RESPONSE,
] ]
PUBLIC_STATUS_MAP = {
ComplaintStatus.OPEN: {"label": _("Received"), "slug": "received", "progress": 15, "css": "amber"},
ComplaintStatus.IN_PROGRESS: {"label": _("In Progress"), "slug": "in_progress", "progress": 50, "css": "blue"},
ComplaintStatus.PARTIALLY_RESOLVED: {
"label": _("In Progress"),
"slug": "in_progress",
"progress": 75,
"css": "blue",
},
ComplaintStatus.RESOLVED: {"label": _("Resolved"), "slug": "resolved", "progress": 100, "css": "emerald"},
ComplaintStatus.CLOSED: {"label": _("Closed"), "slug": "closed", "progress": 100, "css": "slate"},
ComplaintStatus.CANCELLED: {"label": _("Cancelled"), "slug": "cancelled", "progress": 0, "css": "rose"},
ComplaintStatus.CONTACTED: {"label": _("In Progress"), "slug": "in_progress", "progress": 50, "css": "blue"},
ComplaintStatus.CONTACTED_NO_RESPONSE: {
"label": _("In Progress"),
"slug": "in_progress",
"progress": 50,
"css": "blue",
},
}
@property
def public_status(self):
mapping = self.PUBLIC_STATUS_MAP.get(
self.status,
{
"label": _("Received"),
"slug": "received",
"progress": 15,
"css": "amber",
},
)
return {
"label": str(mapping["label"]),
"slug": mapping["slug"],
"progress": mapping["progress"],
"css": mapping["css"],
}
@property @property
def short_description_en(self): def short_description_en(self):
"""Get AI-generated short description (English) from metadata""" """Get AI-generated short description (English) from metadata"""
@ -2225,8 +2274,7 @@ class OnCallAdmin(UUIDModel, TimeStampedModel):
"accounts.User", "accounts.User",
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="on_call_schedules", related_name="on_call_schedules",
help_text="PX Admin user who is on-call", help_text="User who is on-call (PX Admin, PX Coordinator, or Hospital Admin)",
limit_choices_to={"groups__name": "PX Admin"},
) )
# Optional: date range for this on-call assignment # Optional: date range for this on-call assignment

View File

@ -1977,6 +1977,8 @@ def public_complaint_submit(request):
severity="medium", # Default, AI will update severity="medium", # Default, AI will update
priority="medium", # Default, AI will update priority="medium", # Default, AI will update
status="open", # Start as open status="open", # Start as open
complaint_source_type=ComplaintSourceType.INTERNAL,
source=PXSource.objects.filter(name_en="Public Form").first(),
reference_number=reference_number, reference_number=reference_number,
# Location hierarchy (FK relationships) # Location hierarchy (FK relationships)
location=location, location=location,
@ -2112,15 +2114,39 @@ def public_complaint_track(request):
except Complaint.DoesNotExist: except Complaint.DoesNotExist:
error_message = _("No complaint found with this reference number. Please check and try again.") error_message = _("No complaint found with this reference number. Please check and try again.")
# Get public updates only (exclude internal notes) public_status = None
public_updates = [] public_updates = []
if complaint: if complaint:
public_updates = complaint.updates.filter( public_status = complaint.public_status
update_type__in=["status_change", "resolution", "communication"]
).order_by("-created_at") public_updates = list(
complaint.updates.filter(update_type__in=["status_change", "resolution", "communication"]).order_by(
"-created_at"
)
)
_status_map = {
"open": str(_("Received")),
"in_progress": str(_("In Progress")),
"partially_resolved": str(_("In Progress")),
"contacted": str(_("In Progress")),
"contacted_no_response": str(_("In Progress")),
"resolved": str(_("Resolved")),
"closed": str(_("Closed")),
"cancelled": str(_("Cancelled")),
}
for update in public_updates:
if update.comments:
for internal, public_label in _status_map.items():
update.comments = update.comments.replace(internal, public_label)
if hasattr(update, "old_status") and update.old_status:
update.old_status = _status_map.get(update.old_status, update.old_status)
if hasattr(update, "new_status") and update.new_status:
update.new_status = _status_map.get(update.new_status, update.new_status)
context = { context = {
"complaint": complaint, "complaint": complaint,
"public_status": public_status,
"public_updates": public_updates, "public_updates": public_updates,
"error_message": error_message, "error_message": error_message,
"reference_number": reference_number, "reference_number": reference_number,
@ -2288,7 +2314,10 @@ def api_lookup_patient(request):
lookup_method = None lookup_method = None
if national_id: if national_id:
patient = Patient.objects.filter(national_id=national_id, status="active").first() from apps.core.encryption import compute_national_id_hash
nid_hash = compute_national_id_hash(national_id)
patient = Patient.objects.filter(national_id_hash=nid_hash, status="active").first()
lookup_method = "national_id" lookup_method = "national_id"
if not patient and phone: if not patient and phone:

View File

@ -28,8 +28,8 @@ logger = logging.getLogger(__name__)
def check_px_admin(request): def check_px_admin(request):
"""Check if user is PX Admin, return redirect if not.""" """Check if user is PX Admin, return redirect if not."""
if not request.user.is_px_admin(): if not request.user.is_px_admin():
messages.error(request, _('You do not have permission to access this page.')) messages.error(request, _("You do not have permission to access this page."))
return redirect('dashboard') return redirect("dashboard")
return None return None
@ -41,15 +41,15 @@ def oncall_schedule_list(request):
redirect_response = check_px_admin(request) redirect_response = check_px_admin(request)
if redirect_response: if redirect_response:
return redirect_response return redirect_response
schedules = OnCallAdminSchedule.objects.select_related('hospital').all() schedules = OnCallAdminSchedule.objects.select_related("hospital").all()
context = { context = {
'schedules': schedules, "schedules": schedules,
'title': _('On-Call Admin Schedules'), "title": _("On-Call Admin Schedules"),
} }
return render(request, 'complaints/oncall/schedule_list.html', context) return render(request, "complaints/oncall/schedule_list.html", context)
@login_required @login_required
@ -60,29 +60,29 @@ def oncall_schedule_create(request):
redirect_response = check_px_admin(request) redirect_response = check_px_admin(request)
if redirect_response: if redirect_response:
return redirect_response return redirect_response
hospitals = Hospital.objects.filter(status='active') hospitals = Hospital.objects.filter(status="active")
if request.method == 'POST': if request.method == "POST":
try: try:
# Parse working days from checkboxes # Parse working days from checkboxes
working_days = [] working_days = []
for day in range(7): for day in range(7):
if request.POST.get(f'working_day_{day}'): if request.POST.get(f"working_day_{day}"):
working_days.append(day) working_days.append(day)
if not working_days: if not working_days:
working_days = [0, 1, 2, 3, 4] # Default to Mon-Fri working_days = [0, 1, 2, 3, 4] # Default to Mon-Fri
# Get form data # Get form data
hospital_id = request.POST.get('hospital') hospital_id = request.POST.get("hospital")
hospital = Hospital.objects.get(id=hospital_id) if hospital_id else None hospital = Hospital.objects.get(id=hospital_id) if hospital_id else None
work_start_time = request.POST.get('work_start_time', '08:00') work_start_time = request.POST.get("work_start_time", "08:00")
work_end_time = request.POST.get('work_end_time', '17:00') work_end_time = request.POST.get("work_end_time", "17:00")
timezone_str = request.POST.get('timezone', 'Asia/Riyadh') timezone_str = request.POST.get("timezone", "Asia/Riyadh")
is_active = request.POST.get('is_active') == 'on' is_active = request.POST.get("is_active") == "on"
# Create schedule # Create schedule
schedule = OnCallAdminSchedule.objects.create( schedule = OnCallAdminSchedule.objects.create(
hospital=hospital, hospital=hospital,
@ -90,40 +90,48 @@ def oncall_schedule_create(request):
work_start_time=work_start_time, work_start_time=work_start_time,
work_end_time=work_end_time, work_end_time=work_end_time,
timezone=timezone_str, timezone=timezone_str,
is_active=is_active is_active=is_active,
) )
# Log audit # Log audit
AuditService.log_event( AuditService.log_event(
event_type='oncall_schedule_created', event_type="oncall_schedule_created",
description=f"On-call schedule created: {schedule}", description=f"On-call schedule created: {schedule}",
user=request.user, user=request.user,
content_object=schedule, content_object=schedule,
metadata={ metadata={
'hospital': str(hospital) if hospital else 'system-wide', "hospital": str(hospital) if hospital else "system-wide",
'working_days': working_days, "working_days": working_days,
'work_hours': f"{work_start_time}-{work_end_time}" "work_hours": f"{work_start_time}-{work_end_time}",
} },
) )
messages.success(request, _('On-call schedule created successfully.')) messages.success(request, _("On-call schedule created successfully."))
return redirect('complaints:oncall_schedule_detail', pk=schedule.id) return redirect("complaints:oncall_schedule_detail", pk=schedule.id)
except Exception as e: except Exception as e:
logger.error(f"Error creating on-call schedule: {str(e)}") logger.error(f"Error creating on-call schedule: {str(e)}")
messages.error(request, _('Error creating on-call schedule. Please try again.')) messages.error(request, _("Error creating on-call schedule. Please try again."))
context = { context = {
'hospitals': hospitals, "hospitals": hospitals,
'timezones': [ "timezones": [
'Asia/Riyadh', 'Asia/Dubai', 'Asia/Kuwait', 'Asia/Qatar', "Asia/Riyadh",
'Asia/Bahrain', 'Asia/Muscat', 'Asia/Amman', 'Asia/Beirut', "Asia/Dubai",
'Asia/Cairo', 'Asia/Jerusalem', 'Asia/Baghdad' "Asia/Kuwait",
"Asia/Qatar",
"Asia/Bahrain",
"Asia/Muscat",
"Asia/Amman",
"Asia/Beirut",
"Asia/Cairo",
"Asia/Jerusalem",
"Asia/Baghdad",
], ],
'title': _('Create On-Call Schedule'), "title": _("Create On-Call Schedule"),
} }
return render(request, 'complaints/oncall/schedule_form.html', context) return render(request, "complaints/oncall/schedule_form.html", context)
@login_required @login_required
@ -134,25 +142,22 @@ def oncall_schedule_detail(request, pk):
redirect_response = check_px_admin(request) redirect_response = check_px_admin(request)
if redirect_response: if redirect_response:
return redirect_response return redirect_response
schedule = get_object_or_404( schedule = get_object_or_404(OnCallAdminSchedule.objects.select_related("hospital"), pk=pk)
OnCallAdminSchedule.objects.select_related('hospital'),
pk=pk on_call_admins = schedule.on_call_admins.select_related("admin_user").all()
)
on_call_admins = schedule.on_call_admins.select_related('admin_user').all()
# Check if currently working hours # Check if currently working hours
is_working_hours = schedule.is_working_time() is_working_hours = schedule.is_working_time()
context = { context = {
'schedule': schedule, "schedule": schedule,
'on_call_admins': on_call_admins, "on_call_admins": on_call_admins,
'is_working_hours': is_working_hours, "is_working_hours": is_working_hours,
'title': _('On-Call Schedule Details'), "title": _("On-Call Schedule Details"),
} }
return render(request, 'complaints/oncall/schedule_detail.html', context) return render(request, "complaints/oncall/schedule_detail.html", context)
@login_required @login_required
@ -163,65 +168,73 @@ def oncall_schedule_edit(request, pk):
redirect_response = check_px_admin(request) redirect_response = check_px_admin(request)
if redirect_response: if redirect_response:
return redirect_response return redirect_response
schedule = get_object_or_404(OnCallAdminSchedule, pk=pk) schedule = get_object_or_404(OnCallAdminSchedule, pk=pk)
hospitals = Hospital.objects.filter(status='active') hospitals = Hospital.objects.filter(status="active")
if request.method == 'POST': if request.method == "POST":
try: try:
# Parse working days from checkboxes # Parse working days from checkboxes
working_days = [] working_days = []
for day in range(7): for day in range(7):
if request.POST.get(f'working_day_{day}'): if request.POST.get(f"working_day_{day}"):
working_days.append(day) working_days.append(day)
if not working_days: if not working_days:
working_days = [0, 1, 2, 3, 4] # Default to Mon-Fri working_days = [0, 1, 2, 3, 4] # Default to Mon-Fri
# Get form data # Get form data
hospital_id = request.POST.get('hospital') hospital_id = request.POST.get("hospital")
schedule.hospital = Hospital.objects.get(id=hospital_id) if hospital_id else None schedule.hospital = Hospital.objects.get(id=hospital_id) if hospital_id else None
schedule.working_days = working_days schedule.working_days = working_days
schedule.work_start_time = request.POST.get('work_start_time', '08:00') schedule.work_start_time = request.POST.get("work_start_time", "08:00")
schedule.work_end_time = request.POST.get('work_end_time', '17:00') schedule.work_end_time = request.POST.get("work_end_time", "17:00")
schedule.timezone = request.POST.get('timezone', 'Asia/Riyadh') schedule.timezone = request.POST.get("timezone", "Asia/Riyadh")
schedule.is_active = request.POST.get('is_active') == 'on' schedule.is_active = request.POST.get("is_active") == "on"
schedule.save() schedule.save()
# Log audit # Log audit
AuditService.log_event( AuditService.log_event(
event_type='oncall_schedule_updated', event_type="oncall_schedule_updated",
description=f"On-call schedule updated: {schedule}", description=f"On-call schedule updated: {schedule}",
user=request.user, user=request.user,
content_object=schedule, content_object=schedule,
metadata={ metadata={
'hospital': str(schedule.hospital) if schedule.hospital else 'system-wide', "hospital": str(schedule.hospital) if schedule.hospital else "system-wide",
'working_days': working_days, "working_days": working_days,
'is_active': schedule.is_active "is_active": schedule.is_active,
} },
) )
messages.success(request, _('On-call schedule updated successfully.')) messages.success(request, _("On-call schedule updated successfully."))
return redirect('complaints:oncall_schedule_detail', pk=schedule.id) return redirect("complaints:oncall_schedule_detail", pk=schedule.id)
except Exception as e: except Exception as e:
logger.error(f"Error updating on-call schedule: {str(e)}") logger.error(f"Error updating on-call schedule: {str(e)}")
messages.error(request, _('Error updating on-call schedule. Please try again.')) messages.error(request, _("Error updating on-call schedule. Please try again."))
context = { context = {
'schedule': schedule, "schedule": schedule,
'hospitals': hospitals, "hospitals": hospitals,
'timezones': [ "timezones": [
'Asia/Riyadh', 'Asia/Dubai', 'Asia/Kuwait', 'Asia/Qatar', "Asia/Riyadh",
'Asia/Bahrain', 'Asia/Muscat', 'Asia/Amman', 'Asia/Beirut', "Asia/Dubai",
'Asia/Cairo', 'Asia/Jerusalem', 'Asia/Baghdad' "Asia/Kuwait",
"Asia/Qatar",
"Asia/Bahrain",
"Asia/Muscat",
"Asia/Amman",
"Asia/Beirut",
"Asia/Cairo",
"Asia/Jerusalem",
"Asia/Baghdad",
], ],
'title': _('Edit On-Call Schedule'), "title": _("Edit On-Call Schedule"),
} }
return render(request, 'complaints/oncall/schedule_form.html', context) return render(request, "complaints/oncall/schedule_form.html", context)
@login_required @login_required
@ -233,29 +246,29 @@ def oncall_schedule_delete(request, pk):
redirect_response = check_px_admin(request) redirect_response = check_px_admin(request)
if redirect_response: if redirect_response:
return redirect_response return redirect_response
schedule = get_object_or_404(OnCallAdminSchedule, pk=pk) schedule = get_object_or_404(OnCallAdminSchedule, pk=pk)
try: try:
# Log before deletion # Log before deletion
AuditService.log_event( AuditService.log_event(
event_type='oncall_schedule_deleted', event_type="oncall_schedule_deleted",
description=f"On-call schedule deleted: {schedule}", description=f"On-call schedule deleted: {schedule}",
user=request.user, user=request.user,
metadata={ metadata={
'hospital': str(schedule.hospital) if schedule.hospital else 'system-wide', "hospital": str(schedule.hospital) if schedule.hospital else "system-wide",
'schedule_id': str(pk) "schedule_id": str(pk),
} },
) )
schedule.delete() schedule.delete()
messages.success(request, _('On-call schedule deleted successfully.')) messages.success(request, _("On-call schedule deleted successfully."))
except Exception as e: except Exception as e:
logger.error(f"Error deleting on-call schedule: {str(e)}") logger.error(f"Error deleting on-call schedule: {str(e)}")
messages.error(request, _('Error deleting on-call schedule.')) messages.error(request, _("Error deleting on-call schedule."))
return redirect('complaints:oncall_schedule_list') return redirect("complaints:oncall_schedule_list")
@login_required @login_required
@ -266,70 +279,77 @@ def oncall_admin_add(request, schedule_pk):
redirect_response = check_px_admin(request) redirect_response = check_px_admin(request)
if redirect_response: if redirect_response:
return redirect_response return redirect_response
schedule = get_object_or_404(OnCallAdminSchedule, pk=schedule_pk) schedule = get_object_or_404(OnCallAdminSchedule, pk=schedule_pk)
# Get all PX Admins not already on this schedule # Get all PX Admins not already on this schedule
existing_admin_ids = schedule.on_call_admins.values_list('admin_user_id', flat=True) existing_admin_ids = schedule.on_call_admins.values_list("admin_user_id", flat=True)
available_admins = User.objects.filter( available_admins = (
groups__name='PX Admin', User.objects.filter(
is_active=True Q(groups__name="PX Admin") | Q(groups__name="PX Coordinator") | Q(groups__name="Hospital Admin"),
).exclude(id__in=existing_admin_ids) is_active=True,
)
if request.method == 'POST': .exclude(id__in=existing_admin_ids)
.distinct()
)
if request.method == "POST":
try: try:
admin_user_id = request.POST.get('admin_user') admin_user_id = request.POST.get("admin_user")
if not admin_user_id: if not admin_user_id:
messages.error(request, _('Please select an admin user.')) messages.error(request, _("Please select an admin user."))
return redirect('complaints:oncall_admin_add', schedule_pk=schedule_pk) return redirect("complaints:oncall_admin_add", schedule_pk=schedule_pk)
admin_user = User.objects.get(id=admin_user_id) admin_user = User.objects.get(id=admin_user_id)
# Parse dates # Parse dates
start_date = request.POST.get('start_date') or None start_date = request.POST.get("start_date") or None
end_date = request.POST.get('end_date') or None end_date = request.POST.get("end_date") or None
# Create on-call admin assignment # Create on-call admin assignment
on_call_admin = OnCallAdmin.objects.create( on_call_admin = OnCallAdmin.objects.create(
schedule=schedule, schedule=schedule,
admin_user=admin_user, admin_user=admin_user,
start_date=start_date, start_date=start_date,
end_date=end_date, end_date=end_date,
notification_priority=int(request.POST.get('notification_priority', 1)), notification_priority=int(request.POST.get("notification_priority", 1)),
is_active=request.POST.get('is_active') == 'on', is_active=request.POST.get("is_active") == "on",
notify_email=request.POST.get('notify_email') == 'on', notify_email=request.POST.get("notify_email") == "on",
notify_sms=request.POST.get('notify_sms') == 'on', notify_sms=request.POST.get("notify_sms") == "on",
sms_phone=request.POST.get('sms_phone', '') sms_phone=request.POST.get("sms_phone", ""),
) )
# Log audit # Log audit
AuditService.log_event( AuditService.log_event(
event_type='oncall_admin_added', event_type="oncall_admin_added",
description=f"Admin {admin_user.get_full_name()} added to on-call schedule", description=f"Admin {admin_user.get_full_name()} added to on-call schedule",
user=request.user, user=request.user,
content_object=on_call_admin, content_object=on_call_admin,
metadata={ metadata={
'schedule': str(schedule), "schedule": str(schedule),
'admin_user': str(admin_user), "admin_user": str(admin_user),
'start_date': start_date, "start_date": start_date,
'end_date': end_date "end_date": end_date,
} },
) )
messages.success(request, _('On-call admin added successfully.')) messages.success(request, _("On-call admin added successfully."))
return redirect('complaints:oncall_schedule_detail', pk=schedule_pk) return redirect("complaints:oncall_schedule_detail", pk=schedule_pk)
except Exception as e: except Exception as e:
logger.error(f"Error adding on-call admin: {str(e)}") logger.error(f"Error adding on-call admin: {str(e)}")
messages.error(request, _('Error adding on-call admin. Please try again.')) messages.error(request, _("Error adding on-call admin. Please try again."))
context = { context = {
'schedule': schedule, "schedule": schedule,
'available_admins': available_admins, "available_admins": available_admins,
'title': _('Add On-Call Admin'), "available_px_admins": available_admins.filter(groups__name="PX Admin"),
"available_coordinators": available_admins.filter(groups__name="PX Coordinator"),
"available_hospital_admins": available_admins.filter(groups__name="Hospital Admin"),
"title": _("Add On-Call Admin"),
} }
return render(request, 'complaints/oncall/admin_form.html', context) return render(request, "complaints/oncall/admin_form.html", context)
@login_required @login_required
@ -340,56 +360,53 @@ def oncall_admin_edit(request, pk):
redirect_response = check_px_admin(request) redirect_response = check_px_admin(request)
if redirect_response: if redirect_response:
return redirect_response return redirect_response
on_call_admin = get_object_or_404( on_call_admin = get_object_or_404(OnCallAdmin.objects.select_related("schedule", "admin_user"), pk=pk)
OnCallAdmin.objects.select_related('schedule', 'admin_user'),
pk=pk if request.method == "POST":
)
if request.method == 'POST':
try: try:
# Parse dates # Parse dates
start_date = request.POST.get('start_date') or None start_date = request.POST.get("start_date") or None
end_date = request.POST.get('end_date') or None end_date = request.POST.get("end_date") or None
# Update fields # Update fields
on_call_admin.start_date = start_date on_call_admin.start_date = start_date
on_call_admin.end_date = end_date on_call_admin.end_date = end_date
on_call_admin.notification_priority = int(request.POST.get('notification_priority', 1)) on_call_admin.notification_priority = int(request.POST.get("notification_priority", 1))
on_call_admin.is_active = request.POST.get('is_active') == 'on' on_call_admin.is_active = request.POST.get("is_active") == "on"
on_call_admin.notify_email = request.POST.get('notify_email') == 'on' on_call_admin.notify_email = request.POST.get("notify_email") == "on"
on_call_admin.notify_sms = request.POST.get('notify_sms') == 'on' on_call_admin.notify_sms = request.POST.get("notify_sms") == "on"
on_call_admin.sms_phone = request.POST.get('sms_phone', '') on_call_admin.sms_phone = request.POST.get("sms_phone", "")
on_call_admin.save() on_call_admin.save()
# Log audit # Log audit
AuditService.log_event( AuditService.log_event(
event_type='oncall_admin_updated', event_type="oncall_admin_updated",
description=f"On-call admin updated: {on_call_admin}", description=f"On-call admin updated: {on_call_admin}",
user=request.user, user=request.user,
content_object=on_call_admin, content_object=on_call_admin,
metadata={ metadata={
'schedule': str(on_call_admin.schedule), "schedule": str(on_call_admin.schedule),
'admin_user': str(on_call_admin.admin_user), "admin_user": str(on_call_admin.admin_user),
'is_active': on_call_admin.is_active "is_active": on_call_admin.is_active,
} },
) )
messages.success(request, _('On-call admin updated successfully.')) messages.success(request, _("On-call admin updated successfully."))
return redirect('complaints:oncall_schedule_detail', pk=on_call_admin.schedule.id) return redirect("complaints:oncall_schedule_detail", pk=on_call_admin.schedule.id)
except Exception as e: except Exception as e:
logger.error(f"Error updating on-call admin: {str(e)}") logger.error(f"Error updating on-call admin: {str(e)}")
messages.error(request, _('Error updating on-call admin. Please try again.')) messages.error(request, _("Error updating on-call admin. Please try again."))
context = { context = {
'on_call_admin': on_call_admin, "on_call_admin": on_call_admin,
'schedule': on_call_admin.schedule, "schedule": on_call_admin.schedule,
'title': _('Edit On-Call Admin'), "title": _("Edit On-Call Admin"),
} }
return render(request, 'complaints/oncall/admin_form.html', context) return render(request, "complaints/oncall/admin_form.html", context)
@login_required @login_required
@ -401,34 +418,31 @@ def oncall_admin_delete(request, pk):
redirect_response = check_px_admin(request) redirect_response = check_px_admin(request)
if redirect_response: if redirect_response:
return redirect_response return redirect_response
on_call_admin = get_object_or_404( on_call_admin = get_object_or_404(OnCallAdmin.objects.select_related("schedule", "admin_user"), pk=pk)
OnCallAdmin.objects.select_related('schedule', 'admin_user'),
pk=pk
)
schedule_pk = on_call_admin.schedule.id schedule_pk = on_call_admin.schedule.id
try: try:
# Log before deletion # Log before deletion
AuditService.log_event( AuditService.log_event(
event_type='oncall_admin_removed', event_type="oncall_admin_removed",
description=f"Admin removed from on-call schedule: {on_call_admin}", description=f"Admin removed from on-call schedule: {on_call_admin}",
user=request.user, user=request.user,
metadata={ metadata={
'schedule': str(on_call_admin.schedule), "schedule": str(on_call_admin.schedule),
'admin_user': str(on_call_admin.admin_user), "admin_user": str(on_call_admin.admin_user),
'oncall_admin_id': str(pk) "oncall_admin_id": str(pk),
} },
) )
on_call_admin.delete() on_call_admin.delete()
messages.success(request, _('On-call admin removed successfully.')) messages.success(request, _("On-call admin removed successfully."))
except Exception as e: except Exception as e:
logger.error(f"Error removing on-call admin: {str(e)}") logger.error(f"Error removing on-call admin: {str(e)}")
messages.error(request, _('Error removing on-call admin.')) messages.error(request, _("Error removing on-call admin."))
return redirect('complaints:oncall_schedule_detail', pk=schedule_pk) return redirect("complaints:oncall_schedule_detail", pk=schedule_pk)
@login_required @login_required
@ -439,41 +453,43 @@ def oncall_dashboard(request):
redirect_response = check_px_admin(request) redirect_response = check_px_admin(request)
if redirect_response: if redirect_response:
return redirect_response return redirect_response
# Get all schedules # Get all schedules
schedules = OnCallAdminSchedule.objects.select_related('hospital').all() schedules = OnCallAdminSchedule.objects.select_related("hospital").all()
# Get currently active on-call admins # Get currently active on-call admins
now = timezone.now() now = timezone.now()
today = now.date() today = now.date()
active_on_call_admins = OnCallAdmin.objects.filter( active_on_call_admins = (
is_active=True, OnCallAdmin.objects.filter(is_active=True, schedule__is_active=True)
schedule__is_active=True .select_related("admin_user", "schedule", "schedule__hospital")
).select_related('admin_user', 'schedule', 'schedule__hospital').filter( .filter(
Q(start_date__isnull=True) | Q(start_date__lte=today), Q(start_date__isnull=True) | Q(start_date__lte=today), Q(end_date__isnull=True) | Q(end_date__gte=today)
Q(end_date__isnull=True) | Q(end_date__gte=today) )
) )
# Check each schedule's current status # Check each schedule's current status
schedule_statuses = [] schedule_statuses = []
for schedule in schedules: for schedule in schedules:
is_working = schedule.is_working_time() is_working = schedule.is_working_time()
schedule_oncall = active_on_call_admins.filter(schedule=schedule) schedule_oncall = active_on_call_admins.filter(schedule=schedule)
schedule_statuses.append({ schedule_statuses.append(
'schedule': schedule, {
'is_working_hours': is_working, "schedule": schedule,
'on_call_count': schedule_oncall.count(), "is_working_hours": is_working,
'on_call_admins': schedule_oncall "on_call_count": schedule_oncall.count(),
}) "on_call_admins": schedule_oncall,
}
)
context = { context = {
'schedule_statuses': schedule_statuses, "schedule_statuses": schedule_statuses,
'total_schedules': schedules.count(), "total_schedules": schedules.count(),
'total_active_oncall': active_on_call_admins.count(), "total_active_oncall": active_on_call_admins.count(),
'current_time': now, "current_time": now,
'title': _('On-Call Dashboard'), "title": _("On-Call Dashboard"),
} }
return render(request, 'complaints/oncall/dashboard.html', context) return render(request, "complaints/oncall/dashboard.html", context)

View File

@ -1,14 +1,17 @@
""" """
Configuration URLs Configuration URLs
""" """
from django.urls import path from django.urls import path
from . import config_views from . import config_views
app_name = 'config' app_name = "config"
urlpatterns = [ urlpatterns = [
path('', config_views.config_dashboard, name='dashboard'), path("", config_views.config_dashboard, name="dashboard"),
path('sla/', config_views.sla_config_list, name='sla_config_list'), path("sla/", config_views.sla_config_list, name="sla_config_list"),
path('routing/', config_views.routing_rules_list, name='routing_rules_list'), path("routing/", config_views.routing_rules_list, name="routing_rules_list"),
path('test/',config_views.test, name='test'), path("users/", config_views.hospital_users_list, name="hospital_users_list"),
path("users/<uuid:user_id>/reset-password/", config_views.reset_user_password, name="reset_user_password"),
path("test/", config_views.test, name="test"),
] ]

View File

@ -2,15 +2,25 @@
Configuration Console UI views - System configuration management Configuration Console UI views - System configuration management
""" """
import json
from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.shortcuts import render from django.db.models import Q
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from apps.organizations.models import Hospital from apps.organizations.models import Department, Hospital
from apps.organizations.services import StaffService
from apps.px_action_center.models import PXActionSLAConfig, RoutingRule from apps.px_action_center.models import PXActionSLAConfig, RoutingRule
from apps.complaints.models import OnCallAdminSchedule from apps.complaints.models import OnCallAdminSchedule
from apps.callcenter.models import CallRecord from apps.callcenter.models import CallRecord
from apps.core.decorators import px_admin_required from apps.notifications.services import NotificationService
from apps.core.decorators import px_admin_required, admin_required
from apps.accounts.models import User from apps.accounts.models import User
@ -25,6 +35,7 @@ def config_dashboard(request):
oncall_schedules_count = OnCallAdminSchedule.objects.filter(is_active=True).count() oncall_schedules_count = OnCallAdminSchedule.objects.filter(is_active=True).count()
call_records_count = CallRecord.objects.count() call_records_count = CallRecord.objects.count()
provisional_users_count = User.objects.filter(is_provisional=True).count() provisional_users_count = User.objects.filter(is_provisional=True).count()
active_users_count = User.objects.filter(is_active=True, is_superuser=False, is_provisional=False).count()
context = { context = {
"sla_configs_count": sla_configs_count, "sla_configs_count": sla_configs_count,
@ -33,6 +44,7 @@ def config_dashboard(request):
"oncall_schedules_count": oncall_schedules_count, "oncall_schedules_count": oncall_schedules_count,
"call_records_count": call_records_count, "call_records_count": call_records_count,
"provisional_users_count": provisional_users_count, "provisional_users_count": provisional_users_count,
"active_users_count": active_users_count,
} }
return render(request, "config/dashboard.html", context) return render(request, "config/dashboard.html", context)
@ -116,6 +128,136 @@ def routing_rules_list(request):
return render(request, "config/routing_rules.html", context) return render(request, "config/routing_rules.html", context)
@admin_required
def hospital_users_list(request):
"""Hospital users list view - PX Admin and Hospital Admin"""
queryset = User.objects.select_related("hospital", "department").filter(is_superuser=False, is_provisional=False)
if not request.user.is_px_admin():
if request.tenant_hospital:
queryset = queryset.filter(hospital=request.tenant_hospital)
else:
queryset = queryset.none()
hospital_filter = request.GET.get("hospital")
if hospital_filter:
queryset = queryset.filter(hospital_id=hospital_filter)
role_filter = request.GET.get("role")
if role_filter:
queryset = queryset.filter(groups__name=role_filter)
is_active = request.GET.get("is_active")
if is_active == "true":
queryset = queryset.filter(is_active=True)
elif is_active == "false":
queryset = queryset.filter(is_active=False)
search_query = request.GET.get("search")
if search_query:
queryset = queryset.filter(
Q(first_name__icontains=search_query)
| Q(last_name__icontains=search_query)
| Q(email__icontains=search_query)
| Q(employee_id__icontains=search_query)
)
queryset = queryset.order_by("-date_joined")
page_size = int(request.GET.get("page_size", 25))
paginator = Paginator(queryset, page_size)
page_number = request.GET.get("page", 1)
page_obj = paginator.get_page(page_number)
if request.user.is_px_admin():
hospitals = Hospital.objects.filter(status="active").order_by("name")
elif request.tenant_hospital:
hospitals = Hospital.objects.filter(id=request.tenant_hospital.id)
else:
hospitals = Hospital.objects.none()
from django.contrib.auth.models import Group
roles = Group.objects.all().order_by("name")
total_users = paginator.count
active_count = queryset.filter(is_active=True).count()
inactive_count = queryset.filter(is_active=False).count()
context = {
"page_obj": page_obj,
"users": page_obj.object_list,
"hospitals": hospitals,
"roles": roles,
"filters": request.GET,
"total_users": total_users,
"active_count": active_count,
"inactive_count": inactive_count,
}
return render(request, "config/hospital_users.html", context)
@admin_required
def reset_user_password(request, user_id):
"""Reset a user's password and send them the new credentials via email."""
if request.method != "POST":
return JsonResponse({"error": "Method not allowed"}, status=405)
target_user = get_object_or_404(User, pk=user_id, is_superuser=False)
if not request.user.is_px_admin():
if request.tenant_hospital and target_user.hospital != request.tenant_hospital:
return JsonResponse({"error": "You can only reset passwords for users in your hospital."}, status=403)
new_password = StaffService.generate_password()
target_user.set_password(new_password)
target_user.save(update_fields=["password"])
login_url = f"{request.scheme}://{request.get_host()}/accounts/login/"
html_message = render_to_string(
"config/emails/reset_password_email.html",
{
"user": target_user,
"password": new_password,
"login_url": login_url,
},
request=request,
)
plain_message = (
f"Dear {target_user.get_full_name()},\n\n"
f"Your password has been reset by an administrator.\n\n"
f"Your new credentials:\n"
f"Email: {target_user.email}\n"
f"Password: {new_password}\n\n"
f"Please login and change your password immediately.\n"
f"Login URL: {login_url}"
)
NotificationService.send_email(
email=target_user.email,
subject=_("Your PX360 Password Has Been Reset"),
message=plain_message,
html_message=html_message,
user=target_user,
notification_type="system",
)
return JsonResponse(
{
"success": True,
"message": f"Password has been reset for {target_user.get_full_name()}. A new password has been sent to {target_user.email}.",
"password": new_password,
"user_name": target_user.get_full_name(),
"user_email": target_user.email,
}
)
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from rich import print from rich import print

71
apps/core/encryption.py Normal file
View 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

View File

@ -574,35 +574,19 @@ class Command(BaseCommand):
def _send_complaint_notification_inline(self): def _send_complaint_notification_inline(self):
"""Example complaint notification (currently inline - no template)""" """Example complaint notification (currently inline - no template)"""
html_content = f""" html_content = f"""
<!DOCTYPE html> <h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">Complaint Notification</h1>
<html> <p style="margin: 0 0 15px; font-size: 16px; color: #1e293b; line-height: 1.6;">Dear Staff Member,</p>
<head><style> <p style="margin: 0 0 20px; font-size: 16px; color: #475569; line-height: 1.6;">A new complaint has been logged and requires your attention:</p>
body {{ font-family: 'Segoe UI', Tahoma, sans-serif; background: #f8fafc; margin: 0; padding: 20px; }} <div style="background-color: #f8fafc; border-radius: 6px; padding: 20px; margin-bottom: 20px;">
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }} <p style="margin: 0; font-size: 14px; color: #64748b; line-height: 1.8;">
.header {{ background: linear-gradient(135deg, #005696 0%, #007bbd 100%); padding: 30px; text-align: center; color: white; }} <strong style="color: #005696;">Reference:</strong> #COMP-2026-0050<br>
.content {{ padding: 30px; }} <strong style="color: #005696;">Title:</strong> Cleanliness Issue in Ward 3B<br>
.info-box {{ background: #eef6fb; border-left: 4px solid #005696; padding: 15px; margin: 20px 0; }} <strong style="color: #005696;">Patient:</strong> Mrs. Layla Ahmed<br>
</style></head> <strong style="color: #005696;">Department:</strong> Inpatient Ward 3B<br>
<body> <strong style="color: #005696;">Status:</strong> New
<div class="container"> </p>
<div class="header"> </div>
<h1>Complaint Notification</h1> <p style="margin: 0; font-size: 14px; color: #475569; line-height: 1.6;">Please review and take appropriate action.</p>
</div>
<div class="content">
<p>Dear Staff Member,</p>
<p>A new complaint has been logged and requires your attention:</p>
<div class="info-box">
<strong>Reference:</strong> #COMP-2026-0050<br>
<strong>Title:</strong> Cleanliness Issue in Ward 3B<br>
<strong>Patient:</strong> Mrs. Layla Ahmed<br>
<strong>Department:</strong> Inpatient Ward 3B<br>
<strong>Status:</strong> New
</div>
<p>Please review and take appropriate action.</p>
</div>
</div>
</body>
</html>
""" """
text_content = """ text_content = """
Complaint Notification Complaint Notification
@ -624,34 +608,18 @@ class Command(BaseCommand):
def _send_complaint_resolution_inline(self): def _send_complaint_resolution_inline(self):
"""Example complaint resolution notification (currently inline - no template)""" """Example complaint resolution notification (currently inline - no template)"""
html_content = f""" html_content = f"""
<!DOCTYPE html> <h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">Complaint Resolved</h1>
<html> <p style="margin: 0 0 15px; font-size: 16px; color: #1e293b; line-height: 1.6;">Dear Patient,</p>
<head><style> <p style="margin: 0 0 20px; font-size: 16px; color: #475569; line-height: 1.6;">Your complaint has been resolved!</p>
body {{ font-family: 'Segoe UI', Tahoma, sans-serif; background: #f8fafc; margin: 0; padding: 20px; }} <div style="background-color: #f8fafc; border-radius: 6px; padding: 20px; margin-bottom: 20px;">
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }} <p style="margin: 0; font-size: 14px; color: #64748b; line-height: 1.8;">
.header {{ background: linear-gradient(135deg, #005696 0%, #007bbd 100%); padding: 30px; text-align: center; color: white; }} <strong style="color: #005696;">Reference:</strong> #COMP-2026-0045<br>
.content {{ padding: 30px; }} <strong style="color: #005696;">Title:</strong> Appointment Scheduling Issue<br>
.success-box {{ background: #eef6fb; border-left: 4px solid #005696; padding: 15px; margin: 20px 0; }} <strong style="color: #005696;">Resolution:</strong> We have improved our scheduling system and scheduled your appointment for next week.
</style></head> </p>
<body> </div>
<div class="container"> <p style="margin: 0; font-size: 14px; color: #475569; line-height: 1.6;">We hope you are satisfied with the resolution. If you have any concerns, please contact us.</p>
<div class="header"> <p style="margin: 10px 0 0; font-size: 14px; color: #475569; line-height: 1.6;">Thank you for your feedback.</p>
<h1>Complaint Resolved</h1>
</div>
<div class="content">
<p>Dear Patient,</p>
<div class="success-box">
<strong>Your complaint has been resolved!</strong><br><br>
<strong>Reference:</strong> #COMP-2026-0045<br>
<strong>Title:</strong> Appointment Scheduling Issue<br>
<strong>Resolution:</strong> We have improved our scheduling system and scheduled your appointment for next week.
</div>
<p>We hope you are satisfied with the resolution. If you have any concerns, please contact us.</p>
<p>Thank you for your feedback.</p>
</div>
</div>
</body>
</html>
""" """
text_content = """ text_content = """
Complaint Resolved Complaint Resolved
@ -673,21 +641,7 @@ class Command(BaseCommand):
def _send_admin_new_complaint_inline(self): def _send_admin_new_complaint_inline(self):
"""Example admin new complaint notification (currently inline - no template)""" """Example admin new complaint notification (currently inline - no template)"""
html_content = f""" html_content = f"""
<!DOCTYPE html> <h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">🚨 New Complaint Alert</h1>
<html>
<head><style>
body {{ font-family: 'Segoe UI', Tahoma, sans-serif; background: #f8fafc; margin: 0; padding: 20px; }}
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }}
.header {{ background: linear-gradient(135deg, #005696 0%, #007bbd 100%); padding: 30px; text-align: center; color: white; }}
.content {{ padding: 30px; }}
.alert-box {{ background: #eef6fb; border-left: 4px solid #005696; padding: 15px; margin: 20px 0; }}
</style></head>
<body>
<div class="container">
<div class="header">
<h1>🚨 New Complaint Alert</h1>
</div>
<div class="content">
<p>Dear Administrator,</p> <p>Dear Administrator,</p>
<div class="alert-box"> <div class="alert-box">
<strong>A new high-priority complaint has been logged:</strong><br><br> <strong>A new high-priority complaint has been logged:</strong><br><br>
@ -700,10 +654,7 @@ class Command(BaseCommand):
<strong>Date:</strong> April 7, 2026 <strong>Date:</strong> April 7, 2026
</div> </div>
<p>Immediate attention required. Please review and assign to appropriate staff.</p> <p>Immediate attention required. Please review and assign to appropriate staff.</p>
</div>
</div>
</body>
</html>
""" """
text_content = """ text_content = """
🚨 New Complaint Alert 🚨 New Complaint Alert
@ -727,21 +678,7 @@ class Command(BaseCommand):
def _send_escalation_inline(self): def _send_escalation_inline(self):
"""Example escalation notification (currently inline - no template)""" """Example escalation notification (currently inline - no template)"""
html_content = f""" html_content = f"""
<!DOCTYPE html> <h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;"> Complaint Escalated</h1>
<html>
<head><style>
body {{ font-family: 'Segoe UI', Tahoma, sans-serif; background: #f8fafc; margin: 0; padding: 20px; }}
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }}
.header {{ background: linear-gradient(135deg, #005696 0%, #007bbd 100%); padding: 30px; text-align: center; color: white; }}
.content {{ padding: 30px; }}
.warning-box {{ background: #eef6fb; border-left: 4px solid #005696; padding: 15px; margin: 20px 0; }}
</style></head>
<body>
<div class="container">
<div class="header">
<h1> Complaint Escalated</h1>
</div>
<div class="content">
<p>Dear Manager,</p> <p>Dear Manager,</p>
<div class="warning-box"> <div class="warning-box">
<strong>Complaint has been escalated to your attention:</strong><br><br> <strong>Complaint has been escalated to your attention:</strong><br><br>
@ -752,10 +689,7 @@ class Command(BaseCommand):
<strong>Date:</strong> April 7, 2026 <strong>Date:</strong> April 7, 2026
</div> </div>
<p>Please review this complaint and provide your intervention to ensure resolution.</p> <p>Please review this complaint and provide your intervention to ensure resolution.</p>
</div>
</div>
</body>
</html>
""" """
text_content = """ text_content = """
Complaint Escalated Complaint Escalated
@ -935,21 +869,7 @@ class Command(BaseCommand):
def _send_survey_invitation(self): def _send_survey_invitation(self):
"""Example survey invitation (currently inline - no HTML template)""" """Example survey invitation (currently inline - no HTML template)"""
html_content = f""" html_content = f"""
<!DOCTYPE html> <h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">Share Your Experience</h1>
<html>
<head><style>
body {{ font-family: 'Segoe UI', Tahoma, sans-serif; background: #f8fafc; margin: 0; padding: 20px; }}
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }}
.header {{ background: linear-gradient(135deg, #005696 0%, #007bbd 100%); padding: 30px; text-align: center; color: white; }}
.content {{ padding: 30px; }}
.button {{ display: inline-block; padding: 14px 32px; background: #005696; color: white; text-decoration: none; border-radius: 8px; font-weight: 600; }}
</style></head>
<body>
<div class="container">
<div class="header">
<h1>Share Your Experience</h1>
</div>
<div class="content">
<p>Dear Valued Patient,</p> <p>Dear Valued Patient,</p>
<p>Thank you for visiting Al Hammadi Hospital. We would appreciate your feedback to help us improve our services.</p> <p>Thank you for visiting Al Hammadi Hospital. We would appreciate your feedback to help us improve our services.</p>
<p>Please take a few minutes to complete our satisfaction survey:</p> <p>Please take a few minutes to complete our satisfaction survey:</p>
@ -957,10 +877,7 @@ class Command(BaseCommand):
<a href="https://px360.sa/survey/complete/abc123" class="button">Take Survey</a> <a href="https://px360.sa/survey/complete/abc123" class="button">Take Survey</a>
</p> </p>
<p>Your feedback is important to us!</p> <p>Your feedback is important to us!</p>
</div>
</div>
</body>
</html>
""" """
text_content = """ text_content = """
Share Your Experience Share Your Experience
@ -1004,21 +921,7 @@ class Command(BaseCommand):
def _send_appreciation_notification(self): def _send_appreciation_notification(self):
"""Example appreciation notification (currently inline - no template)""" """Example appreciation notification (currently inline - no template)"""
html_content = f""" html_content = f"""
<!DOCTYPE html> <h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">🌟 Staff Appreciation</h1>
<html>
<head><style>
body {{ font-family: 'Segoe UI', Tahoma, sans-serif; background: #f8fafc; margin: 0; padding: 20px; }}
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }}
.header {{ background: linear-gradient(135deg, #005696 0%, #007bbd 100%); padding: 30px; text-align: center; color: white; }}
.content {{ padding: 30px; }}
.appreciation-box {{ background: #eef6fb; border-left: 4px solid #005696; padding: 20px; margin: 20px 0; }}
</style></head>
<body>
<div class="container">
<div class="header">
<h1>🌟 Staff Appreciation</h1>
</div>
<div class="content">
<p>Dear Team,</p> <p>Dear Team,</p>
<div class="appreciation-box"> <div class="appreciation-box">
<strong>Excellent work recognized!</strong><br><br> <strong>Excellent work recognized!</strong><br><br>
@ -1028,10 +931,7 @@ class Command(BaseCommand):
<strong>From:</strong> Patient Family Member <strong>From:</strong> Patient Family Member
</div> </div>
<p>Congratulations on this recognition! Your dedication to patient care is truly appreciated.</p> <p>Congratulations on this recognition! Your dedication to patient care is truly appreciated.</p>
</div>
</div>
</body>
</html>
""" """
text_content = """ text_content = """
🌟 Staff Appreciation 🌟 Staff Appreciation
@ -1086,28 +986,12 @@ class Command(BaseCommand):
def _send_explanation_requested(self): def _send_explanation_requested(self):
"""Example explanation requested from settings service (currently inline)""" """Example explanation requested from settings service (currently inline)"""
html_content = f""" html_content = f"""
<!DOCTYPE html> <h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">Explanation Requested</h1>
<html>
<head><style>
body {{ font-family: 'Segoe UI', Tahoma, sans-serif; background: #f8fafc; margin: 0; padding: 20px; }}
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }}
.header {{ background: linear-gradient(135deg, #005696 0%, #007bbd 100%); padding: 30px; text-align: center; color: white; }}
.content {{ padding: 30px; }}
</style></head>
<body>
<div class="container">
<div class="header">
<h1>Explanation Requested</h1>
</div>
<div class="content">
<p>Dear Staff Member,</p> <p>Dear Staff Member,</p>
<p>An explanation has been requested for complaint #COMP-2026-0060.</p> <p>An explanation has been requested for complaint #COMP-2026-0060.</p>
<p><strong>Deadline:</strong> April 14, 2026</p> <p><strong>Deadline:</strong> April 14, 2026</p>
<p>Please submit your explanation through the system.</p> <p>Please submit your explanation through the system.</p>
</div>
</div>
</body>
</html>
""" """
text_content = """ text_content = """
Explanation Requested Explanation Requested
@ -1125,29 +1009,13 @@ class Command(BaseCommand):
def _send_complaint_assigned(self): def _send_complaint_assigned(self):
"""Example complaint assigned from settings service (currently inline)""" """Example complaint assigned from settings service (currently inline)"""
html_content = f""" html_content = f"""
<!DOCTYPE html> <h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">Complaint Assigned to You</h1>
<html>
<head><style>
body {{ font-family: 'Segoe UI', Tahoma, sans-serif; background: #f8fafc; margin: 0; padding: 20px; }}
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }}
.header {{ background: linear-gradient(135deg, #005696 0%, #007bbd 100%); padding: 30px; text-align: center; color: white; }}
.content {{ padding: 30px; }}
</style></head>
<body>
<div class="container">
<div class="header">
<h1>Complaint Assigned to You</h1>
</div>
<div class="content">
<p>Dear Dr. Khalid,</p> <p>Dear Dr. Khalid,</p>
<p>A complaint has been assigned to you for review and response.</p> <p>A complaint has been assigned to you for review and response.</p>
<p><strong>Complaint:</strong> #COMP-2026-0062 - Staff Attitude Issue</p> <p><strong>Complaint:</strong> #COMP-2026-0062 - Staff Attitude Issue</p>
<p><strong>Department:</strong> Outpatient Clinic</p> <p><strong>Department:</strong> Outpatient Clinic</p>
<p>Please review and provide your explanation.</p> <p>Please review and provide your explanation.</p>
</div>
</div>
</body>
</html>
""" """
text_content = """ text_content = """
Complaint Assigned to You Complaint Assigned to You
@ -1166,29 +1034,13 @@ class Command(BaseCommand):
def _send_complaint_status_changed(self): def _send_complaint_status_changed(self):
"""Example complaint status changed from settings service (currently inline)""" """Example complaint status changed from settings service (currently inline)"""
html_content = f""" html_content = f"""
<!DOCTYPE html> <h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">Complaint Status Updated</h1>
<html>
<head><style>
body {{ font-family: 'Segoe UI', Tahoma, sans-serif; background: #f8fafc; margin: 0; padding: 20px; }}
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }}
.header {{ background: linear-gradient(135deg, #005696 0%, #007bbd 100%); padding: 30px; text-align: center; color: white; }}
.content {{ padding: 30px; }}
</style></head>
<body>
<div class="container">
<div class="header">
<h1>Complaint Status Updated</h1>
</div>
<div class="content">
<p>Dear Stakeholder,</p> <p>Dear Stakeholder,</p>
<p>The status of complaint #COMP-2026-0058 has been updated.</p> <p>The status of complaint #COMP-2026-0058 has been updated.</p>
<p><strong>Previous Status:</strong> Under Review</p> <p><strong>Previous Status:</strong> Under Review</p>
<p><strong>New Status:</strong> Resolved</p> <p><strong>New Status:</strong> Resolved</p>
<p><strong>Resolution:</strong> Issue has been addressed with staff member. Apology issued to patient.</p> <p><strong>Resolution:</strong> Issue has been addressed with staff member. Apology issued to patient.</p>
</div>
</div>
</body>
</html>
""" """
text_content = """ text_content = """
Complaint Status Updated Complaint Status Updated

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

View File

@ -231,9 +231,9 @@ class HISAdapter:
"""Get or create patient from HIS demographic data""" """Get or create patient from HIS demographic data"""
patient_id = patient_data.get("PatientID") patient_id = patient_data.get("PatientID")
mrn = patient_id mrn = patient_id
national_id = patient_data.get("SSN") national_id = patient_data.get("SSN") or ""
phone = patient_data.get("MobileNo") phone = patient_data.get("MobileNo") or ""
email = patient_data.get("Email") email = patient_data.get("Email") or ""
full_name = patient_data.get("PatientName") full_name = patient_data.get("PatientName")
nationality = patient_data.get("PatientNationality", "") nationality = patient_data.get("PatientNationality", "")
@ -249,8 +249,8 @@ class HISAdapter:
if patient: if patient:
patient.first_name = first_name patient.first_name = first_name
patient.last_name = last_name patient.last_name = last_name
patient.national_id = national_id patient.national_id = national_id or ""
patient.phone = phone patient.phone = phone or ""
if email is not None: if email is not None:
patient.email = email patient.email = email
patient.date_of_birth = date_of_birth patient.date_of_birth = date_of_birth
@ -262,7 +262,10 @@ class HISAdapter:
mrn_taken = Patient.objects.filter(mrn=mrn).exists() mrn_taken = Patient.objects.filter(mrn=mrn).exists()
if mrn_taken and national_id: if mrn_taken and national_id:
patient = Patient.objects.filter(national_id=national_id).first() from apps.core.encryption import compute_national_id_hash
nid_hash = compute_national_id_hash(national_id)
patient = Patient.objects.filter(national_id_hash=nid_hash).first()
if patient: if patient:
patient.mrn = mrn patient.mrn = mrn
patient.primary_hospital = hospital patient.primary_hospital = hospital
@ -288,8 +291,8 @@ class HISAdapter:
first_name=first_name, first_name=first_name,
last_name=last_name, last_name=last_name,
national_id=national_id, national_id=national_id,
phone=phone, phone=phone or "",
email=email if email else "", email=email or "",
date_of_birth=date_of_birth, date_of_birth=date_of_birth,
gender=gender, gender=gender,
nationality=nationality, nationality=nationality,

View File

@ -32,17 +32,17 @@ class HISClient:
Initialize HIS client. Initialize HIS client.
Args: Args:
config: IntegrationConfig instance (optional). config: IntegrationConfig instance (optional).
Environment variables (.env) take priority. Environment variables (.env) take priority.
""" """
from django.conf import settings from django.conf import settings
self.config = config self.config = config
self.session = requests.Session() self.session = requests.Session()
# Load credentials from Django settings (which reads .env) # Load credentials from Django settings (which reads .env)
self.username = getattr(settings, 'HIS_API_USERNAME', '') self.username = getattr(settings, "HIS_API_USERNAME", "")
self.password = getattr(settings, 'HIS_API_PASSWORD', '') self.password = getattr(settings, "HIS_API_PASSWORD", "")
def _get_default_config(self) -> Optional[IntegrationConfig]: def _get_default_config(self) -> Optional[IntegrationConfig]:
"""Get default active HIS configuration from database.""" """Get default active HIS configuration from database."""
@ -55,14 +55,14 @@ class HISClient:
def _get_headers(self) -> Dict[str, str]: def _get_headers(self) -> Dict[str, str]:
"""Get request headers with authentication.""" """Get request headers with authentication."""
from django.conf import settings from django.conf import settings
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"Accept": "application/json", "Accept": "application/json",
} }
# Priority 1: API key from Django settings # Priority 1: API key from Django settings
api_key = getattr(settings, 'HIS_API_KEY', None) api_key = getattr(settings, "HIS_API_KEY", None)
if api_key: if api_key:
headers["X-API-Key"] = api_key headers["X-API-Key"] = api_key
headers["Authorization"] = f"Bearer {api_key}" headers["Authorization"] = f"Bearer {api_key}"
@ -76,16 +76,16 @@ class HISClient:
def _get_api_url(self) -> Optional[str]: def _get_api_url(self) -> Optional[str]:
"""Get API URL from Django settings (priority) or fallback.""" """Get API URL from Django settings (priority) or fallback."""
from django.conf import settings from django.conf import settings
# Priority 1: Django settings (.env file) # Priority 1: Django settings (.env file)
settings_url = getattr(settings, 'HIS_API_URL', None) settings_url = getattr(settings, "HIS_API_URL", None)
if settings_url: if settings_url:
return settings_url return settings_url
# Priority 2: Database config (if explicitly passed) # Priority 2: Database config (if explicitly passed)
if self.config and self.config.api_url: if self.config and self.config.api_url:
return self.config.api_url return self.config.api_url
# Priority 3: Hardcoded default # Priority 3: Hardcoded default
return "https://his.alhammadi.med.sa/SSRCE/API/FetchPatientVisitTimeStamps" return "https://his.alhammadi.med.sa/SSRCE/API/FetchPatientVisitTimeStamps"
@ -159,18 +159,18 @@ class HISClient:
def fetch_doctor_ratings(self, from_date: datetime, to_date: datetime) -> Optional[Dict]: def fetch_doctor_ratings(self, from_date: datetime, to_date: datetime) -> Optional[Dict]:
""" """
Fetch doctor ratings from HIS FetchDoctorRatingMAPI1 endpoint. Fetch doctor ratings from HIS FetchDoctorRatingMAPI endpoint.
Args: Args:
from_date: Start date for ratings from_date: Start date for ratings
to_date: End date for ratings to_date: End date for ratings
Returns: Returns:
HIS response dict with FetchDoctorRatingMAPI1List or None on error HIS response dict with FetchDoctorRatingMAPIList or None on error
""" """
from django.conf import settings from django.conf import settings
api_url = getattr(settings, 'HIS_RATINGS_API_URL', None) api_url = getattr(settings, "HIS_RATINGS_API_URL", None)
if not api_url: if not api_url:
logger.error("HIS_RATINGS_API_URL not configured in Django settings") logger.error("HIS_RATINGS_API_URL not configured in Django settings")
return None return None
@ -192,11 +192,17 @@ class HISClient:
) )
response.raise_for_status() response.raise_for_status()
logger.info(f"HIS doctor ratings response status: {response.status_code}")
logger.debug(f"HIS doctor ratings response body: {response.text[:2000]}")
data = response.json() data = response.json()
if isinstance(data, dict): if isinstance(data, dict):
logger.info(f"HIS doctor ratings response keys: {list(data.keys())}")
rating_count = len(data.get("FetchDoctorRatingMAPI1List", [])) rating_count = len(data.get("FetchDoctorRatingMAPI1List", []))
logger.info(f"Fetched {rating_count} doctor ratings from HIS") logger.info(f"Fetched {rating_count} doctor ratings from HIS")
if rating_count == 0 and data:
logger.info(f"Full HIS response (no ratings): {data}")
return data return data
else: else:
logger.error(f"Unexpected HIS response type: {type(data)}") logger.error(f"Unexpected HIS response type: {type(data)}")
@ -390,12 +396,12 @@ class HISClientFactory:
def get_all_active_clients() -> List[HISClient]: def get_all_active_clients() -> List[HISClient]:
"""Get all active HIS clients for multi-hospital setups.""" """Get all active HIS clients for multi-hospital setups."""
from django.conf import settings from django.conf import settings
# Priority 1: If Django settings has HIS API URL, use it # Priority 1: If Django settings has HIS API URL, use it
if getattr(settings, 'HIS_API_URL', None): if getattr(settings, "HIS_API_URL", None):
logger.info("Using HIS API URL from Django settings (.env file)") logger.info("Using HIS API URL from Django settings (.env file)")
return [HISClient()] return [HISClient()]
# Priority 2: Fall back to database configs (for multi-hospital setups) # Priority 2: Fall back to database configs (for multi-hospital setups)
configs = IntegrationConfig.objects.filter(source_system=SourceSystem.HIS, is_active=True) configs = IntegrationConfig.objects.filter(source_system=SourceSystem.HIS, is_active=True)

View File

@ -1,209 +1,20 @@
""" """
Integrations Celery tasks Integrations Celery tasks
This module contains the core event processing logic that: This module contains tasks for:
1. Processes inbound events from external systems 1. Fetching surveys from HIS systems (every 25 minutes)
2. Finds matching journey instances 2. Testing HIS connection
3. Completes journey stages
4. Triggers survey creation
5. Fetches surveys from HIS systems (every 5 minutes)
""" """
import logging import logging
from datetime import datetime from datetime import datetime
from celery import shared_task from celery import shared_task
from django.db import transaction
from django.utils import timezone from django.utils import timezone
logger = logging.getLogger("apps.integrations") logger = logging.getLogger("apps.integrations")
@shared_task(bind=True, max_retries=3)
def process_inbound_event(self, event_id):
"""
Process an inbound integration event.
This is the core event processing task that:
1. Finds the journey instance by encounter_id
2. Finds the matching stage by trigger_event_code
3. Completes the stage
4. Creates survey instance if configured
5. Logs audit events
Args:
event_id: UUID of the InboundEvent to process
Returns:
dict: Processing result with status and details
"""
from apps.core.services import create_audit_log
from apps.integrations.models import InboundEvent
from apps.journeys.models import PatientJourneyInstance, PatientJourneyStageInstance, StageStatus
from apps.organizations.models import Department, Staff
try:
# Get the event
event = InboundEvent.objects.get(id=event_id)
event.mark_processing()
logger.info(f"Processing event {event.id}: {event.event_code} for encounter {event.encounter_id}")
# Find journey instance by encounter_id
try:
journey_instance = PatientJourneyInstance.objects.select_related(
"journey_template", "patient", "hospital"
).get(encounter_id=event.encounter_id)
except PatientJourneyInstance.DoesNotExist:
error_msg = f"No journey instance found for encounter {event.encounter_id}"
logger.warning(error_msg)
event.mark_ignored(error_msg)
return {"status": "ignored", "reason": error_msg}
# Find matching stage by trigger_event_code
matching_stages = journey_instance.stage_instances.filter(
stage_template__trigger_event_code=event.event_code,
status__in=[StageStatus.PENDING, StageStatus.IN_PROGRESS],
).select_related("stage_template")
if not matching_stages.exists():
error_msg = f"No pending stage found with trigger {event.event_code}"
logger.warning(error_msg)
event.mark_ignored(error_msg)
return {"status": "ignored", "reason": error_msg}
# Get the first matching stage
stage_instance = matching_stages.first()
# Extract staff and department from event payload
staff = None
department = None
if event.physician_license:
try:
staff = Staff.objects.get(license_number=event.physician_license, hospital=journey_instance.hospital)
except Staff.DoesNotExist:
logger.warning(f"Staff member not found with license: {event.physician_license}")
if event.department_code:
try:
department = Department.objects.get(code=event.department_code, hospital=journey_instance.hospital)
except Department.DoesNotExist:
logger.warning(f"Department not found: {event.department_code}")
# Complete the stage
with transaction.atomic():
success = stage_instance.complete(
event=event, staff=staff, department=department, metadata=event.payload_json
)
if success:
# Log stage completion
create_audit_log(
event_type="stage_completed",
description=f"Stage {stage_instance.stage_template.name} completed for encounter {event.encounter_id}",
content_object=stage_instance,
metadata={
"event_code": event.event_code,
"stage_name": stage_instance.stage_template.name,
"journey_type": journey_instance.journey_template.journey_type,
},
)
# Check if this is a discharge event
if event.event_code.upper() == "PATIENT_DISCHARGED":
logger.info(f"Discharge event received for encounter {event.encounter_id}")
# Mark journey as completed
journey_instance.status = "completed"
journey_instance.completed_at = timezone.now()
journey_instance.save()
# Check if post-discharge survey is enabled
if journey_instance.journey_template.send_post_discharge_survey:
logger.info(
f"Post-discharge survey enabled for journey {journey_instance.id}. "
f"Will send in {journey_instance.journey_template.post_discharge_survey_delay_hours} hour(s)"
)
# Queue post-discharge survey creation task with delay
from apps.surveys.tasks import create_post_discharge_survey
delay_hours = journey_instance.journey_template.post_discharge_survey_delay_hours
delay_seconds = delay_hours * 3600
create_post_discharge_survey.apply_async(
args=[str(journey_instance.id)], countdown=delay_seconds
)
logger.info(
f"Queued post-discharge survey for journey {journey_instance.id} (delay: {delay_hours}h)"
)
else:
logger.info(f"Post-discharge survey disabled for journey {journey_instance.id}")
# Mark event as processed
event.mark_processed()
logger.info(
f"Successfully processed event {event.id}: Completed stage {stage_instance.stage_template.name}"
)
return {
"status": "processed",
"stage_completed": stage_instance.stage_template.name,
"journey_completion": journey_instance.get_completion_percentage(),
}
else:
error_msg = "Failed to complete stage"
event.mark_failed(error_msg)
return {"status": "failed", "reason": error_msg}
except InboundEvent.DoesNotExist:
error_msg = f"Event {event_id} not found"
logger.error(error_msg)
return {"status": "error", "reason": error_msg}
except Exception as e:
error_msg = f"Error processing event: {str(e)}"
logger.error(error_msg, exc_info=True)
try:
event.mark_failed(error_msg)
except:
pass
# Retry the task
raise self.retry(exc=e, countdown=60 * (self.request.retries + 1))
@shared_task
def process_pending_events():
"""
Periodic task to process pending events.
This task runs every minute (configured in config/celery.py)
and processes all pending events.
"""
from apps.integrations.models import InboundEvent
pending_events = InboundEvent.objects.filter(status="pending").order_by("received_at")[
:100
] # Process max 100 at a time
processed_count = 0
for event in pending_events:
# Queue individual event for processing
process_inbound_event.delay(str(event.id))
processed_count += 1
if processed_count > 0:
logger.info(f"Queued {processed_count} pending events for processing")
return {"queued": processed_count}
# ============================================================================= # =============================================================================
# HIS Survey Fetching Tasks # HIS Survey Fetching Tasks
# ============================================================================= # =============================================================================

View File

@ -1,141 +1,170 @@
""" """
Organizations admin Organizations admin
""" """
from django.contrib import admin from django.contrib import admin
from .models import Department, Hospital, Organization, Patient, Staff,Location,MainSection,SubSection from .models import Department, Hospital, Organization, Patient, Staff, Location, MainSection, SubSection
@admin.register(Organization) @admin.register(Organization)
class OrganizationAdmin(admin.ModelAdmin): class OrganizationAdmin(admin.ModelAdmin):
"""Organization admin""" """Organization admin"""
list_display = ['name', 'code', 'city', 'status', 'created_at']
list_filter = ['status', 'city'] list_display = ["name", "code", "city", "status", "created_at"]
search_fields = ['name', 'name_ar', 'code', 'license_number'] list_filter = ["status", "city"]
ordering = ['name'] search_fields = ["name", "name_ar", "code"]
ordering = ["name"]
fieldsets = ( fieldsets = (
(None, {'fields': ('name', 'name_ar', 'code')}), (None, {"fields": ("name", "name_ar", "code")}),
('Contact Information', {'fields': ('address', 'city', 'phone', 'email', 'website')}), ("Contact Information", {"fields": ("address", "city", "phone", "email")}),
('Details', {'fields': ('license_number', 'status', 'logo')}), ("Details", {"fields": ("status", "preferred_language")}),
('Metadata', {'fields': ('created_at', 'updated_at')}), ("Metadata", {"fields": ("created_at", "updated_at")}),
) )
readonly_fields = ['created_at', 'updated_at'] readonly_fields = ["created_at", "updated_at"]
@admin.register(Hospital) @admin.register(Hospital)
class HospitalAdmin(admin.ModelAdmin): class HospitalAdmin(admin.ModelAdmin):
"""Hospital admin""" """Hospital admin"""
list_display = ['name', 'code', 'city', 'ceo', 'status', 'capacity', 'created_at']
list_filter = ['status', 'city'] list_display = ["name", "code", "city", "ceo", "status", "capacity", "created_at"]
search_fields = ['name', 'name_ar', 'code', 'license_number'] list_filter = ["status", "city"]
ordering = ['name'] search_fields = ["name", "name_ar", "code", "license_number"]
ordering = ["name"]
fieldsets = ( fieldsets = (
(None, {'fields': ('organization', 'name', 'name_ar', 'code')}), (None, {"fields": ("organization", "name", "name_ar", "code")}),
('Contact Information', {'fields': ('address', 'city', 'phone', 'email')}), ("Contact Information", {"fields": ("address", "city", "phone", "email")}),
('Executive Leadership', {'fields': ('ceo', 'medical_director', 'coo', 'cfo')}), ("Executive Leadership", {"fields": ("ceo", "medical_director", "coo", "cfo")}),
('Details', {'fields': ('license_number', 'capacity', 'status')}), ("Details", {"fields": ("license_number", "capacity", "status")}),
('Metadata', {'fields': ('created_at', 'updated_at')}), ("Metadata", {"fields": ("created_at", "updated_at")}),
) )
autocomplete_fields = ['organization', 'ceo', 'medical_director', 'coo', 'cfo'] autocomplete_fields = ["organization", "ceo", "medical_director", "coo", "cfo"]
readonly_fields = ['created_at', 'updated_at'] readonly_fields = ["created_at", "updated_at"]
@admin.register(Department) @admin.register(Department)
class DepartmentAdmin(admin.ModelAdmin): class DepartmentAdmin(admin.ModelAdmin):
"""Department admin""" """Department admin"""
list_display = ['name', 'hospital', 'code', 'manager', 'status', 'created_at']
list_filter = ['status', 'hospital'] list_display = ["name", "hospital", "code", "manager", "status", "created_at"]
search_fields = ['name', 'name_ar', 'code'] list_filter = ["status", "hospital"]
ordering = ['hospital', 'name'] search_fields = ["name", "name_ar", "code"]
autocomplete_fields = ['hospital', 'parent', 'manager'] ordering = ["hospital", "name"]
autocomplete_fields = ["hospital", "parent", "manager"]
fieldsets = ( fieldsets = (
(None, {'fields': ('hospital', 'name', 'name_ar', 'code')}), (None, {"fields": ("hospital", "name", "name_ar", "code")}),
('Hierarchy', {'fields': ('parent', 'manager')}), ("Hierarchy", {"fields": ("parent", "manager")}),
('Contact', {'fields': ('phone', 'email', 'location')}), ("Contact", {"fields": ("phone", "email", "location")}),
('Status', {'fields': ('status',)}), ("Status", {"fields": ("status",)}),
('Metadata', {'fields': ('created_at', 'updated_at')}), ("Metadata", {"fields": ("created_at", "updated_at")}),
) )
readonly_fields = ['created_at', 'updated_at'] readonly_fields = ["created_at", "updated_at"]
def get_queryset(self, request): def get_queryset(self, request):
qs = super().get_queryset(request) qs = super().get_queryset(request)
return qs.select_related('hospital', 'manager', 'parent') return qs.select_related("hospital", "manager", "parent")
@admin.register(Staff) @admin.register(Staff)
class StaffAdmin(admin.ModelAdmin): class StaffAdmin(admin.ModelAdmin):
"""Staff admin""" """Staff admin"""
list_display = ['__str__', 'staff_type', 'job_title', 'employee_id', 'hospital', 'department', 'email','phone', 'report_to', 'country', 'has_user_account', 'status']
list_filter = ['status', 'hospital', 'staff_type', 'specialization', 'gender', 'country'] list_display = [
search_fields = ['name', 'first_name', 'last_name', 'first_name_ar', 'last_name_ar', 'employee_id', 'license_number', 'job_title', 'phone', 'department_name', 'section'] "__str__",
ordering = ['last_name', 'first_name'] "staff_type",
autocomplete_fields = ['hospital', 'department', 'user', 'report_to'] "job_title",
actions = ['create_user_accounts', 'send_credentials_emails'] "employee_id",
"hospital",
"department",
"email",
"phone",
"report_to",
"country",
"has_user_account",
"status",
]
list_filter = ["status", "hospital", "staff_type", "specialization", "gender", "country"]
search_fields = [
"name",
"first_name",
"last_name",
"first_name_ar",
"last_name_ar",
"employee_id",
"license_number",
"job_title",
"phone",
"department_name",
"section",
]
ordering = ["last_name", "first_name"]
autocomplete_fields = ["hospital", "department", "user", "report_to"]
actions = ["create_user_accounts", "send_credentials_emails"]
fieldsets = ( fieldsets = (
(None, {'fields': ('name', 'first_name', 'last_name', 'first_name_ar', 'last_name_ar')}), (None, {"fields": ("name", "first_name", "last_name", "first_name_ar", "last_name_ar")}),
('Role', {'fields': ('staff_type', 'job_title')}), ("Role", {"fields": ("staff_type", "job_title")}),
('Professional', {'fields': ('license_number', 'specialization', 'employee_id', 'email', 'phone')}), ("Professional", {"fields": ("license_number", "specialization", "employee_id", "email", "phone")}),
('Organization', {'fields': ('hospital', 'department', 'department_name', 'section', 'subsection', 'location')}), (
('Hierarchy', {'fields': ('report_to',)}), "Organization",
('Personal Information', {'fields': ('country', 'gender')}), {"fields": ("hospital", "department", "department_name", "section", "subsection", "location")},
('Account', {'fields': ('user',)}), ),
('Status', {'fields': ('status',)}), ("Hierarchy", {"fields": ("report_to",)}),
('Metadata', {'fields': ('created_at', 'updated_at')}), ("Personal Information", {"fields": ("country", "gender")}),
("Account", {"fields": ("user",)}),
("Status", {"fields": ("status",)}),
("Metadata", {"fields": ("created_at", "updated_at")}),
) )
readonly_fields = ['created_at', 'updated_at'] readonly_fields = ["created_at", "updated_at"]
def get_queryset(self, request): def get_queryset(self, request):
qs = super().get_queryset(request) qs = super().get_queryset(request)
return qs.select_related('hospital', 'department', 'user') return qs.select_related("hospital", "department", "user")
def has_user_account(self, obj): def has_user_account(self, obj):
"""Display user account status""" """Display user account status"""
if obj.user: if obj.user:
return '<span style="color: green;">✓ Yes</span>' return '<span style="color: green;">✓ Yes</span>'
return '<span style="color: red;">✗ No</span>' return '<span style="color: red;">✗ No</span>'
has_user_account.short_description = 'User Account'
has_user_account.short_description = "User Account"
has_user_account.allow_tags = True has_user_account.allow_tags = True
def create_user_accounts(self, request, queryset): def create_user_accounts(self, request, queryset):
"""Admin action to create user accounts for selected staff""" """Admin action to create user accounts for selected staff"""
from .services import StaffService from .services import StaffService
created = 0 created = 0
failed = 0 failed = 0
for staff in queryset: for staff in queryset:
if not staff.user and staff.email: if not staff.user and staff.email:
try: try:
role = StaffService.get_staff_type_role(staff.staff_type) role = StaffService.get_staff_type_role(staff.staff_type)
user, was_created, password = StaffService.create_user_for_staff( user, was_created, password = StaffService.create_user_for_staff(staff, role=role, request=request)
staff,
role=role,
request=request
)
if was_created and password: if was_created and password:
StaffService.send_credentials_email(staff, password, request) StaffService.send_credentials_email(staff, password, request)
created += 1 created += 1
except Exception as e: except Exception as e:
failed += 1 failed += 1
self.message_user( self.message_user(
request, request, f"Created {created} user accounts. Failed: {failed}", level="success" if failed == 0 else "warning"
f'Created {created} user accounts. Failed: {failed}',
level='success' if failed == 0 else 'warning'
) )
create_user_accounts.short_description = 'Create user accounts for selected staff'
create_user_accounts.short_description = "Create user accounts for selected staff"
def send_credentials_emails(self, request, queryset): def send_credentials_emails(self, request, queryset):
"""Admin action to send credential emails to selected staff""" """Admin action to send credential emails to selected staff"""
from .services import StaffService from .services import StaffService
sent = 0 sent = 0
failed = 0 failed = 0
for staff in queryset: for staff in queryset:
@ -148,42 +177,55 @@ class StaffAdmin(admin.ModelAdmin):
sent += 1 sent += 1
except Exception as e: except Exception as e:
failed += 1 failed += 1
self.message_user( self.message_user(
request, request, f"Sent {sent} credential emails. Failed: {failed}", level="success" if failed == 0 else "warning"
f'Sent {sent} credential emails. Failed: {failed}',
level='success' if failed == 0 else 'warning'
) )
send_credentials_emails.short_description = 'Send credential emails to selected staff'
send_credentials_emails.short_description = "Send credential emails to selected staff"
@admin.register(Patient) @admin.register(Patient)
class PatientAdmin(admin.ModelAdmin): class PatientAdmin(admin.ModelAdmin):
"""Patient admin""" """Patient admin"""
list_display = ['get_full_name', 'mrn', 'national_id', 'phone', 'primary_hospital', 'status']
list_filter = ['status', 'gender', 'primary_hospital', 'city'] list_display = ["get_full_name", "mrn", "get_masked_national_id", "phone", "primary_hospital", "status"]
search_fields = ['mrn', 'national_id', 'first_name', 'last_name', 'first_name_ar', 'last_name_ar', 'phone', 'email'] list_filter = ["status", "gender", "primary_hospital", "city"]
ordering = ['last_name', 'first_name'] search_fields = [
autocomplete_fields = ['primary_hospital'] "mrn",
"national_id_hash",
"first_name",
"last_name",
"first_name_ar",
"last_name_ar",
"phone",
"email",
]
ordering = ["last_name", "first_name"]
autocomplete_fields = ["primary_hospital"]
fieldsets = ( fieldsets = (
(None, {'fields': ('mrn', 'national_id')}), (None, {"fields": ("mrn", "national_id")}),
('Personal Information', {'fields': ('first_name', 'last_name', 'first_name_ar', 'last_name_ar')}), ("Personal Information", {"fields": ("first_name", "last_name", "first_name_ar", "last_name_ar")}),
('Demographics', {'fields': ('date_of_birth', 'gender')}), ("Demographics", {"fields": ("date_of_birth", "gender")}),
('Contact', {'fields': ('phone', 'email', 'address', 'city')}), ("Contact", {"fields": ("phone", "email", "address", "city")}),
('Hospital', {'fields': ('primary_hospital',)}), ("Hospital", {"fields": ("primary_hospital",)}),
('Status', {'fields': ('status',)}), ("Status", {"fields": ("status",)}),
('Metadata', {'fields': ('created_at', 'updated_at')}), ("Metadata", {"fields": ("created_at", "updated_at")}),
) )
readonly_fields = ['created_at', 'updated_at'] readonly_fields = ["created_at", "updated_at"]
def get_masked_national_id(self, obj):
return obj.get_masked_national_id()
get_masked_national_id.short_description = "National ID"
def get_queryset(self, request): def get_queryset(self, request):
qs = super().get_queryset(request) qs = super().get_queryset(request)
return qs.select_related('primary_hospital') return qs.select_related("primary_hospital")
admin.site.register(Location) admin.site.register(Location)
admin.site.register(MainSection) admin.site.register(MainSection)
admin.site.register(SubSection) admin.site.register(SubSection)

View File

@ -5,6 +5,7 @@ Organizations models - Hospital, Department, Physician, Employee, Patient
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from apps.core.encryption import EncryptedCharField, compute_national_id_hash, mask_national_id
from apps.core.models import TimeStampedModel, UUIDModel, StatusChoices from apps.core.models import TimeStampedModel, UUIDModel, StatusChoices
@ -412,7 +413,8 @@ class Patient(UUIDModel, TimeStampedModel):
# Basic information # Basic information
mrn = models.CharField(max_length=50, unique=True, verbose_name="Medical Record Number") mrn = models.CharField(max_length=50, unique=True, verbose_name="Medical Record Number")
national_id = models.CharField(max_length=50, blank=True, db_index=True) national_id = EncryptedCharField(max_length=50, blank=True, default="")
national_id_hash = models.CharField(max_length=64, blank=True, db_index=True, default="")
first_name = models.CharField(max_length=100) first_name = models.CharField(max_length=100)
last_name = models.CharField(max_length=100) last_name = models.CharField(max_length=100)
@ -449,6 +451,17 @@ class Patient(UUIDModel, TimeStampedModel):
def get_full_name(self): def get_full_name(self):
return f"{self.first_name} {self.last_name}" return f"{self.first_name} {self.last_name}"
def get_masked_national_id(self):
return mask_national_id(self.national_id)
def save(self, *args, **kwargs):
if self.national_id:
self.national_id_hash = compute_national_id_hash(self.national_id)
else:
self.national_id = self.national_id or ""
self.national_id_hash = ""
super().save(*args, **kwargs)
@staticmethod @staticmethod
def generate_mrn(): def generate_mrn():
""" """

View File

@ -268,6 +268,8 @@ class PatientSerializer(serializers.ModelSerializer):
primary_hospital_name = serializers.CharField(source="primary_hospital.name", read_only=True) primary_hospital_name = serializers.CharField(source="primary_hospital.name", read_only=True)
full_name = serializers.CharField(source="get_full_name", read_only=True) full_name = serializers.CharField(source="get_full_name", read_only=True)
age = serializers.SerializerMethodField() age = serializers.SerializerMethodField()
national_id = serializers.SerializerMethodField()
national_id_masked = serializers.SerializerMethodField()
class Meta: class Meta:
model = Patient model = Patient
@ -275,6 +277,7 @@ class PatientSerializer(serializers.ModelSerializer):
"id", "id",
"mrn", "mrn",
"national_id", "national_id",
"national_id_masked",
"first_name", "first_name",
"last_name", "last_name",
"first_name_ar", "first_name_ar",
@ -295,6 +298,15 @@ class PatientSerializer(serializers.ModelSerializer):
] ]
read_only_fields = ["id", "created_at", "updated_at"] read_only_fields = ["id", "created_at", "updated_at"]
def get_national_id(self, obj):
request = self.context.get("request")
if request and request.user and request.user.is_superuser:
return obj.national_id
return None
def get_national_id_masked(self, obj):
return obj.get_masked_national_id()
def get_age(self, obj): def get_age(self, obj):
"""Calculate patient age""" """Calculate patient age"""
if obj.date_of_birth: if obj.date_of_birth:
@ -314,10 +326,11 @@ class PatientListSerializer(serializers.ModelSerializer):
full_name = serializers.CharField(source="get_full_name", read_only=True) full_name = serializers.CharField(source="get_full_name", read_only=True)
primary_hospital_name = serializers.CharField(source="primary_hospital.name", read_only=True) primary_hospital_name = serializers.CharField(source="primary_hospital.name", read_only=True)
national_id_masked = serializers.CharField(source="get_masked_national_id", read_only=True)
class Meta: class Meta:
model = Patient model = Patient
fields = ["id", "mrn", "full_name", "phone", "email", "primary_hospital_name", "status"] fields = ["id", "mrn", "full_name", "national_id_masked", "phone", "email", "primary_hospital_name", "status"]
class LocationSerializer(serializers.ModelSerializer): class LocationSerializer(serializers.ModelSerializer):

View File

@ -324,11 +324,14 @@ def patient_list(request):
# Search # Search
search_query = request.GET.get("search") search_query = request.GET.get("search")
if search_query: if search_query:
from apps.core.encryption import compute_national_id_hash
nid_hash = compute_national_id_hash(search_query)
queryset = queryset.filter( queryset = queryset.filter(
Q(mrn__icontains=search_query) Q(mrn__icontains=search_query)
| Q(first_name__icontains=search_query) | Q(first_name__icontains=search_query)
| Q(last_name__icontains=search_query) | Q(last_name__icontains=search_query)
| Q(national_id__icontains=search_query) | Q(national_id_hash=nid_hash)
| Q(phone__icontains=search_query) | Q(phone__icontains=search_query)
) )
@ -375,7 +378,7 @@ def patient_list(request):
p.mrn, p.mrn,
p.first_name, p.first_name,
p.last_name, p.last_name,
p.national_id, p.get_masked_national_id(),
p.get_gender_display(), p.get_gender_display(),
p.nationality, p.nationality,
p.phone, p.phone,

View File

@ -610,7 +610,7 @@ class PatientViewSet(viewsets.ModelViewSet):
queryset = Patient.objects.all() queryset = Patient.objects.all()
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
filterset_fields = ["status", "gender", "primary_hospital", "city", "primary_hospital__organization"] filterset_fields = ["status", "gender", "primary_hospital", "city", "primary_hospital__organization"]
search_fields = ["mrn", "national_id", "first_name", "last_name", "phone", "email"] search_fields = ["mrn", "national_id_hash", "first_name", "last_name", "phone", "email"]
ordering_fields = ["last_name", "created_at"] ordering_fields = ["last_name", "created_at"]
ordering = ["last_name", "first_name"] ordering = ["last_name", "first_name"]
@ -647,15 +647,27 @@ class PatientViewSet(viewsets.ModelViewSet):
q = request.query_params.get("q", "").strip() q = request.query_params.get("q", "").strip()
queryset = self.get_queryset().filter(status="active") queryset = self.get_queryset().filter(status="active")
if q: if q:
from apps.core.encryption import compute_national_id_hash
nid_hash = compute_national_id_hash(q)
queryset = queryset.filter( queryset = queryset.filter(
models.Q(mrn__icontains=q) models.Q(mrn__icontains=q)
| models.Q(first_name__icontains=q) | models.Q(first_name__icontains=q)
| models.Q(last_name__icontains=q) | models.Q(last_name__icontains=q)
| models.Q(national_id__icontains=q) | models.Q(national_id_hash=nid_hash)
| models.Q(phone__icontains=q) | models.Q(phone__icontains=q)
) )
queryset = queryset[:20] queryset = queryset[:20]
data = [{"id": p.id, "first_name": p.first_name, "last_name": p.last_name, "mrn": p.mrn} for p in queryset] data = [
{
"id": p.id,
"first_name": p.first_name,
"last_name": p.last_name,
"mrn": p.mrn,
"national_id_masked": p.get_masked_national_id(),
}
for p in queryset
]
return Response(data) return Response(data)
@ -706,7 +718,7 @@ class SubSectionViewSet(viewsets.ReadOnlyModelViewSet):
@permission_classes([]) @permission_classes([])
def api_location_list(request): def api_location_list(request):
"""API endpoint for location dropdown (public access)""" """API endpoint for location dropdown (public access)"""
locations = Location.objects.all().order_by("id") locations = Location.objects.filter(id__in=[48, 49, 82, 110]).order_by("id")
serializer = LocationSerializer(locations, many=True) serializer = LocationSerializer(locations, many=True)
return Response(serializer.data) return Response(serializer.data)

View File

@ -81,14 +81,19 @@ class DoctorRatingAdapter:
formats = [ formats = [
"%d-%b-%Y %H:%M:%S", "%d-%b-%Y %H:%M:%S",
"%d-%b-%Y %H:%M",
"%d-%b-%Y", "%d-%b-%Y",
"%d-%b-%y %H:%M:%S", "%d-%b-%y %H:%M:%S",
"%d-%b-%y %H:%M",
"%d-%b-%y", "%d-%b-%y",
"%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S",
"%Y-%m-%d %H:%M",
"%Y-%m-%d", "%Y-%m-%d",
"%d/%m/%Y %H:%M:%S", "%d/%m/%Y %H:%M:%S",
"%d/%m/%Y %H:%M",
"%d/%m/%Y", "%d/%m/%Y",
"%m/%d/%Y %H:%M:%S", "%m/%d/%Y %H:%M:%S",
"%m/%d/%Y %H:%M",
"%m/%d/%Y", "%m/%d/%Y",
] ]

View File

@ -3,6 +3,7 @@ Physicians Forms
Forms for doctor rating imports and filtering. Forms for doctor rating imports and filtering.
""" """
from django import forms from django import forms
from apps.organizations.models import Hospital from apps.organizations.models import Hospital
@ -12,113 +13,146 @@ from apps.core.form_mixins import HospitalFieldMixin
class DoctorRatingImportForm(HospitalFieldMixin, forms.Form): class DoctorRatingImportForm(HospitalFieldMixin, forms.Form):
""" """
Form for importing doctor ratings from CSV. Form for importing doctor ratings from CSV.
Hospital field visibility: Hospital field visibility:
- PX Admins: See dropdown with all hospitals - PX Admins: See dropdown with all hospitals
- Others: Hidden field, auto-set to user's hospital - Others: Hidden field, auto-set to user's hospital
""" """
hospital = forms.ModelChoiceField( hospital = forms.ModelChoiceField(
queryset=Hospital.objects.filter(status='active'), queryset=Hospital.objects.filter(status="active"),
label="Hospital", label="Hospital",
help_text="Select the hospital these ratings belong to" help_text="Select the hospital these ratings belong to",
) )
csv_file = forms.FileField( csv_file = forms.FileField(
label="CSV File", label="CSV File",
help_text="Upload the Doctor Rating Report CSV file", help_text="Upload the Doctor Rating Report CSV file",
widget=forms.FileInput(attrs={'accept': '.csv'}) widget=forms.FileInput(attrs={"accept": ".csv"}),
) )
skip_header_rows = forms.IntegerField( skip_header_rows = forms.IntegerField(
label="Skip Header Rows", label="Skip Header Rows",
initial=6, initial=6,
min_value=0, min_value=0,
max_value=20, max_value=20,
help_text="Number of rows to skip before the column headers (Doctor Rating Report typically has 6 header rows)" help_text="Number of rows to skip before the column headers (Doctor Rating Report typically has 6 header rows)",
) )
def clean_csv_file(self): def clean_csv_file(self):
csv_file = self.cleaned_data['csv_file'] csv_file = self.cleaned_data["csv_file"]
# Check file extension # Check file extension
if not csv_file.name.endswith('.csv'): if not csv_file.name.endswith(".csv"):
raise forms.ValidationError("File must be a CSV file (.csv extension)") raise forms.ValidationError("File must be a CSV file (.csv extension)")
# Check file size (max 10MB) # Check file size (max 10MB)
if csv_file.size > 10 * 1024 * 1024: if csv_file.size > 10 * 1024 * 1024:
raise forms.ValidationError("File size must be less than 10MB") raise forms.ValidationError("File size must be less than 10MB")
return csv_file return csv_file
class DoctorRatingFetchForm(HospitalFieldMixin, forms.Form):
"""
Form for fetching doctor ratings from HIS API by date range.
Hospital field visibility:
- PX Admins: See dropdown with all hospitals
- Others: Hidden field, auto-set to user's hospital
"""
hospital = forms.ModelChoiceField(
queryset=Hospital.objects.filter(status="active"),
label="Hospital",
help_text="Select the hospital for tracking the import job",
)
from_date = forms.DateField(
label="From Date",
widget=forms.DateInput(
attrs={
"type": "date",
"class": "w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy/20 focus:border-navy outline-none transition",
}
),
help_text="Start date for fetching ratings",
)
to_date = forms.DateField(
label="To Date",
widget=forms.DateInput(
attrs={
"type": "date",
"class": "w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy/20 focus:border-navy outline-none transition",
}
),
help_text="End date for fetching ratings",
)
def clean(self):
cleaned_data = super().clean()
from_date = cleaned_data.get("from_date")
to_date = cleaned_data.get("to_date")
if from_date and to_date and from_date > to_date:
raise forms.ValidationError("From date must be before or equal to to date.")
return cleaned_data
class DoctorRatingFilterForm(forms.Form): class DoctorRatingFilterForm(forms.Form):
""" """
Form for filtering individual doctor ratings. Form for filtering individual doctor ratings.
""" """
hospital = forms.ModelChoiceField( hospital = forms.ModelChoiceField(
queryset=Hospital.objects.filter(status='active'), queryset=Hospital.objects.filter(status="active"), required=False, label="Hospital"
required=False,
label="Hospital"
) )
doctor_id = forms.CharField( doctor_id = forms.CharField(
required=False, required=False, label="Doctor ID", widget=forms.TextInput(attrs={"placeholder": "e.g., 10738"})
label="Doctor ID",
widget=forms.TextInput(attrs={'placeholder': 'e.g., 10738'})
) )
doctor_name = forms.CharField( doctor_name = forms.CharField(
required=False, required=False, label="Doctor Name", widget=forms.TextInput(attrs={"placeholder": "Search by doctor name"})
label="Doctor Name",
widget=forms.TextInput(attrs={'placeholder': 'Search by doctor name'})
) )
rating_min = forms.IntegerField( rating_min = forms.IntegerField(
required=False, required=False,
min_value=1, min_value=1,
max_value=5, max_value=5,
label="Min Rating", label="Min Rating",
widget=forms.NumberInput(attrs={'placeholder': '1-5'}) widget=forms.NumberInput(attrs={"placeholder": "1-5"}),
) )
rating_max = forms.IntegerField( rating_max = forms.IntegerField(
required=False, required=False,
min_value=1, min_value=1,
max_value=5, max_value=5,
label="Max Rating", label="Max Rating",
widget=forms.NumberInput(attrs={'placeholder': '1-5'}) widget=forms.NumberInput(attrs={"placeholder": "1-5"}),
) )
date_from = forms.DateField( date_from = forms.DateField(required=False, label="From Date", widget=forms.DateInput(attrs={"type": "date"}))
required=False,
label="From Date", date_to = forms.DateField(required=False, label="To Date", widget=forms.DateInput(attrs={"type": "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( source = forms.ChoiceField(
required=False, required=False,
label="Source", label="Source",
choices=[('', 'All Sources')] + [ choices=[("", "All Sources")]
('his_api', 'HIS API'), + [("his_api", "HIS API"), ("csv_import", "CSV Import"), ("manual", "Manual Entry")],
('csv_import', 'CSV Import'),
('manual', 'Manual Entry')
]
) )
def __init__(self, user, *args, **kwargs): def __init__(self, user, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Filter hospital choices based on user role # Filter hospital choices based on user role
if user.is_px_admin(): if user.is_px_admin():
self.fields['hospital'].queryset = Hospital.objects.filter(status='active') self.fields["hospital"].queryset = Hospital.objects.filter(status="active")
elif user.hospital: elif user.hospital:
self.fields['hospital'].queryset = Hospital.objects.filter(id=user.hospital.id) self.fields["hospital"].queryset = Hospital.objects.filter(id=user.hospital.id)
self.fields['hospital'].initial = user.hospital self.fields["hospital"].initial = user.hospital
else: else:
self.fields['hospital'].queryset = Hospital.objects.none() self.fields["hospital"].queryset = Hospital.objects.none()

View File

@ -21,9 +21,9 @@ from apps.core.services import AuditService
from apps.organizations.models import Hospital from apps.organizations.models import Hospital
from .adapter import DoctorRatingAdapter from .adapter import DoctorRatingAdapter
from .forms import DoctorRatingImportForm from .forms import DoctorRatingFetchForm, DoctorRatingImportForm
from .models import DoctorRatingImportJob, PhysicianIndividualRating from .models import DoctorRatingImportJob, PhysicianIndividualRating
from .tasks import process_doctor_rating_job from .tasks import _fetch_and_process_his_doctor_ratings, process_doctor_rating_job
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -226,6 +226,82 @@ def doctor_rating_import(request):
return render(request, "physicians/doctor_rating_import.html", context) return render(request, "physicians/doctor_rating_import.html", context)
@login_required
def doctor_rating_fetch(request):
"""
Fetch doctor ratings from HIS API by date range.
Runs the fetch synchronously for immediate feedback, then redirects
to the job status page (which will already show completed results).
"""
user = request.user
if not user.is_px_admin() and not user.is_hospital_admin():
messages.error(request, "You don't have permission to fetch doctor ratings.")
return redirect("physicians:physician_list")
if request.method == "POST":
form = DoctorRatingFetchForm(request.POST, request=request)
if form.is_valid():
try:
hospital = form.cleaned_data["hospital"]
from_date = form.cleaned_data["from_date"]
to_date = form.cleaned_data["to_date"]
date_label = f"{from_date.isoformat()} to {to_date.isoformat()}"
job = DoctorRatingImportJob.objects.create(
name=f"HIS Fetch - {date_label}",
status=DoctorRatingImportJob.JobStatus.PENDING,
source=DoctorRatingImportJob.JobSource.HIS_API,
created_by=user,
hospital=hospital,
)
AuditService.log_event(
event_type="doctor_rating_his_fetch",
description=f"Fetching HIS ratings for {date_label}",
user=user,
metadata={
"job_id": str(job.id),
"hospital": hospital.name,
"from_date": from_date.isoformat(),
"to_date": to_date.isoformat(),
},
)
result = _fetch_and_process_his_doctor_ratings(str(job.id), from_date.isoformat(), to_date.isoformat())
if result.get("success"):
total = result.get("total_ratings", 0)
if total == 0:
messages.info(request, f"No ratings found for {date_label}.")
else:
messages.success(
request,
f"Fetched {total} ratings from HIS: "
f"{result.get('success_count', 0)} imported, "
f"{result.get('duplicate_count', 0)} duplicates, "
f"{result.get('failed_count', 0)} failed.",
)
else:
messages.error(request, f"HIS fetch failed: {result.get('error', 'Unknown error')}")
return redirect("physicians:doctor_rating_job_status", job_id=job.id)
except Exception as e:
logger.error(f"Error fetching HIS ratings: {str(e)}", exc_info=True)
messages.error(request, f"Error: {str(e)}")
else:
form = DoctorRatingFetchForm(request=request)
context = {
"form": form,
}
return render(request, "physicians/doctor_rating_fetch.html", context)
@login_required @login_required
def doctor_rating_review(request): def doctor_rating_review(request):
""" """

View File

@ -1,7 +1,7 @@
""" """
Management command to import doctor ratings from HIS API. Management command to import doctor ratings from HIS API.
This command fetches doctor ratings from the HIS FetchDoctorRatingMAPI1 endpoint This command fetches doctor ratings from the HIS FetchDoctorRatingMAPI endpoint
and imports them into the system. It supports importing for specific months, and imports them into the system. It supports importing for specific months,
multiple months, or full historical data. multiple months, or full historical data.

View File

@ -8,6 +8,7 @@ Background tasks for:
""" """
import logging import logging
from typing import Dict
from celery import shared_task from celery import shared_task
from django.utils import timezone from django.utils import timezone
@ -249,84 +250,81 @@ def cleanup_old_import_jobs(days: int = 30):
return {"cleaned_count": count} return {"cleaned_count": count}
@shared_task(bind=True, max_retries=3, default_retry_delay=300) def _fetch_and_process_his_doctor_ratings(job_id: str, from_date_iso: str, to_date_iso: str) -> Dict:
def fetch_his_doctor_ratings_monthly(self):
""" """
Monthly task to fetch doctor ratings from HIS API. Core logic to fetch and process HIS doctor ratings.
Runs on the 1st of each month to fetch the previous month's ratings. Can be called synchronously (from a view) or wrapped in a Celery task.
Example: On March 1st, fetches all ratings from February 1-28/29.
This task runs at 1:00 AM on the 1st of each month, before the
aggregation task which runs at 2:00 AM.
""" """
from datetime import datetime from datetime import datetime
from calendar import monthrange
from apps.integrations.services.his_client import HISClient
try: try:
# Calculate previous month job = DoctorRatingImportJob.objects.get(id=job_id)
now = timezone.now() except DoctorRatingImportJob.DoesNotExist:
if now.month == 1: logger.error(f"Doctor rating import job {job_id} not found")
target_year = now.year - 1 return {"error": "Job not found"}
target_month = 12
else:
target_year = now.year
target_month = now.month - 1
month_label = f"{target_year}-{target_month:02d}" try:
logger.info(f"Starting monthly HIS doctor rating fetch for {month_label}") from_date = datetime.fromisoformat(from_date_iso)
to_date = datetime.fromisoformat(to_date_iso)
if to_date.hour == 0 and to_date.minute == 0 and to_date.second == 0:
to_date = to_date.replace(hour=23, minute=59, second=59)
# Calculate date range for the month date_label = f"{from_date_iso} to {to_date_iso}"
from_date = datetime(target_year, target_month, 1) logger.info(f"Starting HIS doctor rating fetch for {date_label}")
last_day = monthrange(target_year, target_month)[1]
to_date = datetime(target_year, target_month, last_day, 23, 59, 59)
# Initialize HIS client job.status = DoctorRatingImportJob.JobStatus.PROCESSING
from apps.integrations.services.his_client import HISClient job.started_at = timezone.now()
job.save()
client = HISClient() client = HISClient()
# Fetch ratings from HIS
his_data = client.fetch_doctor_ratings(from_date, to_date) his_data = client.fetch_doctor_ratings(from_date, to_date)
if not his_data: if not his_data:
job.status = DoctorRatingImportJob.JobStatus.FAILED
job.error_message = "Failed to fetch data from HIS API"
job.completed_at = timezone.now()
job.save()
logger.error("Failed to fetch data from HIS API") logger.error("Failed to fetch data from HIS API")
return {"success": False, "error": "Failed to fetch data from HIS API", "month": month_label} return {"success": False, "error": "Failed to fetch data from HIS API", "date_range": date_label}
if his_data.get("Code") != 200: if his_data.get("Code") != 200:
error_msg = his_data.get("Message", "Unknown error") error_msg = his_data.get("Message", "Unknown error")
job.status = DoctorRatingImportJob.JobStatus.FAILED
job.error_message = f"HIS API error: {error_msg}"
job.completed_at = timezone.now()
job.save()
logger.error(f"HIS API error: {error_msg}") logger.error(f"HIS API error: {error_msg}")
return {"success": False, "error": f"HIS API error: {error_msg}", "month": month_label} return {"success": False, "error": f"HIS API error: {error_msg}", "date_range": date_label}
ratings_list = his_data.get("FetchDoctorRatingMAPI1List", []) ratings_list = his_data.get("FetchDoctorRatingMAPI1List", [])
if not ratings_list: if not ratings_list:
logger.info(f"No ratings found for {month_label}") logger.info(
f"HIS returned no ratings. Response keys: {list(his_data.keys())}, Code: {his_data.get('Code')}, Message: {his_data.get('Message')}"
)
logger.info(f"Full HIS response: {his_data}")
job.status = DoctorRatingImportJob.JobStatus.COMPLETED
job.total_records = 0
job.processed_count = 0
job.completed_at = timezone.now()
job.results = {"stats": {"total": 0, "success": 0, "failed": 0, "duplicates": 0, "staff_matched": 0}}
job.save()
logger.info(f"No ratings found for {date_label}")
return { return {
"success": True, "success": True,
"month": month_label, "date_range": date_label,
"total_ratings": 0, "total_ratings": 0,
"message": "No ratings found for this period", "message": "No ratings found for this period",
} }
logger.info(f"Fetched {len(ratings_list)} ratings from HIS for {month_label}") logger.info(f"Fetched {len(ratings_list)} ratings from HIS for {date_label}")
# Create import job for tracking job.total_records = len(ratings_list)
first_hospital = Hospital.objects.first() job.save()
if first_hospital:
job = DoctorRatingImportJob.objects.create(
name=f"Monthly HIS Import - {month_label}",
status=DoctorRatingImportJob.JobStatus.PROCESSING,
source=DoctorRatingImportJob.JobSource.HIS_API,
hospital=first_hospital,
total_records=len(ratings_list),
started_at=timezone.now(),
)
else:
job = None
logger.warning("No hospitals found, creating ratings without import job")
# Process ratings
stats = { stats = {
"total": len(ratings_list), "total": len(ratings_list),
"success": 0, "success": 0,
@ -337,7 +335,6 @@ def fetch_his_doctor_ratings_monthly(self):
for idx, rating_data in enumerate(ratings_list, 1): for idx, rating_data in enumerate(ratings_list, 1):
try: try:
# Find hospital by name
hospital_name = rating_data.get("HospitalName", "") hospital_name = rating_data.get("HospitalName", "")
hospital = Hospital.objects.filter(name__iexact=hospital_name).first() hospital = Hospital.objects.filter(name__iexact=hospital_name).first()
@ -349,7 +346,6 @@ def fetch_his_doctor_ratings_monthly(self):
logger.warning(f"Hospital not found: {hospital_name}") logger.warning(f"Hospital not found: {hospital_name}")
continue continue
# Process the rating
result = DoctorRatingAdapter.process_his_rating_record(rating_data, hospital) result = DoctorRatingAdapter.process_his_rating_record(rating_data, hospital)
if result["is_duplicate"]: if result["is_duplicate"]:
@ -362,8 +358,7 @@ def fetch_his_doctor_ratings_monthly(self):
stats["failed"] += 1 stats["failed"] += 1
logger.warning(f"Failed to process rating: {result.get('message')}") logger.warning(f"Failed to process rating: {result.get('message')}")
# Update job progress every 100 records if idx % 100 == 0:
if job and idx % 100 == 0:
job.processed_count = idx job.processed_count = idx
job.success_count = stats["success"] job.success_count = stats["success"]
job.failed_count = stats["failed"] job.failed_count = stats["failed"]
@ -374,31 +369,29 @@ def fetch_his_doctor_ratings_monthly(self):
stats["failed"] += 1 stats["failed"] += 1
logger.error(f"Error processing rating {idx}: {e}", exc_info=True) logger.error(f"Error processing rating {idx}: {e}", exc_info=True)
# Finalize job job.processed_count = stats["total"]
if job: job.success_count = stats["success"]
job.processed_count = stats["total"] job.failed_count = stats["failed"]
job.success_count = stats["success"] job.completed_at = timezone.now()
job.failed_count = stats["failed"]
job.completed_at = timezone.now()
if stats["failed"] == 0: if stats["failed"] == 0:
job.status = DoctorRatingImportJob.JobStatus.COMPLETED job.status = DoctorRatingImportJob.JobStatus.COMPLETED
elif stats["success"] == 0: elif stats["success"] == 0:
job.status = DoctorRatingImportJob.JobStatus.FAILED job.status = DoctorRatingImportJob.JobStatus.FAILED
else: else:
job.status = DoctorRatingImportJob.JobStatus.PARTIAL job.status = DoctorRatingImportJob.JobStatus.PARTIAL
job.results = {"stats": stats} job.results = {"stats": stats}
job.save() job.save()
logger.info( logger.info(
f"Completed monthly HIS doctor rating fetch for {month_label}: " f"Completed HIS doctor rating fetch for {date_label}: "
f"{stats['success']} success, {stats['failed']} failed, {stats['duplicates']} duplicates" f"{stats['success']} success, {stats['failed']} failed, {stats['duplicates']} duplicates"
) )
return { return {
"success": True, "success": True,
"month": month_label, "date_range": date_label,
"total_ratings": stats["total"], "total_ratings": stats["total"],
"success_count": stats["success"], "success_count": stats["success"],
"failed_count": stats["failed"], "failed_count": stats["failed"],
@ -407,6 +400,117 @@ def fetch_his_doctor_ratings_monthly(self):
} }
except Exception as exc: except Exception as exc:
logger.error(f"Error in monthly HIS doctor rating fetch: {exc}", exc_info=True) logger.error(f"Error in HIS doctor rating fetch: {exc}", exc_info=True)
# Retry the task try:
job.status = DoctorRatingImportJob.JobStatus.FAILED
job.error_message = str(exc)
job.completed_at = timezone.now()
job.save()
except Exception:
pass
raise
@shared_task(bind=True, max_retries=3, default_retry_delay=300)
def fetch_his_doctor_ratings(self, job_id: str, from_date_iso: str, to_date_iso: str):
"""
Celery task wrapper for fetching and processing HIS doctor ratings.
Used by the monthly scheduled task. For manual UI fetches, the view
calls _fetch_and_process_his_doctor_ratings() directly.
"""
try:
return _fetch_and_process_his_doctor_ratings(job_id, from_date_iso, to_date_iso)
except Exception as exc:
raise self.retry(exc=exc)
@shared_task(bind=True, max_retries=3, default_retry_delay=300)
def fetch_his_doctor_ratings_monthly(self):
"""
Monthly task to fetch doctor ratings from HIS API.
Runs on the 1st of each month to fetch the previous month's ratings.
Example: On March 1st, fetches all ratings from February 1-28/29.
This task runs at 1:00 AM on the 1st of each month, before the
aggregation task which runs at 2:00 AM.
"""
from calendar import monthrange
from datetime import datetime
try:
now = timezone.now()
if now.month == 1:
target_year = now.year - 1
target_month = 12
else:
target_year = now.year
target_month = now.month - 1
month_label = f"{target_year}-{target_month:02d}"
logger.info(f"Starting monthly HIS doctor rating fetch for {month_label}")
from_date = datetime(target_year, target_month, 1)
last_day = monthrange(target_year, target_month)[1]
to_date = datetime(target_year, target_month, last_day)
first_hospital = Hospital.objects.first()
if not first_hospital:
logger.error("No hospitals found")
return {"success": False, "error": "No hospitals found"}
job = DoctorRatingImportJob.objects.create(
name=f"Monthly HIS Import - {month_label}",
status=DoctorRatingImportJob.JobStatus.PENDING,
source=DoctorRatingImportJob.JobSource.HIS_API,
hospital=first_hospital,
)
fetch_his_doctor_ratings.delay(str(job.id), from_date.date().isoformat(), to_date.date().isoformat())
return {"success": True, "job_id": str(job.id), "month": month_label}
except Exception as exc:
logger.error(f"Error in monthly HIS doctor rating fetch: {exc}", exc_info=True)
raise self.retry(exc=exc)
@shared_task(bind=True, max_retries=3, default_retry_delay=300)
def fetch_his_doctor_ratings_daily(self):
"""
Daily task to fetch doctor ratings from HIS API for yesterday.
Query window: FromDate=yesterday 00:00:00, ToDate=yesterday 23:59:59
e.g. FromDate=08-Apr-2026 00:00:00&ToDate=08-Apr-2026 23:59:59
"""
from datetime import datetime, timedelta
try:
yesterday = timezone.now().date() - timedelta(days=1)
from_date = datetime.combine(yesterday, datetime.min.time())
to_date = datetime(yesterday.year, yesterday.month, yesterday.day, 23, 59, 59)
date_label = f"{from_date.date().isoformat()} to {to_date.date().isoformat()}"
logger.info(f"Starting daily HIS doctor rating fetch for {date_label}")
first_hospital = Hospital.objects.first()
if not first_hospital:
logger.error("No hospitals found")
return {"success": False, "error": "No hospitals found"}
job = DoctorRatingImportJob.objects.create(
name=f"Daily HIS Import - {yesterday.isoformat()}",
status=DoctorRatingImportJob.JobStatus.PENDING,
source=DoctorRatingImportJob.JobSource.HIS_API,
hospital=first_hospital,
)
fetch_his_doctor_ratings.delay(str(job.id), from_date.isoformat(), to_date.isoformat())
return {"success": True, "job_id": str(job.id), "date": yesterday.isoformat()}
except Exception as exc:
logger.error(f"Error in daily HIS doctor rating fetch: {exc}", exc_info=True)
raise self.retry(exc=exc) raise self.retry(exc=exc)

View File

@ -34,6 +34,8 @@ urlpatterns = [
path("individual-ratings/", import_views.individual_ratings_list, name="individual_ratings_list"), path("individual-ratings/", import_views.individual_ratings_list, name="individual_ratings_list"),
# Doctor Rating Import (CSV Upload) # Doctor Rating Import (CSV Upload)
path("import/", import_views.doctor_rating_import, name="doctor_rating_import"), path("import/", import_views.doctor_rating_import, name="doctor_rating_import"),
# Doctor Rating Fetch (HIS API)
path("fetch/", import_views.doctor_rating_fetch, name="doctor_rating_fetch"),
path("import/review/", import_views.doctor_rating_review, name="doctor_rating_review"), path("import/review/", import_views.doctor_rating_review, name="doctor_rating_review"),
path("import/jobs/", import_views.doctor_rating_job_list, name="doctor_rating_job_list"), path("import/jobs/", import_views.doctor_rating_job_list, name="doctor_rating_job_list"),
path("import/jobs/<uuid:job_id>/", import_views.doctor_rating_job_status, name="doctor_rating_job_status"), path("import/jobs/<uuid:job_id>/", import_views.doctor_rating_job_status, name="doctor_rating_job_status"),

View File

@ -99,17 +99,31 @@ def source_detail(request, pk):
from datetime import timedelta from datetime import timedelta
cutoff = timezone.now() - timedelta(days=30) cutoff = timezone.now() - timedelta(days=30)
recent_usage = usage_stats_queryset.filter(created_at__gte=cutoff)
from django.contrib.contenttypes.models import ContentType
complaint_ct = ContentType.objects.get(app_label="complaints", model="complaint")
inquiry_ct = ContentType.objects.get(app_label="complaints", model="inquiry")
usage_stats = { usage_stats = {
"total": usage_stats_queryset.count(), "total": usage_stats_queryset.count(),
"recent": usage_stats_queryset.filter(created_at__gte=cutoff).count(), "recent": recent_usage.count(),
"complaints": recent_usage.filter(content_type=complaint_ct).count(),
"inquiries": recent_usage.filter(content_type=inquiry_ct).count(),
} }
source_complaints = source.complaints.select_related("hospital", "department").order_by("-created_at")[:50]
source_inquiries = source.inquiries.select_related("hospital", "department").order_by("-created_at")[:50]
context = { context = {
"source": source, "source": source,
"usage_records": usage_records, "usage_records": usage_records,
"source_users": source_users, "source_users": source_users,
"available_users": available_users, "available_users": available_users,
"usage_stats": usage_stats, "usage_stats": usage_stats,
"source_complaints": source_complaints,
"source_inquiries": source_inquiries,
"complaints_count": source.complaints.count(),
"inquiries_count": source.inquiries.count(),
} }
return render(request, "px_sources/source_detail.html", context) return render(request, "px_sources/source_detail.html", context)

View File

@ -214,7 +214,11 @@ def survey_instance_detail(request, pk):
@login_required @login_required
def survey_template_list(request): def survey_template_list(request):
"""Survey templates list view""" """Survey templates list view"""
queryset = SurveyTemplate.objects.select_related("hospital").prefetch_related("questions") queryset = (
SurveyTemplate.objects.select_related("hospital")
.prefetch_related("questions")
.annotate(questions_count=Count("questions"))
)
user = request.user user = request.user
if user.is_px_admin(): if user.is_px_admin():

View File

@ -29,17 +29,12 @@ app.autodiscover_tasks()
# Celery Beat schedule for periodic tasks # Celery Beat schedule for periodic tasks
app.conf.beat_schedule = { app.conf.beat_schedule = {
# Process unprocessed integration events every 1 minute # Fetch patient data from HIS every 25 minutes
"process-integration-events": {
"task": "apps.integrations.tasks.process_pending_events",
"schedule": crontab(minute="*/1"),
},
# Fetch patient data from HIS every 5 minutes
"fetch-his-surveys": { "fetch-his-surveys": {
"task": "apps.integrations.tasks.fetch_his_surveys", "task": "apps.integrations.tasks.fetch_his_surveys",
"schedule": crontab(minute="*/5"), "schedule": crontab(minute="*/25"),
"options": { "options": {
"expires": 300, "expires": 1500,
}, },
}, },
# TEST TASK - Fetch from JSON file (uncomment for testing, remove when done) # TEST TASK - Fetch from JSON file (uncomment for testing, remove when done)
@ -117,6 +112,11 @@ app.conf.beat_schedule = {
"task": "apps.physicians.tasks.fetch_his_doctor_ratings_monthly", "task": "apps.physicians.tasks.fetch_his_doctor_ratings_monthly",
"schedule": crontab(hour=1, minute=0, day_of_month=1), "schedule": crontab(hour=1, minute=0, day_of_month=1),
}, },
# Fetch doctor ratings from HIS daily at 1:30 AM (yesterday's ratings)
"fetch-his-doctor-ratings-daily": {
"task": "apps.physicians.tasks.fetch_his_doctor_ratings_daily",
"schedule": crontab(hour=1, minute=30),
},
# Calculate physician monthly ratings on the 1st of each month at 2 AM # Calculate physician monthly ratings on the 1st of each month at 2 AM
"calculate-physician-ratings": { "calculate-physician-ratings": {
"task": "apps.physicians.tasks.calculate_monthly_ratings", "task": "apps.physicians.tasks.calculate_monthly_ratings",
@ -183,10 +183,10 @@ app.conf.beat_schedule = {
"task": "apps.surveys.tasks.process_survey_text_analysis", "task": "apps.surveys.tasks.process_survey_text_analysis",
"schedule": crontab(minute="*/30"), "schedule": crontab(minute="*/30"),
}, },
# Pre-compute analytics dashboard cache every 5 minutes # Pre-compute analytics dashboard cache daily at 3 AM
"precompute-analytics-cache": { "precompute-analytics-cache": {
"task": "apps.analytics.tasks.precompute_dashboard_cache_task", "task": "apps.analytics.tasks.precompute_dashboard_cache_task",
"schedule": crontab(minute="*/5"), "schedule": crontab(hour=3, minute=0),
}, },
# Generate AI executive summary daily at 6 AM # Generate AI executive summary daily at 6 AM
"generate-daily-executive-summary": { "generate-daily-executive-summary": {

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -1,58 +1,141 @@
httpx==0.28.1 aiohappyeyeballs==2.6.1
aiohttp==3.13.3
aiohttp-retry==2.9.1
aiosignal==1.4.0
amqp==5.3.1 amqp==5.3.1
annotated-types==0.7.0
anyio==4.12.0
asgiref==3.11.0 asgiref==3.11.0
attrs==25.4.0
billiard==4.2.4 billiard==4.2.4
brotli==1.2.0
cachetools==6.2.4
celery==5.6.2 celery==5.6.2
certifi==2026.1.4 certifi==2026.1.4
cffi==2.0.0
charset-normalizer==3.4.4 charset-normalizer==3.4.4
click==8.3.1 click==8.3.1
click-didyoumean==0.3.1 click-didyoumean==0.3.1
click-plugins==1.1.1.2 click-plugins==1.1.1.2
click-repl==0.3.0 click-repl==0.3.0
cron_descriptor==2.0.6 cron-descriptor==1.4.5
Django==5.2.10 cssselect2==0.8.0
django-celery-beat==2.8.1 distro==1.9.0
django-crontab==0.7.1 django==6.0.1
django-celery-beat==2.9.0
django-environ==0.12.0
django-extensions==4.1 django-extensions==4.1
django-filter==25.1
django-stubs==5.2.8
django-stubs-ext==5.2.8
django-timezone-field==7.2.1 django-timezone-field==7.2.1
djangorestframework==3.16.1
djangorestframework-simplejwt==5.5.1
djangorestframework-stubs==3.16.6
drf-spectacular==0.29.0
et-xmlfile==2.0.0
fastuuid==0.14.0
filelock==3.20.2
fonttools==4.61.1
frozenlist==1.8.0
fsspec==2025.12.0
google-api-core==2.29.0 google-api-core==2.29.0
google-api-python-client==2.188.0 google-api-python-client==2.187.0
google-auth==2.47.0 google-auth==2.41.1
google-auth-httplib2==0.3.0 google-auth-httplib2==0.3.0
google-auth-oauthlib==1.2.4 google-auth-oauthlib==1.2.3
googleapis-common-protos==1.72.0 googleapis-common-protos==1.72.0
httplib2==0.31.2 grpcio==1.67.1
gunicorn==23.0.0
h11==0.16.0
hf-xet==1.2.0
httpcore==1.0.9
httplib2==0.31.0
httpx==0.28.1
huggingface-hub==1.2.3
idna==3.11 idna==3.11
importlib-metadata==8.7.1
inflection==0.5.1
jinja2==3.1.6
jiter==0.12.0
jsonschema==4.25.1
jsonschema-specifications==2025.9.1
kombu==5.6.2 kombu==5.6.2
litellm==1.80.11
markdown-it-py==4.0.0
markupsafe==3.0.3
mdurl==0.1.2
multidict==6.7.0
numpy==2.4.3
oauthlib==3.3.1 oauthlib==3.3.1
packaging==26.0 openai==2.14.0
prompt_toolkit==3.0.52 openpyxl==3.1.5
packaging==25.0
pandas==3.0.1
pillow==12.1.0
pip==24.0
polib==1.2.0
prompt-toolkit==3.0.52
propcache==0.4.1
proto-plus==1.27.0 proto-plus==1.27.0
protobuf==6.33.4 protobuf==6.33.3
pyasn1==0.6.2 psycopg2-binary==2.9.11
pyasn1_modules==0.4.2 -e file:///home/ismail/projects/HH
pyparsing==3.3.2 pyasn1==0.6.1
pyasn1-modules==0.4.2
pycparser==2.23
pydantic==2.12.5
pydantic-core==2.41.5
pydyf==0.12.1
pygments==2.19.2
pyjwt==2.10.1
pyparsing==3.3.1
pyphen==0.17.2
python-crontab==3.3.0 python-crontab==3.3.0
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
python-dotenv==1.2.1
pytz==2025.2
pyyaml==6.0.3
redis==7.1.0 redis==7.1.0
referencing==0.37.0
regex==2025.11.3
reportlab==4.4.7
requests==2.32.5 requests==2.32.5
requests-oauthlib==2.0.0 requests-oauthlib==2.0.0
rich==14.2.0
rpds-py==0.30.0
rsa==4.9.1 rsa==4.9.1
shellingham==1.5.4
six==1.17.0 six==1.17.0
sniffio==1.3.1
sqlparse==0.5.5 sqlparse==0.5.5
stack-data==0.6.3 tiktoken==0.12.0
traitlets==5.14.3 tinycss2==1.5.1
trio==0.32.0 tinyhtml5==2.0.0
trio-websocket==0.12.2 tokenizers==0.22.2
tqdm==4.67.1
tweepy==4.16.0 tweepy==4.16.0
twilio==9.10.3 twilio==9.10.3
types-PyYAML==6.0.12.20250915 typer-slim==0.21.0
types-pyyaml==6.0.12.20250915
types-requests==2.32.4.20250913 types-requests==2.32.4.20250913
typing_extensions==4.15.0 typing-extensions==4.15.0
typing-inspection==0.4.2
tzdata==2025.3 tzdata==2025.3
tzlocal==5.3.1 tzlocal==5.3.1
ua-parser==1.0.1
ua-parser-builtins==202601
unidecode==1.4.0
uritemplate==4.2.0 uritemplate==4.2.0
urllib3==2.6.3 urllib3==2.6.2
user-agents==2.2.0
vine==5.1.0 vine==5.1.0
wcwidth==0.3.1 watchdog==6.0.0
rich==13.9.4 wcwidth==0.2.14
weasyprint==67.0
webencodings==0.5.1
whitenoise==6.11.0
xlrd==2.0.2
yarl==1.22.0
zipp==3.23.0
zopfli==0.4.0

View File

@ -2,137 +2,48 @@
{% load i18n %} {% load i18n %}
{% block title %}{% trans "Welcome to PX360 - Al Hammadi Hospital" %}{% endblock %} {% block title %}{% trans "Welcome to PX360 - Al Hammadi Hospital" %}{% endblock %}
{% block preheader %}{% trans "You have been invited to join PX360. Complete your account setup." %}{% endblock %} {% block preheader %}{% trans "You have been invited to join PX360. Complete your account setup." %}{% endblock %}
{% block hero_title %}{% trans "Welcome to PX360!" %}{% endblock %}
{% block hero_subtitle %}{% trans "Your comprehensive Patient Experience management platform" %}{% endblock %}
{% block content %} {% block content %}
<!-- Greeting --> <h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">{% trans "Welcome to PX360!" %}</h1>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding-bottom: 20px;">
<p style="margin: 0; font-size: 16px; color: #1e293b; line-height: 1.6;">
{% blocktrans with name=user.first_name|default:user.email %}Hello <strong>{{ name }}</strong>,{% endblocktrans %}
</p>
<p style="margin: 15px 0 0 0; font-size: 16px; color: #64748b; line-height: 1.6;">
{% trans "You have been invited to join PX360, our comprehensive Patient Experience management platform. To complete your account setup, please click the button below." %}
</p>
</td>
</tr>
</table>
<!-- What You'll Do --> <p style="margin: 0 0 15px; font-size: 16px; color: #1e293b; line-height: 1.6;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-top: 25px;"> {% blocktrans with name=user.first_name|default:user.email %}Hello <strong>{{ name }}</strong>,{% endblocktrans %}
<tr> </p>
<td> <p style="margin: 0 0 20px; font-size: 16px; color: #475569; line-height: 1.6;">
<h3 style="margin: 0 0 15px 0; font-size: 18px; font-weight: 600; color: #005696;"> {% trans "You have been invited to join PX360, our comprehensive Patient Experience management platform. To complete your account setup, please click the button below." %}
{% trans "During the onboarding process, you will:" %} </p>
</h3>
</td> <!-- Onboarding Steps -->
</tr> <div style="margin-bottom: 25px;">
<tr> <p style="margin: 0 0 12px; font-size: 15px; font-weight: 600; color: #005696;">{% trans "During onboarding, you will:" %}</p>
<td> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<!-- Item 1 --> <tr><td style="padding: 6px 0; font-size: 15px; color: #475569;">✓ {% trans "Learn about PX360 features and your role" %}</td></tr>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;"> <tr><td style="padding: 6px 0; font-size: 15px; color: #475569;">✓ {% trans "Set up your profile and preferences" %}</td></tr>
<tr> <tr><td style="padding: 6px 0; font-size: 15px; color: #475569;">✓ {% trans "Complete required training" %}</td></tr>
<td width="40" valign="top" style="padding-right: 10px;"> <tr><td style="padding: 6px 0; font-size: 15px; color: #475569;">✓ {% trans "Activate your account" %}</td></tr>
<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> </table>
</td> </div>
<td>
<p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
{% trans "Learn about PX360 features and your role responsibilities" %}
</p>
</td>
</tr>
</table>
<!-- Item 2 -->
<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>
<!-- CTA Button --> <!-- CTA Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-top: 30px;"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr> <tr>
<td align="center"> <td align="center">
<table role="presentation" cellspacing="0" cellpadding="0" border="0"> <a href="{{ invitation_url }}"
<tr> style="display: inline-block; padding: 12px 32px; font-size: 16px; font-weight: 600; color: #ffffff; text-decoration: none; border-radius: 6px; background-color: #005696;">
<td align="center" style="border-radius: 6px;" bgcolor="#005696"> {% trans "Complete Account Setup" %}
<a href="{{ invitation_url }}" target="_blank" </a>
style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 600; color: #ffffff; text-decoration: none; border-radius: 6px; background-color: #005696;">
{% trans "Complete Account Setup" %}
</a>
</td>
</tr>
</table>
</td> </td>
</tr> </tr>
</table> </table>
<!-- Security Notice --> <div style="padding: 15px; background-color: #fef3c7; border-radius: 6px; margin-top: 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-top: 30px;"> <p style="margin: 0; font-size: 14px; color: #92400e; line-height: 1.5;">
<tr> <strong>{% trans "Important:" %}</strong> {% trans "This invitation link will expire in 7 days." %}
<td style="background-color: #fffbeb; border-left: 4px solid #f59e0b; padding: 15px; border-radius: 4px;"> </p>
<p style="margin: 0; font-size: 14px; color: #92400e; line-height: 1.5;"> </div>
<strong>{% trans "Important:" %}</strong> {% trans "This invitation link will expire in 7 days. If you don't complete the setup within this period, you'll need to request a new invitation." %}
</p>
</td>
</tr>
</table>
<!-- Help Section --> <p style="margin: 25px 0 0; font-size: 13px; color: #94a3b8; line-height: 1.5;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-top: 25px;"> {% trans "Need help? Contact support@alhammadi.com or call +966 11 123 4567." %}
<tr> </p>
<td style="border-top: 1px solid #e2e8f0; padding-top: 20px;">
<p style="margin: 0; font-size: 14px; color: #64748b; line-height: 1.6;">
{% trans "If you have any questions or need assistance, please contact our support team at" %}
<a href="mailto:support@alhammadi.com" style="color: #005696; text-decoration: none;">support@alhammadi.com</a>
{% trans "or call us at" %} <strong>+966 11 123 4567</strong>.
</p>
</td>
</tr>
</table>
{% endblock %} {% endblock %}

View File

@ -12,64 +12,99 @@
<!-- Header --> <!-- Header -->
<div class="text-center mb-8"> <div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-20 h-20 bg-navy rounded-full mb-6"> <div class="inline-flex items-center justify-center w-20 h-20 bg-navy rounded-full mb-6">
{% if current_content.icon %}
<i data-lucide="{{ current_content.icon }}" class="w-10 h-10 text-white"></i>
{% else %}
<i data-lucide="book-open" class="w-10 h-10 text-white"></i> <i data-lucide="book-open" class="w-10 h-10 text-white"></i>
{% endif %}
</div> </div>
<h1 class="text-xl md:text-2xl font-bold text-navy mb-1"> <h1 class="text-xl md:text-2xl font-bold text-navy mb-1">
{% trans "Review Onboarding Material" %} {{ current_content.get_localized_title }}
</h1> </h1>
{% if current_content.get_localized_description %}
<p class="text-sm text-slate"> <p class="text-sm text-slate">
{% trans "Please review the following important information" %} {{ current_content.get_localized_description }}
</p> </p>
{% endif %}
</div> </div>
<!-- Progress --> <!-- Progress -->
<div class="mb-8"> <div class="mb-8">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-500">{% trans "Step 3 of 3" %}</span> <span class="text-sm font-medium text-gray-500">{% trans "Step" %} {{ step }} {% trans "of" %} {{ content|length }}</span>
<span class="text-sm font-medium text-navy">100%</span> <span class="text-sm font-medium text-navy">{{ progress_percentage }}%</span>
</div> </div>
<div class="w-full bg-gray-200 h-2 rounded-full overflow-hidden"> <div class="w-full bg-gray-200 h-2.5 rounded-full overflow-hidden">
<div class="bg-navy h-full w-full"></div> <div class="bg-navy h-full rounded-full transition-all duration-500" style="width: {{ progress_percentage }}%"></div>
</div> </div>
</div> </div>
<!-- Content --> <!-- Step Indicators -->
<div class="space-y-6 mb-8"> <div class="flex items-center justify-center gap-2 mb-8">
{% for content in content_items %} {% for item in content %}
<div class="bg-slate-50 rounded-xl p-6 border border-slate-200"> <div class="flex items-center">
<div class="flex items-start gap-4 mb-4"> {% if item.id == current_content.id %}
<div class="flex items-center justify-center w-12 h-12 bg-light rounded-xl flex-shrink-0"> <div class="w-8 h-8 rounded-full bg-navy text-white flex items-center justify-center text-sm font-bold">
<i data-lucide="file-text" class="w-6 h-6 text-navy"></i> {{ forloop.counter }}
</div>
<div class="flex-1">
<h3 class="text-xl font-bold text-gray-800 mb-2">{{ content.title }}</h3>
<div class="prose prose-sm text-gray-600">
{{ content.body|safe }}
</div>
</div>
</div> </div>
{% elif forloop.counter0 < step|add:"-1" %}
<div class="w-8 h-8 rounded-full bg-green-500 text-white flex items-center justify-center">
<i data-lucide="check" class="w-4 h-4"></i>
</div>
{% else %}
<div class="w-8 h-8 rounded-full bg-gray-200 text-gray-400 flex items-center justify-center text-sm font-bold">
{{ forloop.counter }}
</div>
{% endif %}
{% if not forloop.last %}
<div class="w-8 h-0.5 {% if forloop.counter < step %}bg-green-500{% else %}bg-gray-200{% endif %}"></div>
{% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<!-- Content Body -->
<div class="space-y-6 mb-8">
<div class="bg-slate-50 rounded-xl p-6 border border-slate-200">
<div class="prose prose-sm max-w-none text-gray-600">
{{ current_content.get_localized_content|safe }}
</div>
</div>
</div>
<!-- Confirmation --> <!-- Confirmation -->
<div class="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-6"> <div class="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-6">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<input type="checkbox" id="confirm_reviewed" name="confirm_reviewed" required <input type="checkbox" id="confirm_reviewed" name="confirm_reviewed" required
class="w-5 h-5 mt-0.5 text-blue-500 border-gray-300 rounded focus:ring-blue-500"> class="w-5 h-5 mt-0.5 text-blue-500 border-gray-300 rounded focus:ring-blue-500">
<label for="confirm_reviewed" class="text-sm text-blue-700"> <label for="confirm_reviewed" class="text-sm text-blue-700">
<span class="font-bold">{% trans "I have reviewed all the onboarding material above" %}</span> <span class="font-bold">{% trans "I have reviewed the onboarding material above" %}</span>
</label> </label>
</div> </div>
</div> </div>
<!-- Actions --> <!-- Actions -->
<form method="post"> <form method="post" id="stepForm">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="w-full bg-navy text-white px-6 py-4 rounded-xl font-bold hover:bg-blue transition shadow-lg"> <input type="hidden" name="step" value="{{ step }}">
{% trans "Complete Onboarding" %} <div class="flex gap-3">
<i data-lucide="check-circle" class="w-5 h-5 inline ml-2"></i> {% if previous_step %}
</button> <a href="/accounts/onboarding/wizard/step/{{ previous_step }}/" class="px-6 py-4 border-2 border-gray-200 rounded-xl font-medium text-gray-600 hover:bg-gray-50 transition">
{% trans "Previous" %}
</a>
{% endif %}
{% if next_step %}
<button type="submit" class="flex-1 bg-navy text-white px-6 py-4 rounded-xl font-bold hover:bg-blue transition shadow-lg">
{% trans "Next Step" %}
<i data-lucide="arrow-right" class="w-5 h-5 inline ml-2"></i>
</button>
{% else %}
<a href="/accounts/onboarding/wizard/checklist/" class="flex-1 bg-navy text-white px-6 py-4 rounded-xl font-bold text-center hover:bg-blue transition shadow-lg block">
{% trans "Continue to Checklist" %}
<i data-lucide="clipboard-check" class="w-5 h-5 inline ml-2"></i>
</a>
{% endif %}
</div>
</form> </form>
</div> </div>
</div> </div>
@ -78,6 +113,16 @@
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons(); lucide.createIcons();
document.getElementById('stepForm').addEventListener('submit', function(e) {
const checkbox = document.getElementById('confirm_reviewed');
if (!checkbox.checked) {
e.preventDefault();
checkbox.focus();
checkbox.classList.add('ring-2', 'ring-red-300');
setTimeout(() => checkbox.classList.remove('ring-2', 'ring-red-300'), 2000);
}
});
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -826,9 +826,32 @@
function refreshDashboard() { function refreshDashboard() {
const icon = document.querySelector('[data-lucide="refresh-cw"]'); const icon = document.querySelector('[data-lucide="refresh-cw"]');
icon.classList.add('animate-spin'); icon.classList.add('animate-spin');
setTimeout(() => {
window.location.reload(); // Call API to trigger cache refresh
}, 500); fetch('{% url "analytics:refresh_dashboard_cache" %}', {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token }}',
'Content-Type': 'application/json',
},
})
.then(r => r.json())
.then(data => {
showToast(data.message || 'Dashboard cache refresh triggered');
// Reload after a short delay to get fresh data
setTimeout(() => {
window.location.reload();
}, 2000);
})
.catch(err => {
console.error('Failed to trigger dashboard refresh:', err);
showToast('Failed to trigger dashboard refresh');
icon.classList.remove('animate-spin');
// Fallback: just reload the page
setTimeout(() => {
window.location.reload();
}, 500);
});
} }
function refreshAiAnalytics() { function refreshAiAnalytics() {

View File

@ -357,7 +357,7 @@ document.addEventListener('DOMContentLoaded', function() {
<small class="text-muted"> <small class="text-muted">
MRN: ${patient.mrn} | MRN: ${patient.mrn} |
Phone: ${patient.phone || 'N/A'} | Phone: ${patient.phone || 'N/A'} |
ID: ${patient.national_id || 'N/A'} ID: ${patient.national_id_masked || 'N/A'}
</small> </small>
</div> </div>
<div> <div>

View File

@ -302,7 +302,7 @@ document.addEventListener('DOMContentLoaded', function() {
<small class="text-muted"> <small class="text-muted">
MRN: ${patient.mrn} | MRN: ${patient.mrn} |
Phone: ${patient.phone || 'N/A'} | Phone: ${patient.phone || 'N/A'} |
ID: ${patient.national_id || 'N/A'} ID: ${patient.national_id_masked || 'N/A'}
</small> </small>
</div> </div>
<div> <div>

View File

@ -799,6 +799,74 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
} }
// Location hierarchy cascading dropdowns
if (locationSelect) {
locationSelect.addEventListener('change', function () {
const locationId = this.value;
if (!locationId) {
if (mainSectionSelect) mainSectionSelect.innerHTML = '<option value="">{% trans "Select Section" %}</option>';
if (subsectionSelect) subsectionSelect.innerHTML = '<option value="">{% trans "Select Subsection" %}</option>';
return;
}
if (mainSectionSelect) {
mainSectionSelect.innerHTML = '<option value="">{% trans "Loading..." %}</option>';
}
fetch('{% url "organizations:ajax_main_sections" %}?location_id=' + locationId)
.then(r => r.json())
.then(data => {
const sections = data.sections || [];
if (mainSectionSelect) {
mainSectionSelect.innerHTML = '<option value="">{% trans "Select Section" %}</option>';
sections.forEach(section => {
const opt = document.createElement('option');
opt.value = section.id;
opt.textContent = {% if LANG == 'ar' %}section.name_ar || section.name{% else %}section.name{% endif %};
mainSectionSelect.appendChild(opt);
});
}
if (subsectionSelect) {
subsectionSelect.innerHTML = '<option value="">{% trans "Select Subsection" %}</option>';
}
})
.catch(err => {
console.error('Failed to load main sections:', err);
if (mainSectionSelect) mainSectionSelect.innerHTML = '<option value="">{% trans "Error loading sections" %}</option>';
});
});
}
if (mainSectionSelect) {
mainSectionSelect.addEventListener('change', function () {
const locationId = locationSelect ? locationSelect.value : '';
const mainSectionId = this.value;
if (!locationId || !mainSectionId) {
if (subsectionSelect) subsectionSelect.innerHTML = '<option value="">{% trans "Select Subsection" %}</option>';
return;
}
if (subsectionSelect) {
subsectionSelect.innerHTML = '<option value="">{% trans "Loading..." %}</option>';
}
fetch('{% url "organizations:ajax_subsections" %}?location_id=' + locationId + '&main_section_id=' + mainSectionId)
.then(r => r.json())
.then(data => {
const subsections = data.subsections || [];
if (subsectionSelect) {
subsectionSelect.innerHTML = '<option value="">{% trans "Select Subsection" %}</option>';
subsections.forEach(sub => {
const opt = document.createElement('option');
opt.value = sub.internal_id || sub.id;
opt.textContent = {% if LANG == 'ar' %}sub.name_ar || sub.name{% else %}sub.name{% endif %};
subsectionSelect.appendChild(opt);
});
}
})
.catch(err => {
console.error('Failed to load subsections:', err);
if (subsectionSelect) subsectionSelect.innerHTML = '<option value="">{% trans "Error loading subsections" %}</option>';
});
});
}
// Complaint type selection // Complaint type selection
complaintTypeCards.forEach(card => { complaintTypeCards.forEach(card => {
card.addEventListener('click', function() { card.addEventListener('click', function() {
@ -807,6 +875,7 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
this.classList.add('active'); this.classList.add('active');
complaintTypeInput.value = this.dataset.value; complaintTypeInput.value = this.dataset.value;
});
}); });
// Patient auto-lookup by national_id // Patient auto-lookup by national_id

View File

@ -138,22 +138,45 @@
<div> <div>
<label class="form-label"> <label class="form-label">
{% trans "PX Admin User" %} <span class="text-red-500">*</span> {% trans "On-Call User" %} <span class="text-red-500">*</span>
</label> </label>
<select name="admin_user" required class="form-select"> <select name="admin_user" required class="form-select">
<option value="">{% trans "Select an admin..." %}</option> <option value="">{% trans "Select a user..." %}</option>
{% for admin in available_admins %} {% if available_px_admins %}
<option value="{{ admin.id }}"> <optgroup label="{% trans 'PX Admins' %}">
{{ admin.get_full_name|default:admin.email }} ({{ admin.email }}) {% for admin in available_px_admins %}
</option> <option value="{{ admin.id }}">
{% empty %} {{ admin.get_full_name|default:admin.email }} ({{ admin.email }})
<option value="" disabled>{% trans "No available PX Admins" %}</option> </option>
{% endfor %} {% endfor %}
</optgroup>
{% endif %}
{% if available_coordinators %}
<optgroup label="{% trans 'PX Coordinators' %}">
{% for admin in available_coordinators %}
<option value="{{ admin.id }}">
{{ admin.get_full_name|default:admin.email }} ({{ admin.email }})
</option>
{% endfor %}
</optgroup>
{% endif %}
{% if available_hospital_admins %}
<optgroup label="{% trans 'Hospital Admins' %}">
{% for admin in available_hospital_admins %}
<option value="{{ admin.id }}">
{{ admin.get_full_name|default:admin.email }} ({{ admin.email }})
</option>
{% endfor %}
</optgroup>
{% endif %}
{% if not available_admins %}
<option value="" disabled>{% trans "No available users" %}</option>
{% endif %}
</select> </select>
{% if not available_admins %} {% if not available_admins %}
<p class="mt-2 text-sm text-amber-600 flex items-center gap-2"> <p class="mt-2 text-sm text-amber-600 flex items-center gap-2">
<i data-lucide="alert-triangle" class="w-4 h-4"></i> <i data-lucide="alert-triangle" class="w-4 h-4"></i>
{% trans "All PX Admins are already assigned to this schedule." %} {% trans "All eligible users are already assigned to this schedule." %}
</p> </p>
{% endif %} {% endif %}
</div> </div>

View File

@ -5,14 +5,16 @@
<h3 class="text-xl font-bold text-navy flex items-center gap-2"> <h3 class="text-xl font-bold text-navy flex items-center gap-2">
<i data-lucide="bot" class="w-6 h-6"></i> {% trans "AI Analysis" %} <i data-lucide="bot" class="w-6 h-6"></i> {% trans "AI Analysis" %}
</h3> </h3>
{% if not complaint.emotion and not complaint.short_description_en and not complaint.suggested_actions %}
{% if user.is_px_admin or user.is_hospital_admin %} {% if user.is_px_admin or user.is_hospital_admin %}
<button id="reanalyzeBtn" onclick="reanalyzeComplaintAI()" class="inline-flex items-center gap-1 px-3 py-1.5 bg-white border border-slate-200 rounded-lg text-xs font-semibold text-navy hover:bg-slate-50 transition"> <button id="reanalyzeBtn" onclick="reanalyzeComplaintAI()" class="inline-flex items-center gap-1 px-3 py-1.5 bg-white border border-slate-200 rounded-lg text-xs font-semibold text-navy hover:bg-slate-50 transition">
<i data-lucide="refresh-cw" class="w-3 h-3"></i> <i data-lucide="refresh-cw" class="w-3 h-3"></i>
{% trans "Analyze" %} {% if complaint.emotion or complaint.short_description_en or complaint.suggested_actions %}
{% trans "Reanalyze" %}
{% else %}
{% trans "Analyze" %}
{% endif %}
</button> </button>
{% endif %} {% endif %}
{% endif %}
</div> </div>
{% if complaint.emotion %} {% if complaint.emotion %}
@ -118,12 +120,9 @@
{% if not complaint.emotion and not complaint.short_description_en and not complaint.suggested_actions %} {% if not complaint.emotion and not complaint.short_description_en and not complaint.suggested_actions %}
<div id="aiEmptyState" class="text-center py-12"> <div id="aiEmptyState" class="text-center py-12">
<i data-lucide="bot" class="w-16 h-16 mx-auto text-slate-300 mb-4"></i> <i data-lucide="bot" class="w-16 h-16 mx-auto text-slate-300 mb-4"></i>
<p class="text-slate mb-4">{% trans "No AI analysis available for this complaint" %}</p> <p class="text-slate">{% trans "No AI analysis available for this complaint" %}</p>
{% if user.is_px_admin or user.is_hospital_admin %} {% if user.is_px_admin or user.is_hospital_admin %}
<button onclick="reanalyzeComplaintAI()" class="inline-flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-blue to-navy text-white rounded-lg font-semibold hover:from-navy hover:to-blue transition text-sm shadow-lg"> <p class="text-slate text-sm mt-1">{% trans "Click \"Analyze\" above to run AI analysis" %}</p>
<i data-lucide="sparkles" class="w-4 h-4"></i>
{% trans "Run AI Analysis" %}
</button>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
@ -189,7 +188,7 @@ function reanalyzeComplaintAI() {
}) })
.then(data => { .then(data => {
if (data.success) { if (data.success) {
let html = '<div class="flex items-center justify-between mb-6"><h3 class="text-xl font-bold text-navy flex items-center gap-2"><i data-lucide="bot" class="w-6 h-6"></i> {% trans "AI Analysis" %}</h3><span class="text-xs text-green-600 font-semibold">{% trans "Analysis complete" %}</span></div>'; let html = '<div class="flex items-center justify-between mb-6"><h3 class="text-xl font-bold text-navy flex items-center gap-2"><i data-lucide="bot" class="w-6 h-6"></i> {% trans "AI Analysis" %}</h3><div class="flex items-center gap-3"><span class="text-xs text-green-600 font-semibold">{% trans "Analysis complete" %}</span><button id="reanalyzeBtn" onclick="reanalyzeComplaintAI()" class="inline-flex items-center gap-1 px-3 py-1.5 bg-white border border-slate-200 rounded-lg text-xs font-semibold text-navy hover:bg-slate-50 transition"><i data-lucide="refresh-cw" class="w-3 h-3"></i> {% trans "Reanalyze" %}</button></div></div>';
if (data.emotion && data.emotion !== 'neutral') { if (data.emotion && data.emotion !== 'neutral') {
html += '<div class="bg-gradient-to-r from-light to-blue-50 border border-blue-200 rounded-2xl p-6 mb-6">'; html += '<div class="bg-gradient-to-r from-light to-blue-50 border border-blue-200 rounded-2xl p-6 mb-6">';

View File

@ -59,7 +59,7 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="min-h-screen bg-gradient-to-br from-light via-blue-50 to-light py-12 px-4 sm:px-6 lg:px-8"> <div class="min-h-screen py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-3xl mx-auto"> <div class="max-w-3xl mx-auto">
<!-- Success Icon Animation --> <!-- Success Icon Animation -->
<div class="text-center mb-8"> <div class="text-center mb-8">
@ -184,7 +184,7 @@
<!-- Footer Note --> <!-- Footer Note -->
<div class="text-center mt-8"> <div class="text-center mt-8">
<p class="text-slate text-sm"> <p class="text-white/70 text-sm">
{% trans "Your feedback helps us improve our services" %} {% trans "Your feedback helps us improve our services" %}
<i data-lucide="heart" class="w-4 h-4 inline-block text-red-500 mx-1"></i> <i data-lucide="heart" class="w-4 h-4 inline-block text-red-500 mx-1"></i>
</p> </p>

View File

@ -9,11 +9,6 @@ header.glass-card {
display: none !important; display: none !important;
} }
body.public-bg {
background: linear-gradient(to bottom right, #005696, #007bbd, #eef6fb) !important;
min-height: 100vh;
}
.lang-switcher { .lang-switcher {
position: fixed; position: fixed;
top: 1rem; top: 1rem;
@ -166,21 +161,22 @@ body.public-bg {
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="text-right hidden md:block"> <div class="text-right hidden md:block">
<span class="text-xs font-bold text-slate/40 uppercase tracking-widest block mb-1">{% trans "Current Status" %}</span> <span class="text-xs font-bold text-slate/40 uppercase tracking-widest block mb-1">{% trans "Current Status" %}</span>
<p class="font-bold text-navy">{{ complaint.get_status_display }}</p> <p class="font-bold text-navy">{{ public_status.label }}</p>
</div> </div>
<div class="px-6 py-3 rounded-2xl text-sm font-black uppercase tracking-wider shadow-sm border-b-4 <div class="px-6 py-3 rounded-2xl text-sm font-black uppercase tracking-wider shadow-sm border-b-4
{% if complaint.status == 'open' %}bg-amber-50 text-amber-700 border-amber-200 {% if public_status.css == 'amber' %}bg-amber-50 text-amber-700 border-amber-200
{% elif complaint.status == 'in_progress' %}bg-blue-50 text-blue-700 border-blue-200 {% elif public_status.css == 'blue' %}bg-blue-50 text-blue-700 border-blue-200
{% elif complaint.status == 'resolved' %}bg-emerald-50 text-emerald-700 border-emerald-200 {% elif public_status.css == 'emerald' %}bg-emerald-50 text-emerald-700 border-emerald-200
{% elif public_status.css == 'rose' %}bg-rose-50 text-rose-700 border-rose-200
{% else %}bg-slate-50 text-slate-700 border-slate-200{% endif %}"> {% else %}bg-slate-50 text-slate-700 border-slate-200{% endif %}">
{{ complaint.status }} {{ public_status.label }}
</div> </div>
</div> </div>
</div> </div>
<div class="mt-8 h-2 w-full bg-slate-100 rounded-full overflow-hidden"> <div class="mt-8 h-2 w-full bg-slate-100 rounded-full overflow-hidden">
<div class="h-full bg-navy transition-all duration-1000" <div class="h-full bg-navy transition-all duration-1000"
style="width: {% if complaint.status == 'resolved' %}100%{% elif complaint.status == 'in_progress' %}50%{% else %}15%{% endif %}"> style="width: {{ public_status.progress }}%">
</div> </div>
</div> </div>
</div> </div>

View File

@ -39,7 +39,7 @@
</div> </div>
<!-- Escalation Rules --> <!-- Escalation Rules -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6 text-center card-hover"> {% comment %} <div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6 text-center card-hover">
<div class="w-16 h-16 mx-auto mb-4 bg-orange-100 rounded-2xl flex items-center justify-center"> <div class="w-16 h-16 mx-auto mb-4 bg-orange-100 rounded-2xl flex items-center justify-center">
<i data-lucide="trending-up" class="w-8 h-8 text-orange-600"></i> <i data-lucide="trending-up" class="w-8 h-8 text-orange-600"></i>
</div> </div>
@ -62,7 +62,7 @@
<i data-lucide="settings-2" class="w-4 h-4"></i> <i data-lucide="settings-2" class="w-4 h-4"></i>
{% trans "Manage Thresholds" %} {% trans "Manage Thresholds" %}
</a> </a>
</div> </div> {% endcomment %}
<!-- On-Call Schedules --> <!-- On-Call Schedules -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6 text-center card-hover"> <div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6 text-center card-hover">
@ -80,7 +80,7 @@
</section> </section>
<!-- PX Actions Section --> <!-- PX Actions Section -->
<section class="mb-8"> {% comment %} <section class="mb-8">
<h2 class="text-lg font-bold text-navy mb-4 flex items-center gap-2"> <h2 class="text-lg font-bold text-navy mb-4 flex items-center gap-2">
<i data-lucide="zap" class="w-5 h-5 text-yellow-500"></i> <i data-lucide="zap" class="w-5 h-5 text-yellow-500"></i>
{% trans "PX Actions" %} {% trans "PX Actions" %}
@ -112,7 +112,7 @@
</a> </a>
</div> </div>
</div> </div>
</section> </section> {% endcomment %}
<!-- User Management Section --> <!-- User Management Section -->
<section class="mb-8"> <section class="mb-8">
@ -127,8 +127,8 @@
<i data-lucide="users" class="w-8 h-8 text-green-600"></i> <i data-lucide="users" class="w-8 h-8 text-green-600"></i>
</div> </div>
<h3 class="font-bold text-navy text-lg mb-2">{% trans "Users" %}</h3> <h3 class="font-bold text-navy text-lg mb-2">{% trans "Users" %}</h3>
<p class="text-sm text-slate mb-4">{% trans "Manage system users and permissions" %}</p> <p class="text-sm text-slate mb-4">{{ active_users_count }} {% trans "active users" %}</p>
<a href="/admin/accounts/user/" class="inline-flex items-center gap-2 bg-navy text-white px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg shadow-navy/20 hover:bg-blue transition"> <a href="{% url 'config:hospital_users_list' %}" class="inline-flex items-center gap-2 bg-navy text-white px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg shadow-navy/20 hover:bg-blue transition">
<i data-lucide="user-cog" class="w-4 h-4"></i> <i data-lucide="user-cog" class="w-4 h-4"></i>
{% trans "Manage Users" %} {% trans "Manage Users" %}
</a> </a>
@ -170,7 +170,7 @@
</div> </div>
<!-- Hospital Notifications --> <!-- Hospital Notifications -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6 text-center card-hover"> {% comment %} <div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6 text-center card-hover">
<div class="w-16 h-16 mx-auto mb-4 bg-pink-100 rounded-2xl flex items-center justify-center"> <div class="w-16 h-16 mx-auto mb-4 bg-pink-100 rounded-2xl flex items-center justify-center">
<i data-lucide="bell-ring" class="w-8 h-8 text-pink-600"></i> <i data-lucide="bell-ring" class="w-8 h-8 text-pink-600"></i>
</div> </div>
@ -180,7 +180,7 @@
<i data-lucide="bell" class="w-4 h-4"></i> <i data-lucide="bell" class="w-4 h-4"></i>
{% trans "Configure" %} {% trans "Configure" %}
</a> </a>
</div> </div> {% endcomment %}
<!-- Send SMS --> <!-- Send SMS -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6 text-center card-hover"> <div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6 text-center card-hover">

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

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

View File

@ -10,12 +10,6 @@ header.glass-card {
display: none !important; display: none !important;
} }
/* Match login page background */
body.public-bg {
background: linear-gradient(to bottom right, #005696, #007bbd, #eef6fb) !important;
min-height: 100vh;
}
/* Language switcher styles */ /* Language switcher styles */
.lang-switcher { .lang-switcher {
position: fixed; position: fixed;

View File

@ -12,198 +12,69 @@
<meta name="supported-color-schemes" content="light"> <meta name="supported-color-schemes" content="light">
<title>{% block title %}Al Hammadi Hospital{% endblock %}</title> <title>{% block title %}Al Hammadi Hospital{% endblock %}</title>
<style> <style>
/* Reset Styles */ /* Reset */
html, body { margin: 0; padding: 0; width: 100% !important; height: 100% !important; } html, body { margin: 0; padding: 0; width: 100% !important; height: 100% !important; }
* { -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; } * { -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; }
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; } table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { -ms-interpolation-mode: bicubic; border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; } img { -ms-interpolation-mode: bicubic; border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; }
/* Client-specific resets */
#outlook a { padding: 0; } #outlook a { padding: 0; }
.ExternalClass { width: 100%; } .ExternalClass { width: 100%; }
.ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div { line-height: 100%; } .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div { line-height: 100%; }
/* Body centering */
body, table, td { margin: 0 auto; } body, table, td { margin: 0 auto; }
/* Responsive */
/* Responsive styles */
@media only screen and (max-width: 600px) { @media only screen and (max-width: 600px) {
.email-container { width: 100% !important; max-width: 100% !important; } .email-container { width: 100% !important; }
.fluid { width: 100% !important; height: auto !important; }
.stack-column { display: block !important; width: 100% !important; padding-bottom: 20px; }
.padding-mobile { padding-left: 20px !important; padding-right: 20px !important; }
.heading-mobile { font-size: 22px !important; }
.button-full { width: 100% !important; display: block !important; }
.content-padding { padding-left: 20px !important; padding-right: 20px !important; } .content-padding { padding-left: 20px !important; padding-right: 20px !important; }
} }
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.bg-light { background-color: #1e293b !important; }
.text-dark { color: #f8fafc !important; }
}
</style> </style>
<!-- Block for additional custom styles -->
{% block extra_styles %}{% endblock %} {% block extra_styles %}{% endblock %}
</head> </head>
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;"> <body style="margin: 0; padding: 20px 0; background-color: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
<!-- Preheader Text (invisible preview text) --> <!-- Preheader -->
<div style="display: none; font-size: 1px; line-height: 1px; max-height: 0px; max-width: 0px; opacity: 0; overflow: hidden; mso-hide: all;"> <div style="display: none; font-size: 1px; line-height: 1px; max-height: 0px; max-width: 0px; opacity: 0; overflow: hidden; mso-hide: all;">
{% block preheader %}Al Hammadi Hospital - Patient Experience Management{% endblock %} {% block preheader %}Al Hammadi Hospital - Patient Experience Management{% endblock %}
</div> </div>
<!-- Email Container - Full width background --> <!-- Container -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f8fafc;"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f8fafc;">
<tr> <tr>
<td align="center" style="padding: 20px 10px;"> <td align="center" style="padding: 20px 10px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" class="email-container" style="width: 600px; max-width: 600px; background-color: #ffffff; border-radius: 8px;">
<!-- Main Email Wrapper - Centered with max-width -->
<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);"> <!-- Logo -->
<!-- Header Section -->
<tr> <tr>
<td style="background: linear-gradient(135deg, #005696 0%, #007bbd 100%); padding: 30px 40px; text-align: center;"> <td style="padding: 30px 40px 20px; text-align: center;">
<!-- Logo -->
{% email_logo_url as default_logo_url %} {% email_logo_url as default_logo_url %}
<a href="#" style="text-decoration: none;"> <img src="{{ logo_url|default:default_logo_url }}"
<img src="{{ logo_url|default:default_logo_url }}" alt="Al Hammadi Hospital"
alt="Al Hammadi Hospital" width="200"
width="400" style="display: block; margin: 0 auto; max-width: 200px; height: auto;">
height="120"
style="display: block; margin: 0 auto; max-width: 100%; font-family: sans-serif; color: #ffffff;">
</a>
</td> </td>
</tr> </tr>
<!-- Hero/Title Section --> <!-- Content -->
{% block hero %}
<tr> <tr>
<td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #ffffff;"> <td class="content-padding" style="padding: 0 40px 40px;">
<h1 class="heading-mobile" style="margin: 0 0 10px 0; font-size: 26px; font-weight: 700; color: #005696; line-height: 1.4;"> {% block content %}{% endblock %}
{% block hero_title %}Welcome to Al Hammadi Hospital{% endblock %}
</h1>
<p style="margin: 0; font-size: 16px; color: #64748b; line-height: 1.6;">
{% block hero_subtitle %}Your health and satisfaction are our priority{% endblock %}
</p>
</td> </td>
</tr> </tr>
{% endblock %}
<!-- Footer -->
<!-- Main Content Section -->
<tr> <tr>
<td class="padding-mobile" style="padding: 0 40px 30px 40px; background-color: #ffffff;"> <td style="padding: 20px 40px; border-top: 1px solid #e2e8f0; text-align: center;">
{% block content %} <p style="margin: 0; font-size: 12px; color: #64748b; line-height: 1.5;">
<!-- Content goes here --> {% block footer %}
{% endblock %}
</td>
</tr>
<!-- Call-to-Action Section -->
{% block cta_section %}
<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 %}
&copy; {% now "Y" %} {% trans "Al Hammadi Hospital. All rights reserved." %} &copy; {% now "Y" %} {% trans "Al Hammadi Hospital. All rights reserved." %}
{% endblock %} {% endblock %}
</p> </p>
<!-- Powered by -->
<p style="margin: 8px 0 0 0; font-size: 11px; color: #eef6fb;">
Powered by <a href="https://tenhal.sa" style="color: #ffffff; text-decoration: underline;">tenhal.sa</a>
</p>
<!-- Unsubscribe -->
{% block unsubscribe %}
<p style="margin: 15px 0 0 0; font-size: 12px; color: #eef6fb;">
<a href="{{ unsubscribe_url|default:'#' }}" style="color: #eef6fb; text-decoration: underline;">{% trans "Unsubscribe" %}</a> {% trans "from these emails" %}
</p>
{% endblock %}
</td> </td>
</tr> </tr>
</table> </table>
<!-- End Main Email Wrapper -->
</td> </td>
</tr> </tr>
</table> </table>
<!-- End Email Container -->
</body> </body>
</html> </html>

View File

@ -2,162 +2,79 @@
{% load i18n %} {% load i18n %}
{% block title %}{% trans "Explanation Request - Al Hammadi Hospital" %}{% endblock %} {% block title %}{% trans "Explanation Request - Al Hammadi Hospital" %}{% endblock %}
{% block preheader %}{% trans "You have been assigned to provide an explanation for a patient complaint" %}{% endblock %} {% block preheader %}{% trans "You have been assigned to provide an explanation for a patient complaint" %}{% endblock %}
{% block hero_title %}{% trans "Explanation Request" %}{% endblock %}
{% block hero_subtitle %}{% trans "Please review the complaint details and submit your response" %}{% endblock %}
{% block content %} {% block content %}
<!-- Greeting --> <h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">{% trans "Explanation Request" %}</h1>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr> <p style="margin: 0 0 15px; font-size: 16px; color: #1e293b; line-height: 1.6;">
<td style="padding-bottom: 20px;"> {% trans "Dear" %} <strong>{{ staff_name }}</strong>,
<p style="margin: 0; font-size: 16px; color: #1e293b; line-height: 1.6;"> </p>
{% trans "Dear" %} <strong>{{ staff_name }}</strong>, <p style="margin: 0 0 20px; font-size: 16px; color: #475569; line-height: 1.6;">
</p> {% trans "You have been assigned to provide an explanation for the following patient complaint. Please review the details and submit your response using the button below." %}
<p style="margin: 15px 0 0 0; font-size: 16px; color: #64748b; line-height: 1.6;"> </p>
{% trans "You have been assigned to provide an explanation for the following patient complaint. Please review the details and submit your response using the button below." %}
</p>
</td>
</tr>
</table>
{% if custom_message %} {% if custom_message %}
<!-- Custom Message --> <div style="padding: 15px; background-color: #eef6fb; border-radius: 6px; margin-bottom: 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 20px 0;"> <p style="margin: 0; font-size: 14px; color: #005696; line-height: 1.6;">
<tr> <strong>{% trans "Note from PX Team:" %}</strong> {{ custom_message }}
<td style="padding: 15px; background-color: #eef6fb; border-left: 4px solid #005696; border-radius: 5px;"> </p>
<p style="margin: 0 0 10px 0; font-size: 14px; font-weight: 600; color: #005696;"> </div>
{% trans "Note from PX Team:" %}
</p>
<p style="margin: 0; font-size: 14px; color: #1e293b; line-height: 1.6;">
{{ custom_message }}
</p>
</td>
</tr>
</table>
{% endif %} {% endif %}
<!-- Complaint Details Card --> <!-- Complaint Details -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 25px 0; background-color: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0;"> <div style="background-color: #f8fafc; border-radius: 6px; padding: 20px; margin-bottom: 25px;">
<tr> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<td style="padding: 20px;"> <tr>
<h3 style="margin: 0 0 15px 0; font-size: 18px; font-weight: 600; color: #005696; text-align: center;"> <td style="padding: 6px 0; font-size: 14px; color: #64748b;">
{% trans "Complaint Details" %} <strong style="color: #005696;">{% trans "Reference:" %}</strong> #{{ complaint_id }}
</h3> </td>
</tr>
<!-- Detail Row 1 --> <tr>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 10px;"> <td style="padding: 6px 0; font-size: 14px; color: #64748b;">
<tr> <strong style="color: #005696;">{% trans "Title:" %}</strong> {{ complaint_title }}
<td width="120" style="padding: 8px 0; font-size: 14px; color: #64748b; font-weight: 500;"> </td>
{% trans "Reference:" %} </tr>
</td> <tr>
<td style="padding: 8px 0; font-size: 14px; color: #1e293b; font-weight: 600;"> <td style="padding: 6px 0; font-size: 14px; color: #64748b;">
#{{ complaint_id }} <strong style="color: #005696;">{% trans "Patient:" %}</strong> {{ patient_name }}
</td> </td>
</tr> </tr>
<tr> <tr>
<td style="padding: 8px 0; font-size: 14px; color: #64748b; font-weight: 500;"> <td style="padding: 6px 0; font-size: 14px; color: #64748b;">
{% trans "Title:" %} <strong style="color: #005696;">{% trans "Department:" %}</strong> {{ department_name }}
</td> </td>
<td style="padding: 8px 0; font-size: 14px; color: #1e293b; font-weight: 600;"> </tr>
{{ complaint_title }} <tr>
</td> <td style="padding: 6px 0; font-size: 14px; color: #64748b;">
</tr> <strong style="color: #005696;">{% trans "Deadline:" %}</strong> {{ created_date }}
<tr> </td>
<td style="padding: 8px 0; font-size: 14px; color: #64748b; font-weight: 500;"> </tr>
{% trans "Patient:" %} {% if description %}
</td> <tr>
<td style="padding: 8px 0; font-size: 14px; color: #1e293b; font-weight: 600;"> <td style="padding: 6px 0; font-size: 14px; color: #64748b;">
{{ patient_name }} <strong style="color: #005696;">{% trans "Description:" %}</strong><br>
</td> {{ description }}
</tr> </td>
<tr> </tr>
<td style="padding: 8px 0; font-size: 14px; color: #64748b; font-weight: 500;"> {% endif %}
{% trans "Hospital:" %} </table>
</td> </div>
<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%"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr> <tr>
<td style="padding: 15px; background-color: #eef6fb; border-left: 4px solid #005696; border-radius: 8px;"> <td align="center">
<p style="margin: 0 0 10px 0; font-size: 14px; font-weight: 600; color: #005696;"> <a href="{{ explanation_url }}"
{% trans "Important Information:" %} style="display: inline-block; padding: 12px 32px; font-size: 16px; font-weight: 600; color: #ffffff; text-decoration: none; border-radius: 6px; background-color: #005696;">
</p> {% trans "Submit Your Explanation" %}
<ul style="margin: 0; padding-left: 20px; font-size: 14px; color: #1e293b; line-height: 1.8;"> </a>
<li>{% trans "This link is unique and can only be used once" %}</li>
<li>{% trans "You can attach supporting documents to your explanation" %}</li>
<li>{% trans "Your response will be reviewed by the PX team" %}</li>
<li>{% trans "Please submit your explanation at your earliest convenience" %}</li>
</ul>
</td> </td>
</tr> </tr>
</table> </table>
{% endblock %}
{% block cta_url %}{{ explanation_url }}{% endblock %} <p style="margin: 25px 0 0; font-size: 13px; color: #94a3b8; line-height: 1.5;">
{% block cta_text %}{% trans "Submit Your Explanation" %}{% endblock %} {% trans "If you have any questions, please contact the PX team." %}<br>
{% trans "This is an automated email. Please do not reply." %}
{% block info_title %}{% trans "Need Assistance?" %}{% endblock %} </p>
{% block info_content %}
{% trans "If you have any questions or concerns, please contact the PX team directly." %}<br>
<strong>{% trans "Note:" %}</strong> {% trans "This is an automated email. Please do not reply directly to this message." %}
{% endblock %}
{% block footer_address %}
PX360 Complaint Management System<br>
Al Hammadi Hospital
{% endblock %} {% endblock %}

View File

@ -1,154 +1,56 @@
{% extends 'emails/base_email_template.html' %} {% extends 'emails/base_email_template.html' %}
{% load i18n %} {% load i18n %}
{% block title %}{% trans "New Observation Notification - Al Hammadi Hospital" %}{% endblock %} {% block title %}{% trans "New Observation Submitted - Al Hammadi Hospital" %}{% endblock %}
{% block preheader %}{% trans "A new observation requires your review and triage" %}{% endblock %}
{% block preheader %}{% trans "A new observation has been submitted and requires review." %}{% endblock %}
{% block hero_title %}{% trans "New Observation Submitted" %}{% endblock %}
{% block hero_subtitle %}{% trans "A new observation requires your review and triage" %}{% endblock %}
{% block content %} {% block content %}
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"> <h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">{% trans "New Observation Submitted" %}</h1>
<tr>
<td style="padding-bottom: 20px;">
<p style="margin: 0 0 15px 0; font-size: 16px; color: #1e293b; line-height: 1.6;">
{% trans "Dear" %} <strong>{{ admin_name|default:'Admin' }}</strong>,
</p>
<p style="margin: 0; font-size: 16px; color: #64748b; line-height: 1.6;">
{% trans "A new observation has been submitted and requires your review. Please assess the details below and take appropriate action." %}
</p>
</td>
</tr>
</table>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 25px 0;"> <p style="margin: 0 0 15px; font-size: 16px; color: #1e293b; line-height: 1.6;">
<tr> {% trans "Dear" %} <strong>{{ recipient_name|default:'Colleague' }}</strong>,
<td style="padding: 20px; background-color: #f8fafc; border-left: 4px solid #005696; border-radius: 8px;"> </p>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"> <p style="margin: 0 0 20px; font-size: 16px; color: #475569; line-height: 1.6;">
<tr> {% trans "A new observation has been submitted and requires your review. Please review the details below." %}
<td style="padding: 8px 0;"> </p>
<p style="margin: 0; font-size: 14px; color: #64748b;">
<strong style="color: #005696;">{% trans "Tracking Code" %}:</strong> {{ observation.tracking_code }}
</p>
</td>
</tr>
{% if observation.title %}
<tr>
<td style="padding: 8px 0;">
<p style="margin: 0; font-size: 14px; color: #1e293b;">
<strong style="color: #005696;">{% trans "Title" %}:</strong> {{ observation.title }}
</p>
</td>
</tr>
{% endif %}
<tr>
<td style="padding: 8px 0;">
<p style="margin: 0; font-size: 14px; color: #64748b;">
<strong style="color: #005696;">{% trans "Category" %}:</strong>
{% if observation.category %}{{ observation.category.name_en }}{% else %}N/A{% endif %}
</p>
</td>
</tr>
<tr>
<td style="padding: 8px 0;">
<p style="margin: 0; font-size: 14px; color: #64748b;">
<strong style="color: #005696;">{% trans "Severity" %}:</strong>
<span style="color: #005696; font-weight: 600;">
{{ observation.get_severity_display }}
</span>
</p>
</td>
</tr>
<tr>
<td style="padding: 8px 0;">
<p style="margin: 0; font-size: 14px; color: #64748b;">
<strong style="color: #005696;">{% trans "Location" %}:</strong>
{{ observation.location_text|default:"N/A" }}
</p>
</td>
</tr>
{% if observation.hospital %}
<tr>
<td style="padding: 8px 0;">
<p style="margin: 0; font-size: 14px; color: #64748b;">
<strong style="color: #005696;">{% trans "Hospital" %}:</strong> {{ observation.hospital.name }}
</p>
</td>
</tr>
{% endif %}
<tr>
<td style="padding: 8px 0;">
<p style="margin: 0; font-size: 14px; color: #64748b;">
<strong style="color: #005696;">{% trans "Reporter" %}:</strong> {{ observation.reporter_display }}
</p>
</td>
</tr>
<tr>
<td style="padding: 8px 0;">
<p style="margin: 0; font-size: 14px; color: #64748b;">
<strong style="color: #005696;">{% trans "Submitted" %}:</strong>
{{ observation.created_at|date:"F d, Y H:i" }}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
{% if observation.description %} <!-- Observation Details -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 25px 0;"> <div style="background-color: #f8fafc; border-radius: 6px; padding: 20px; margin-bottom: 20px;">
<tr> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<td style="padding: 20px; background-color: #f8fafc; border-radius: 8px;"> <tr>
<p style="margin: 0 0 10px 0; font-size: 14px; font-weight: 600; color: #005696;"> <td style="padding: 6px 0; font-size: 14px; color: #64748b;">
{% trans "Description" %} <strong style="color: #005696;">{% trans "Tracking Code:" %}</strong> {{ observation.tracking_code }}
</p> </td>
<p style="margin: 0; font-size: 14px; color: #1e293b; line-height: 1.6; white-space: pre-wrap;"> </tr>
{{ observation.description|truncatechars:1000 }} {% if observation.title %}
</p> <tr>
</td> <td style="padding: 6px 0; font-size: 14px; color: #64748b;">
</tr> <strong style="color: #005696;">{% trans "Title:" %}</strong> {{ observation.title }}
</table> </td>
{% endif %} </tr>
{% endif %}
<tr>
<td style="padding: 6px 0; font-size: 14px; color: #64748b;">
<strong style="color: #005696;">{% trans "Category:" %}</strong>
{% if observation.category %}{{ observation.category.name_en }}{% else %}N/A{% endif %}
</td>
</tr>
<tr>
<td style="padding: 6px 0; font-size: 14px; color: #64748b;">
<strong style="color: #005696;">{% trans "Status:" %}</strong> {{ observation.get_status_display }}
</td>
</tr>
{% if observation.assigned_department %}
<tr>
<td style="padding: 6px 0; font-size: 14px; color: #64748b;">
<strong style="color: #005696;">{% trans "Department:" %}</strong> {{ observation.assigned_department.name }}
</td>
</tr>
{% endif %}
</table>
</div>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 25px 0;"> <p style="margin: 0; font-size: 13px; color: #94a3b8; line-height: 1.5;">
<tr> {% trans "This is an automated notification. Please log in to PX360 for full details." %}
<td style="padding: 15px 20px; background-color: #eef6fb; border-radius: 8px;"> </p>
<p style="margin: 0; font-size: 14px; color: #005696; line-height: 1.6;">
{% trans "Please review this observation and assign it to the appropriate team member for further action." %}
</p>
</td>
</tr>
</table>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-top: 25px;">
<tr>
<td style="padding: 10px 0; font-size: 11px; color: #94a3b8; border-top: 1px solid #e2e8f0; padding-top: 20px;">
<p style="margin: 0 0 5px 0; font-size: 13px; color: #64748b; font-weight: 600; direction: rtl; text-align: right;">
تم إرسال ملاحظة جديدة - {{ observation.tracking_code }}
</p>
<p style="margin: 0; font-size: 12px; color: #94a3b8; direction: rtl; text-align: right; line-height: 1.8;">
تم إرسال ملاحظة جديدة وتتطلب مراجعتكم. يرجى الاطلاع على التفاصيل أدناه واتخاذ الإجراء المناسب.<br>
{% if observation.category %}التصنيف: {{ observation.category.name_ar }}{% endif %}
</p>
</td>
</tr>
</table>
{% endblock %}
{% block cta_url %}{{ observation_url }}{% endblock %}
{% block cta_text %}{% trans "View Observation" %}{% endblock %}
{% block info_title %}{% trans "Notification Details" %}{% endblock %}
{% block info_content %}
<strong>{% trans "Type:" %}</strong> New Observation<br>
<strong>{% trans "Time:" %}</strong> {{ current_time }}<br>
{% trans "This is an automated notification from the PX 360 system." %}
{% endblock %}
{% block footer_address %}
PX360 Observation Management System<br>
Al Hammadi Hospital
{% endblock %} {% endblock %}

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

View File

@ -2,94 +2,42 @@
{% load i18n %} {% load i18n %}
{% block title %}{% trans "Patient Survey Invitation - Al Hammadi Hospital" %}{% endblock %} {% block title %}{% trans "Patient Survey Invitation - Al Hammadi Hospital" %}{% endblock %}
{% block preheader %}{% trans "We value your feedback! Please share your experience with us." %}{% endblock %} {% block preheader %}{% trans "We value your feedback! Please share your experience with us." %}{% endblock %}
{% block hero_title %}{% trans "We Value Your Feedback" %}{% endblock %}
{% block hero_subtitle %}{% trans "Help us improve our services by sharing your recent experience at Al Hammadi Hospital" %}{% endblock %}
{% block content %} {% block content %}
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #005696;">{% trans "We Value Your Feedback" %}</h1>
<p style="margin: 0 0 15px; font-size: 16px; color: #1e293b; line-height: 1.6;">
{% trans "Dear" %} <strong>{{ patient_name|default:_("Valued Patient") }}</strong>,
</p>
<p style="margin: 0 0 20px; font-size: 16px; color: #475569; line-height: 1.6;">
{% blocktrans with visit=visit_date|default:_("your recent visit") %}Thank you for choosing Al Hammadi Hospital for your healthcare needs. We hope your recent visit on {{ visit }} met your expectations.{% endblocktrans %}
</p>
<p style="margin: 0 0 25px; font-size: 16px; color: #475569; line-height: 1.6;">
{% trans "We would greatly appreciate it if you could take a few minutes to complete our satisfaction survey. Your feedback helps us improve our services." %}
</p>
<!-- Survey Info -->
<div style="background-color: #f8fafc; border-radius: 6px; padding: 15px; margin-bottom: 25px;">
<p style="margin: 0; font-size: 14px; color: #64748b; line-height: 1.6;">
⏱ {% trans "Takes only 3-5 minutes" %}<br>
🔒 {% trans "Your responses are confidential" %}
</p>
</div>
<!-- CTA Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr> <tr>
<td style="padding-bottom: 20px;"> <td align="center">
<p style="margin: 0; font-size: 16px; color: #1e293b; line-height: 1.6;"> <a href="{{ survey_url }}"
{% trans "Dear" %} <strong>{{ patient_name|default:_("Valued Patient") }}</strong>, style="display: inline-block; padding: 12px 32px; font-size: 16px; font-weight: 600; color: #ffffff; text-decoration: none; border-radius: 6px; background-color: #005696;">
</p> {% trans "Take Survey" %}
<p style="margin: 15px 0 0 0; font-size: 16px; color: #64748b; line-height: 1.6;"> </a>
{% blocktrans with visit=visit_date|default:_("your recent visit") %}Thank you for choosing Al Hammadi Hospital for your healthcare needs. We hope your recent visit on {{ visit }} met your expectations.{% endblocktrans %}
</p>
<p style="margin: 15px 0 0 0; font-size: 16px; color: #64748b; line-height: 1.6;">
{% blocktrans with duration=survey_duration|default:_("3-5") %}Your feedback is invaluable in helping us maintain and improve the quality of care we provide. Would you mind taking {{ duration }} minutes to complete our patient experience survey?{% endblocktrans %}
</p>
</td> </td>
</tr> </tr>
</table> </table>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-top: 25px;"> <p style="margin: 25px 0 0; font-size: 13px; color: #94a3b8; line-height: 1.5;">
<tr> {% trans "Thank you for your time and feedback." %}
<td> </p>
<h3 style="margin: 0 0 15px 0; font-size: 18px; font-weight: 600; color: #005696;">
{% trans "Why Your Feedback Matters:" %}
</h3>
</td>
</tr>
<tr>
<td>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;">
<tr>
<td width="40" valign="top" style="padding-right: 10px;">
<span style="display: inline-block; width: 32px; height: 32px; background-color: #eef6fb; border-radius: 50%; text-align: center; line-height: 32px; font-size: 18px;"></span>
</td>
<td>
<p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
<strong>{% trans "Improve Patient Care:" %}</strong> {% trans "Your insights help us enhance our services" %}
</p>
</td>
</tr>
</table>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;">
<tr>
<td width="40" valign="top" style="padding-right: 10px;">
<span style="display: inline-block; width: 32px; height: 32px; background-color: #eef6fb; border-radius: 50%; text-align: center; line-height: 32px; font-size: 18px;"></span>
</td>
<td>
<p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
<strong>{% trans "Better Experience:" %}</strong> {% trans "Help us create a better experience for all patients" %}
</p>
</td>
</tr>
</table>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td width="40" valign="top" style="padding-right: 10px;">
<span style="display: inline-block; width: 32px; height: 32px; background-color: #eef6fb; border-radius: 50%; text-align: center; line-height: 32px; font-size: 18px;"></span>
</td>
<td>
<p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
<strong>{% trans "Quality Standards:" %}</strong> {% trans "Contribute to our commitment to excellence" %}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
{% endblock %}
{% block cta_url %}{{ survey_link|default:'#' }}{% endblock %}
{% block cta_text %}{% trans "Start Survey" %}{% endblock %}
{% block info_title %}{% trans "Survey Information" %}{% endblock %}
{% block info_content %}
<strong>{% trans "Duration:" %}</strong> {% trans "Approximately" %} {{ survey_duration|default:_("3-5") }} {% trans "minutes" %}<br>
<strong>{% trans "Confidentiality:" %}</strong> {% trans "Your responses are completely confidential" %}<br>
<strong>{% trans "Deadline:" %}</strong> {% trans "Please complete by" %} {{ deadline|default:_("the end of this week") }}
{% endblock %}
{% block footer_address %}
{% trans "Patient Experience Management Department" %}<br>
{% trans "Al Hammadi Hospital" %}
{% endblock %} {% endblock %}

View File

@ -353,6 +353,11 @@
<span class="sidebar-text whitespace-nowrap">{% trans "Leaderboard" %}</span> <span class="sidebar-text whitespace-nowrap">{% trans "Leaderboard" %}</span>
</a> </a>
{% if user.is_px_admin or user.is_hospital_admin %} {% if user.is_px_admin or user.is_hospital_admin %}
<a href="{% url 'physicians:doctor_rating_fetch' %}"
class="flex items-center gap-2 p-2 rounded-lg transition {% if request.resolver_match.url_name == 'doctor_rating_fetch' %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
<i data-lucide="cloud-download" class="w-4 h-4 flex-shrink-0"></i>
<span class="sidebar-text whitespace-nowrap">{% trans "Fetch Ratings" %}</span>
</a>
<a href="{% url 'physicians:doctor_rating_import' %}" <a href="{% url 'physicians:doctor_rating_import' %}"
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'import' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}"> class="flex items-center gap-2 p-2 rounded-lg transition {% if 'import' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
<i data-lucide="upload" class="w-4 h-4 flex-shrink-0"></i> <i data-lucide="upload" class="w-4 h-4 flex-shrink-0"></i>

View File

@ -55,7 +55,7 @@
/* Background Pattern */ /* Background Pattern */
.public-bg { .public-bg {
background: linear-gradient(135deg, #005696 0%, #007bbd 50%, #00a8e8 100%); background: linear-gradient(135deg, #007bbd 0%, #005696 100%);
min-height: 100vh; min-height: 100vh;
position: relative; position: relative;
overflow-x: hidden; overflow-x: hidden;
@ -63,15 +63,28 @@
.public-bg::before { .public-bg::before {
content: ''; content: '';
position: absolute; position: fixed;
top: 0; top: -100px;
left: 0; right: -100px;
right: 0; width: 200px;
bottom: 0; height: 200px;
background-image: background: rgba(255,255,255,0.05);
radial-gradient(circle at 20% 50%, rgba(255,255,255,0.1) 0%, transparent 50%), border-radius: 9999px;
radial-gradient(circle at 80% 80%, rgba(255,255,255,0.1) 0%, transparent 50%);
pointer-events: none; pointer-events: none;
z-index: 0;
}
.public-bg::after {
content: '';
position: fixed;
bottom: -75px;
left: -75px;
width: 150px;
height: 150px;
background: rgba(255,255,255,0.05);
border-radius: 9999px;
pointer-events: none;
z-index: 0;
} }
/* Glass morphism effect */ /* Glass morphism effect */
@ -206,16 +219,16 @@
</main> </main>
<!-- Footer --> <!-- Footer -->
<footer class="glass-card mt-12 py-6"> <footer class="mt-12 py-6 bg-white/5 backdrop-blur-sm">
<div class="container mx-auto px-4 text-center"> <div class="container mx-auto px-4 text-center">
<p class="text-slate text-sm"> <p class="text-white/70 text-sm">
&copy; {% now "Y" %} <span class="font-semibold text-navy">Al Hammadi Hospital</span> - PX360. {% trans "All rights reserved." %} &copy; {% now "Y" %} <span class="font-semibold text-white">Al Hammadi Hospital</span> - PX360. {% trans "All rights reserved." %}
</p> </p>
<p class="text-slate text-xs mt-2"> <p class="text-white/50 text-xs mt-2">
{% trans "Your feedback helps us improve our services" %} {% trans "Your feedback helps us improve our services" %}
</p> </p>
<p class="text-slate text-xs mt-1"> <p class="text-white/50 text-xs mt-1">
Powered by <a href="https://tenhal.sa" target="_blank" class="text-navy hover:underline font-medium">tenhal.sa</a> Powered by <a href="https://tenhal.sa" target="_blank" class="text-white hover:text-white/80 hover:underline font-medium">tenhal.sa</a>
</p> </p>
</div> </div>
</footer> </footer>

View File

@ -90,9 +90,9 @@
<a href="{% url 'observations:observation_create' %}" class="bg-white text-[#005696] px-5 py-2.5 rounded-xl text-sm font-bold hover:bg-gray-100 flex items-center gap-2 transition"> <a href="{% url 'observations:observation_create' %}" class="bg-white text-[#005696] px-5 py-2.5 rounded-xl text-sm font-bold hover:bg-gray-100 flex items-center gap-2 transition">
<i data-lucide="plus-circle" class="w-4 h-4"></i> {% trans "New Observation" %} <i data-lucide="plus-circle" class="w-4 h-4"></i> {% trans "New Observation" %}
</a> </a>
<a href="{% url 'observations:category_list' %}" class="bg-white text-[#005696] px-5 py-2.5 rounded-xl text-sm font-bold hover:bg-gray-100 flex items-center gap-2 transition"> {% comment %} <a href="{% url 'observations:category_list' %}" class="bg-white text-[#005696] px-5 py-2.5 rounded-xl text-sm font-bold hover:bg-gray-100 flex items-center gap-2 transition">
<i data-lucide="tags" class="w-4 h-4"></i> {% trans "Categories" %} <i data-lucide="tags" class="w-4 h-4"></i> {% trans "Categories" %}
</a> </a> {% endcomment %}
<a href="{% url 'core:public_submit_landing' %}" class="bg-white/20 text-white border-2 border-white/50 px-5 py-2.5 rounded-xl text-sm font-bold hover:bg-white/30 flex items-center gap-2 transition" target="_blank"> <a href="{% url 'core:public_submit_landing' %}" class="bg-white/20 text-white border-2 border-white/50 px-5 py-2.5 rounded-xl text-sm font-bold hover:bg-white/30 flex items-center gap-2 transition" target="_blank">
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Public Form" %} <i data-lucide="plus" class="w-4 h-4"></i> {% trans "Public Form" %}
</a> </a>

View File

@ -42,7 +42,7 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="min-h-screen bg-gradient-to-br from-light via-blue-50 to-light py-8 px-4 sm:px-6 lg:px-8"> <div class="min-h-screen py-8 px-4 sm:px-6 lg:px-8">
<div class="max-w-2xl mx-auto"> <div class="max-w-2xl mx-auto">
<!-- Main Card --> <!-- Main Card -->
<div class="glass-card rounded-3xl shadow-2xl p-8 md:p-10 animate-fade-in"> <div class="glass-card rounded-3xl shadow-2xl p-8 md:p-10 animate-fade-in">

View File

@ -55,7 +55,7 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="min-h-screen bg-gradient-to-br from-light via-blue-50 to-light py-12 px-4 sm:px-6 lg:px-8"> <div class="min-h-screen py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-3xl mx-auto"> <div class="max-w-3xl mx-auto">
<!-- Success Icon --> <!-- Success Icon -->
<div class="text-center mb-8"> <div class="text-center mb-8">

View File

@ -9,11 +9,6 @@ header.glass-card {
display: none !important; display: none !important;
} }
body.public-bg {
background: linear-gradient(to bottom right, #005696, #007bbd, #eef6fb) !important;
min-height: 100vh;
}
.lang-switcher { .lang-switcher {
position: fixed; position: fixed;
top: 1rem; top: 1rem;

View File

@ -1,6 +1,7 @@
{% extends 'layouts/base.html' %} {% extends 'layouts/base.html' %}
{% load i18n %} {% load i18n %}
{% load static %} {% load static %}
{% load national_id_tags %}
{% block title %}{{ patient.get_full_name }} - {% trans "Patient Details" %}{% endblock %} {% block title %}{{ patient.get_full_name }} - {% trans "Patient Details" %}{% endblock %}
@ -161,10 +162,16 @@
<span class="font-mono font-bold text-navy">{{ patient.mrn }}</span> <span class="font-mono font-bold text-navy">{{ patient.mrn }}</span>
</span> </span>
{% if patient.national_id %} {% if patient.national_id %}
<span class="flex items-center gap-2 bg-slate-50 px-3 py-1.5 rounded-lg border border-slate-200"> <span class="flex items-center gap-2 bg-slate-50 px-3 py-1.5 rounded-lg border border-slate-200" x-data="{ revealed: false }">
<i data-lucide="credit-card" class="w-4 h-4 text-blue"></i> <i data-lucide="credit-card" class="w-4 h-4 text-blue"></i>
<span class="text-slate text-xs font-semibold uppercase">{% trans "SSN" %}:</span> <span class="text-slate text-xs font-semibold uppercase">{% trans "SSN" %}:</span>
<span class="font-mono font-bold text-navy">{{ patient.national_id }}</span> <span class="font-mono font-bold text-navy" x-text="revealed ? '{{ patient.national_id }}' : '{{ patient.national_id|mask_id }}'">{{ patient.national_id|mask_id }}</span>
{% if user.is_superuser %}
<button @click="revealed = !revealed" class="text-blue hover:text-navy" title="{% trans 'Toggle' %}">
<i x-show="!revealed" data-lucide="eye" class="w-3.5 h-3.5 inline"></i>
<i x-show="revealed" data-lucide="eye-off" class="w-3.5 h-3.5 inline"></i>
</button>
{% endif %}
</span> </span>
{% endif %} {% endif %}
{% if patient.phone %} {% if patient.phone %}

View File

@ -1,6 +1,7 @@
{% extends "layouts/base.html" %} {% extends "layouts/base.html" %}
{% load i18n %} {% load i18n %}
{% load static %} {% load static %}
{% load national_id_tags %}
{% block title %}{% trans "Patients" %} - PX360{% endblock %} {% block title %}{% trans "Patients" %} - PX360{% endblock %}
@ -262,7 +263,6 @@
<th class="px-4 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "National ID" %}</th> <th class="px-4 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "National ID" %}</th>
<th class="px-4 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Contact" %}</th> <th class="px-4 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Contact" %}</th>
<th class="px-4 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Nationality" %}</th> <th class="px-4 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Nationality" %}</th>
<th class="px-4 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Hospital" %}</th>
<th class="px-4 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Status" %}</th> <th class="px-4 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Status" %}</th>
<th class="px-4 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Actions" %}</th> <th class="px-4 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Actions" %}</th>
</tr> </tr>
@ -287,7 +287,19 @@
<span class="font-mono text-sm font-medium text-navy bg-slate-50 px-2 py-1 rounded">{{ patient.mrn }}</span> <span class="font-mono text-sm font-medium text-navy bg-slate-50 px-2 py-1 rounded">{{ patient.mrn }}</span>
</td> </td>
<td class="px-4 py-4"> <td class="px-4 py-4">
<span class="font-mono text-sm text-slate">{{ patient.national_id|default:"-" }}</span> {% if patient.national_id %}
<span class="font-mono text-sm text-slate" data-masked="{{ patient.national_id|mask_id }}" data-full="{{ patient.national_id }}" x-data="{ revealed: false }">
<span x-text="revealed ? $el.closest('[data-full]').dataset.full : $el.closest('[data-masked]').dataset.masked">{{ patient.national_id|mask_id }}</span>
{% if user.is_superuser %}
<button @click="revealed = !revealed" class="ml-1 text-blue hover:text-navy" title="{% trans 'Toggle' %}">
<i x-show="!revealed" data-lucide="eye" class="w-3.5 h-3.5 inline"></i>
<i x-show="revealed" data-lucide="eye-off" class="w-3.5 h-3.5 inline"></i>
</button>
{% endif %}
</span>
{% else %}
<span class="font-mono text-sm text-slate">-</span>
{% endif %}
</td> </td>
<td class="px-4 py-4"> <td class="px-4 py-4">
{% if patient.phone %} {% if patient.phone %}
@ -306,12 +318,6 @@
<td class="px-4 py-4"> <td class="px-4 py-4">
<span class="text-sm text-slate">{{ patient.nationality|default:"-" }}</span> <span class="text-sm text-slate">{{ patient.nationality|default:"-" }}</span>
</td> </td>
<td class="px-4 py-4">
<div class="flex items-center gap-2">
<i data-lucide="building-2" class="w-3.5 h-3.5 text-slate"></i>
<span class="text-sm text-slate">{{ patient.primary_hospital.name|default:"-" }}</span>
</div>
</td>
<td class="px-4 py-4 text-center"> <td class="px-4 py-4 text-center">
<span class="status-badge {% if patient.status == 'active' %}bg-green-100 text-green-700{% else %}bg-slate-100 text-slate-700{% endif %}"> <span class="status-badge {% if patient.status == 'active' %}bg-green-100 text-green-700{% else %}bg-slate-100 text-slate-700{% endif %}">
{{ patient.get_status_display }} {{ patient.get_status_display }}

View File

@ -1,5 +1,6 @@
{% extends 'layouts/base.html' %} {% extends 'layouts/base.html' %}
{% load i18n %} {% load i18n %}
{% load national_id_tags %}
{% block title %}{% trans "Visit Journey" %} - {{ visit.admission_id }} - {{ patient.get_full_name }}{% endblock %} {% block title %}{% trans "Visit Journey" %} - {{ visit.admission_id }} - {{ patient.get_full_name }}{% endblock %}
@ -166,8 +167,8 @@
{% if event.visit_category %} {% if event.visit_category %}
<span class="text-[10px] font-bold bg-white text-slate-500 px-1.5 py-0.5 rounded border border-slate-200">{{ event.visit_category }}</span> <span class="text-[10px] font-bold bg-white text-slate-500 px-1.5 py-0.5 rounded border border-slate-200">{{ event.visit_category }}</span>
{% endif %} {% endif %}
{% if event.type %} {% if event.event_type %}
<span class="text-xs font-semibold text-navy">{{ event.type }}</span> <span class="text-xs font-semibold text-navy">{{ event.event_type }}</span>
{% endif %} {% endif %}
</div> </div>
{% if event.parsed_date or event.bill_date %} {% if event.parsed_date or event.bill_date %}
@ -241,7 +242,7 @@
{% if patient.national_id %} {% if patient.national_id %}
<div class="flex items-center gap-2 text-sm text-slate"> <div class="flex items-center gap-2 text-sm text-slate">
<i data-lucide="credit-card" class="w-3.5 h-3.5 text-blue"></i> <i data-lucide="credit-card" class="w-3.5 h-3.5 text-blue"></i>
<span class="font-mono">{{ patient.national_id }}</span> <span class="font-mono">{{ patient.national_id|mask_id }}</span>
</div> </div>
{% endif %} {% endif %}
</div> </div>

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

View File

@ -30,7 +30,12 @@
</h1> </h1>
<p class="text-slate mt-2 text-sm">{% trans "Import doctor ratings from HIS CSV export" %}</p> <p class="text-slate mt-2 text-sm">{% trans "Import doctor ratings from HIS CSV export" %}</p>
</div> </div>
<div> <div class="flex items-center gap-3">
<a href="{% url 'physicians:doctor_rating_fetch' %}"
class="px-5 py-2.5 bg-blue text-white rounded-xl font-semibold hover:bg-navy transition flex items-center gap-2">
<i data-lucide="cloud-download" class="w-4 h-4"></i>
{% trans "Fetch from HIS" %}
</a>
<a href="{% url 'physicians:doctor_rating_job_list' %}" <a href="{% url 'physicians:doctor_rating_job_list' %}"
class="px-5 py-2.5 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-slate-50 hover:border-slate-300 transition flex items-center gap-2"> class="px-5 py-2.5 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-slate-50 hover:border-slate-300 transition flex items-center gap-2">
<i data-lucide="clock" class="w-4 h-4"></i> <i data-lucide="clock" class="w-4 h-4"></i>

View File

@ -73,11 +73,18 @@
<p class="text-white/80 text-sm">{% trans "Track doctor rating import jobs" %}</p> <p class="text-white/80 text-sm">{% trans "Track doctor rating import jobs" %}</p>
</div> </div>
</div> </div>
<a href="{% url 'physicians:doctor_rating_import' %}" <div class="flex items-center gap-3">
class="px-6 py-3 bg-white text-[#005696] rounded-xl font-semibold hover:bg-white/90 transition flex items-center gap-2 shadow-lg"> <a href="{% url 'physicians:doctor_rating_fetch' %}"
<i data-lucide="plus-circle" class="w-5 h-5"></i> class="px-6 py-3 bg-white/20 text-white rounded-xl font-semibold hover:bg-white/30 transition flex items-center gap-2 border border-white/30">
{% trans "New Import" %} <i data-lucide="cloud-download" class="w-5 h-5"></i>
</a> {% trans "Fetch from HIS" %}
</a>
<a href="{% url 'physicians:doctor_rating_import' %}"
class="px-6 py-3 bg-white text-[#005696] rounded-xl font-semibold hover:bg-white/90 transition flex items-center gap-2 shadow-lg">
<i data-lucide="plus-circle" class="w-5 h-5"></i>
{% trans "New Import" %}
</a>
</div>
</div> </div>
</div> </div>

View File

@ -200,7 +200,7 @@
<div class="p-6"> <div class="p-6">
<div class="grid grid-cols-3 gap-4"> <div class="grid grid-cols-3 gap-4">
<div class="stat-card"> <div class="stat-card">
<div class="stat-value">{{ usage_stats.total_usage }}</div> <div class="stat-value">{{ usage_stats.total }}</div>
<div class="stat-label">{% trans "Total Usage" %}</div> <div class="stat-label">{% trans "Total Usage" %}</div>
</div> </div>
<div class="stat-card" style="background: linear-gradient(135deg, #10b981, #34d399);"> <div class="stat-card" style="background: linear-gradient(135deg, #10b981, #34d399);">
@ -215,6 +215,162 @@
</div> </div>
</div> </div>
<!-- Complaints & Inquiries Tabs -->
<div class="info-card animate-in">
<div class="card-header flex items-center justify-between">
<h2 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
<i data-lucide="layout-list" class="w-5 h-5"></i>
{% trans "Related Items" %}
</h2>
<div class="flex gap-1 bg-white rounded-lg p-1 border border-slate-200">
<button type="button" onclick="switchTab('complaints')"
id="tab-btn-complaints"
class="tab-btn px-4 py-2 rounded-md text-sm font-semibold transition-all bg-navy text-white">
<i data-lucide="file-text" class="w-4 h-4 inline mr-1"></i>
{% trans "Complaints" %} ({{ complaints_count }})
</button>
<button type="button" onclick="switchTab('inquiries')"
id="tab-btn-inquiries"
class="tab-btn px-4 py-2 rounded-md text-sm font-semibold transition-all text-slate hover:bg-slate-50">
<i data-lucide="help-circle" class="w-4 h-4 inline mr-1"></i>
{% trans "Inquiries" %} ({{ inquiries_count }})
</button>
</div>
</div>
<div class="p-0">
<!-- Complaints Tab -->
<div id="tab-complaints" class="tab-panel">
{% if source_complaints %}
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b-2 border-slate-200">
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Reference" %}</th>
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Description" %}</th>
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Status" %}</th>
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Priority" %}</th>
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Hospital" %}</th>
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Date" %}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for complaint in source_complaints %}
<tr class="hover:bg-slate-50 transition cursor-pointer" onclick="window.location.href='{% url 'complaints:complaint_detail' complaint.pk %}'">
<td class="py-3 px-4 text-sm font-bold text-navy font-mono">{{ complaint.reference_number }}</td>
<td class="py-3 px-4 text-sm text-slate max-w-xs truncate">
{% if complaint.ai_brief_en %}
{{ complaint.ai_brief_en|truncatewords:8 }}
{% else %}
{{ complaint.description|truncatewords:8 }}
{% endif %}
</td>
<td class="py-3 px-4">
<span class="px-2.5 py-1 rounded-full text-[10px] font-bold uppercase
{% if complaint.status == 'open' %}bg-yellow-100 text-yellow-700
{% elif complaint.status == 'in_progress' %}bg-blue-100 text-blue-700
{% elif complaint.status == 'partially_resolved' %}bg-amber-100 text-amber-700
{% elif complaint.status == 'resolved' %}bg-green-100 text-green-700
{% elif complaint.status == 'closed' %}bg-slate-100 text-slate-600
{% elif complaint.status == 'cancelled' %}bg-red-100 text-red-700
{% elif complaint.status == 'contacted' %}bg-purple-100 text-purple-700
{% elif complaint.status == 'contacted_no_response' %}bg-slate-100 text-slate-600
{% else %}bg-slate-100 text-slate-600{% endif %}">
{{ complaint.get_status_display }}
</span>
</td>
<td class="py-3 px-4">
<span class="px-2.5 py-1 rounded-full text-[10px] font-bold uppercase
{% if complaint.priority == 'critical' %}bg-red-500 text-white
{% elif complaint.priority == 'high' %}bg-orange-100 text-orange-700
{% elif complaint.priority == 'medium' %}bg-yellow-100 text-yellow-700
{% else %}bg-green-100 text-green-700{% endif %}">
{{ complaint.get_priority_display }}
</span>
</td>
<td class="py-3 px-4 text-sm text-slate">{{ complaint.hospital.name|default:"-" }}</td>
<td class="py-3 px-4 text-sm text-slate">{{ complaint.created_at|date:"Y-m-d" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-12">
<div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="file-text" class="w-8 h-8 text-slate-400"></i>
</div>
<p class="text-slate font-medium">{% trans "No complaints from this source" %}</p>
<p class="text-slate text-sm mt-1">{% trans "Complaints will appear here when submitted through this source" %}</p>
</div>
{% endif %}
</div>
<!-- Inquiries Tab -->
<div id="tab-inquiries" class="tab-panel hidden">
{% if source_inquiries %}
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b-2 border-slate-200">
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Reference" %}</th>
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Subject" %}</th>
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Category" %}</th>
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Status" %}</th>
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Priority" %}</th>
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Hospital" %}</th>
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Date" %}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for inquiry in source_inquiries %}
<tr class="hover:bg-slate-50 transition cursor-pointer" onclick="window.location.href='{% url 'inquiries:inquiry_detail' inquiry.pk %}'">
<td class="py-3 px-4 text-sm font-bold text-navy font-mono">{{ inquiry.reference_number }}</td>
<td class="py-3 px-4 text-sm text-slate max-w-xs truncate">{{ inquiry.subject|truncatewords:8 }}</td>
<td class="py-3 px-4">
<span class="px-2.5 py-1 bg-slate-100 rounded-full text-[10px] font-bold text-slate">
{{ inquiry.get_category_display }}
</span>
</td>
<td class="py-3 px-4">
<span class="px-2.5 py-1 rounded-full text-[10px] font-bold uppercase
{% if inquiry.status == 'open' %}bg-yellow-100 text-yellow-700
{% elif inquiry.status == 'in_progress' %}bg-blue-100 text-blue-700
{% elif inquiry.status == 'resolved' %}bg-green-100 text-green-700
{% elif inquiry.status == 'closed' %}bg-slate-100 text-slate-600
{% elif inquiry.status == 'contacted' %}bg-purple-100 text-purple-700
{% elif inquiry.status == 'contacted_no_response' %}bg-slate-100 text-slate-600
{% else %}bg-slate-100 text-slate-600{% endif %}">
{{ inquiry.get_status_display }}
</span>
</td>
<td class="py-3 px-4">
<span class="px-2.5 py-1 rounded-full text-[10px] font-bold uppercase
{% if inquiry.priority == 'high' %}bg-orange-100 text-orange-700
{% elif inquiry.priority == 'medium' %}bg-yellow-100 text-yellow-700
{% else %}bg-green-100 text-green-700{% endif %}">
{{ inquiry.get_priority_display }}
</span>
</td>
<td class="py-3 px-4 text-sm text-slate">{{ inquiry.hospital.name|default:"-" }}</td>
<td class="py-3 px-4 text-sm text-slate">{{ inquiry.created_at|date:"Y-m-d" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-12">
<div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="help-circle" class="w-8 h-8 text-slate-400"></i>
</div>
<p class="text-slate font-medium">{% trans "No inquiries from this source" %}</p>
<p class="text-slate text-sm mt-1">{% trans "Inquiries will appear here when submitted through this source" %}</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Usage Records --> <!-- Usage Records -->
<div class="info-card animate-in"> <div class="info-card animate-in">
<div class="card-header"> <div class="card-header">
@ -359,5 +515,17 @@
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons(); lucide.createIcons();
}); });
function switchTab(tab) {
document.querySelectorAll('.tab-panel').forEach(p => p.classList.add('hidden'));
document.querySelectorAll('.tab-btn').forEach(b => {
b.classList.remove('bg-navy', 'text-white');
b.classList.add('text-slate', 'hover:bg-slate-50');
});
document.getElementById('tab-' + tab).classList.remove('hidden');
var btn = document.getElementById('tab-btn-' + tab);
btn.classList.add('bg-navy', 'text-white');
btn.classList.remove('text-slate', 'hover:bg-slate-50');
}
</script> </script>
{% endblock %} {% endblock %}