-
-
-
-
- |
-
- مستشفى الحمادي
- |
-
-
+ return f"""
+
"""
@@ -295,6 +286,8 @@ class NotificationService:
metadata=None,
notification_type="system",
user=None,
+ attachments=None,
+ cc=None,
):
"""
Send Email notification and create corresponding in-app notification.
@@ -308,6 +301,14 @@ class NotificationService:
metadata: Additional metadata dict (optional)
notification_type: Type of notification (default: 'system')
user: User instance to create in-app notification for (optional, will try to lookup by email)
+ attachments: Optional list of (filename, content_bytes, mimetype) tuples.
+ Only applied on the standard Django email backend path; silently
+ skipped on the external Email API path (which would need its own
+ upload mechanism).
+ cc: Optional list of email addresses to CC. Only applied on the
+ standard Django email backend path; silently skipped on the
+ external Email API path. In-app notifications are created for
+ each CC'd user whose email maps to a User account.
Returns:
NotificationLog instance
@@ -326,16 +327,34 @@ class NotificationService:
from apps.accounts.models import User
+ # Normalise CC list to a clean list of strings (drop None/empty)
+ cc_list = [addr for addr in (cc or []) if addr]
+
+ # Enrich metadata with CC trace
+ log_metadata = dict(metadata or {})
+ if cc_list:
+ log_metadata = {**log_metadata, "cc": cc_list}
+
# Check if Email API is enabled and use it (simulator or external API)
email_api_config = settings.EXTERNAL_NOTIFICATION_API.get("email", {})
if email_api_config.get("enabled", False):
+ skipped = []
+ if attachments:
+ skipped.append(f"{len(attachments)} attachment(s)")
+ if cc_list:
+ skipped.append(f"{len(cc_list)} CC recipient(s)")
+ if skipped:
+ logger.warning(
+ f"Email API path does not support {' or '.join(skipped)}; "
+ f"sending email to {email} without them."
+ )
log = NotificationService.send_email_via_api(
message=message,
email=email,
subject=subject,
html_message=html_message,
related_object=related_object,
- metadata=metadata,
+ metadata=log_metadata,
)
# Create in-app notification after sending via API
NotificationService._create_in_app_notification_from_email(
@@ -351,31 +370,61 @@ class NotificationService:
message=message,
content_object=related_object,
provider="console", # TODO: Replace with actual provider
- metadata=metadata or {},
+ metadata=log_metadata,
)
- # Create in-app notification
+ # Create in-app notification for the primary (To:) recipient
NotificationService._create_in_app_notification_from_email(
log, email, subject, message, notification_type, related_object, user
)
+ # Create in-app notifications for each CC'd user (silently skips emails
+ # that don't map to a User account).
+ for cc_addr in cc_list:
+ if cc_addr.lower() == email.lower():
+ continue # Already covered by the To: notification
+ NotificationService._create_in_app_notification_from_email(
+ log, cc_addr, subject, message, notification_type, related_object, None
+ )
+
# Check if Email is enabled
email_config = settings.NOTIFICATION_CHANNELS.get("email", {})
if not email_config.get("enabled", True):
- logger.info(f"[Email Console] To: {email} | Subject: {subject} | Message: {message}")
+ cc_summary = f" | CC: {', '.join(cc_list)}" if cc_list else ""
+ logger.info(f"[Email Console] To: {email}{cc_summary} | Subject: {subject} | Message: {message}")
log.mark_sent()
return log
# Send email using Django's email backend
try:
- send_mail(
- subject=subject,
- message=message,
- from_email=settings.DEFAULT_FROM_EMAIL,
- recipient_list=[email],
- html_message=html_message,
- fail_silently=False,
- )
+ if attachments or cc_list:
+ from django.core.mail import EmailMultiAlternatives
+
+ msg = EmailMultiAlternatives(
+ subject=subject,
+ body=message,
+ from_email=settings.DEFAULT_FROM_EMAIL,
+ to=[email],
+ cc=cc_list if cc_list else None,
+ )
+ if html_message:
+ msg.attach_alternative(html_message, "text/html")
+ for att in (attachments or []):
+ # att is (filename, content_bytes, mimetype)
+ try:
+ msg.attach(*att)
+ except Exception:
+ logger.exception(f"Failed to attach {att[0] if att else '?'} to email for {email}")
+ msg.send(fail_silently=False)
+ else:
+ send_mail(
+ subject=subject,
+ message=message,
+ from_email=settings.DEFAULT_FROM_EMAIL,
+ recipient_list=[email],
+ html_message=html_message,
+ fail_silently=False,
+ )
log.mark_sent()
logger.info(f"Email sent to {email}: {subject}")
except Exception as e:
diff --git a/apps/observations/forms.py b/apps/observations/forms.py
index 9183c63..ddbf90d 100644
--- a/apps/observations/forms.py
+++ b/apps/observations/forms.py
@@ -8,6 +8,7 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from apps.accounts.models import User
+from apps.core.validators import SAUDI_PHONE_HTML_PATTERN, validate_saudi_phone
from apps.organizations.models import Area, Department, Hospital, LocationType
from apps.organizations.models import Section as OrgSection
@@ -77,6 +78,7 @@ class ObservationPublicForm(forms.ModelForm):
"reporter_name",
"reporter_phone",
"reporter_email",
+ "national_id",
]
widgets = {
"location_type": forms.Select(
@@ -139,6 +141,12 @@ class ObservationPublicForm(forms.ModelForm):
"placeholder": "Your email (optional)",
}
),
+ "national_id": forms.TextInput(
+ attrs={
+ "class": "form-control",
+ "placeholder": "National ID/Iqama (optional)",
+ }
+ ),
}
def __init__(self, *args, **kwargs):
@@ -198,11 +206,15 @@ class ObservationInternalForm(forms.ModelForm):
"location_type",
"area",
"description",
+ "title",
+ "location_text",
"section",
"incident_datetime",
- "patient_file_number",
+ "patient_name",
+ "patient_national_id",
+ "patient_phone",
+ "patient_email",
"assigned_department",
- "assigned_to",
"px_source",
]
widgets = {
@@ -225,16 +237,46 @@ class ObservationInternalForm(forms.ModelForm):
"placeholder": "Please describe what you observed in detail...",
}
),
+ "title": forms.TextInput(
+ attrs={
+ "class": "form-control",
+ "placeholder": "Brief title (optional)",
+ }
+ ),
+ "location_text": forms.TextInput(
+ attrs={
+ "class": "form-control",
+ "placeholder": "Specific location details (e.g. room number, floor)",
+ }
+ ),
"incident_datetime": forms.DateTimeInput(
attrs={
"class": "form-control",
"type": "datetime-local",
}
),
- "patient_file_number": forms.TextInput(
+ "patient_name": forms.TextInput(
attrs={
"class": "form-control",
- "placeholder": "Patient MRN / file number",
+ "placeholder": "Patient name (optional)",
+ }
+ ),
+ "patient_national_id": forms.TextInput(
+ attrs={
+ "class": "form-control",
+ "placeholder": "National ID/Iqama (optional)",
+ }
+ ),
+ "patient_phone": forms.TextInput(
+ attrs={
+ "class": "form-control",
+ "placeholder": "Patient phone (optional)",
+ }
+ ),
+ "patient_email": forms.EmailInput(
+ attrs={
+ "class": "form-control",
+ "placeholder": "Patient email (optional)",
}
),
"assigned_department": forms.Select(
@@ -242,11 +284,6 @@ class ObservationInternalForm(forms.ModelForm):
"class": "form-select",
}
),
- "assigned_to": forms.Select(
- attrs={
- "class": "form-select",
- }
- ),
}
section = forms.ModelChoiceField(
@@ -311,15 +348,6 @@ class ObservationInternalForm(forms.ModelForm):
self.fields["assigned_department"].queryset = Department.objects.filter(status="active").order_by("name_en")
self.fields["assigned_department"].empty_label = "Select department (optional)"
- if hospital:
- from apps.core.utils import get_assignable_users
- self.fields["assigned_to"].queryset = get_assignable_users(hospital)
- else:
- self.fields["assigned_to"].queryset = User.objects.filter(is_active=True).order_by(
- "first_name", "last_name"
- )
- self.fields["assigned_to"].empty_label = "Select assignee (optional)"
-
def clean_description(self):
description = self.cleaned_data.get("description", "")
if len(description.strip()) < 10:
@@ -725,7 +753,13 @@ class PublicObservationForm(forms.ModelForm):
label=_("Mobile Number"),
max_length=20,
required=True,
- widget=forms.TextInput(attrs={"class": "form-control", "placeholder": _("Your mobile number")}),
+ validators=[validate_saudi_phone],
+ widget=forms.TextInput(attrs={
+ "class": "form-control",
+ "placeholder": _("05XXXXXXXX or +9665XXXXXXXX"),
+ "pattern": SAUDI_PHONE_HTML_PATTERN,
+ "inputmode": "tel",
+ }),
)
reporter_email = forms.EmailField(
label=_("Email Address"),
diff --git a/apps/observations/migrations/0021_alter_observation_reporter_phone.py b/apps/observations/migrations/0021_alter_observation_reporter_phone.py
new file mode 100644
index 0000000..715af0b
--- /dev/null
+++ b/apps/observations/migrations/0021_alter_observation_reporter_phone.py
@@ -0,0 +1,19 @@
+# Generated by Django 6.0.1 on 2026-07-18 14:46
+
+import apps.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('observations', '0020_remove_observation_dept_response_acceptance_notes_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='observation',
+ name='reporter_phone',
+ field=models.CharField(blank=True, help_text='Optional phone number for follow-up', max_length=20, validators=[apps.core.validators.validate_saudi_phone]),
+ ),
+ ]
diff --git a/apps/observations/migrations/0022_observation_add_national_id.py b/apps/observations/migrations/0022_observation_add_national_id.py
new file mode 100644
index 0000000..ba0480d
--- /dev/null
+++ b/apps/observations/migrations/0022_observation_add_national_id.py
@@ -0,0 +1,18 @@
+# Generated by Django 6.0.1 on 2026-07-19 06:23
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('observations', '0021_alter_observation_reporter_phone'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='observation',
+ name='national_id',
+ field=models.CharField(blank=True, help_text='Optional National ID/Iqama of the reporter', max_length=20),
+ ),
+ ]
diff --git a/apps/observations/migrations/0023_add_patient_info_fields.py b/apps/observations/migrations/0023_add_patient_info_fields.py
new file mode 100644
index 0000000..bbefb03
--- /dev/null
+++ b/apps/observations/migrations/0023_add_patient_info_fields.py
@@ -0,0 +1,33 @@
+# Generated by Django 6.0.1 on 2026-07-19 08:24
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('observations', '0022_observation_add_national_id'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='observation',
+ name='patient_email',
+ field=models.EmailField(blank=True, help_text='Email of the patient', max_length=254),
+ ),
+ migrations.AddField(
+ model_name='observation',
+ name='patient_name',
+ field=models.CharField(blank=True, help_text='Name of the patient involved', max_length=200),
+ ),
+ migrations.AddField(
+ model_name='observation',
+ name='patient_national_id',
+ field=models.CharField(blank=True, help_text='National ID/Iqama of the patient', max_length=20),
+ ),
+ migrations.AddField(
+ model_name='observation',
+ name='patient_phone',
+ field=models.CharField(blank=True, help_text='Phone number of the patient', max_length=20),
+ ),
+ ]
diff --git a/apps/observations/models.py b/apps/observations/models.py
index ff5e8cc..a67e2fd 100644
--- a/apps/observations/models.py
+++ b/apps/observations/models.py
@@ -20,6 +20,7 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from apps.core.models import SoftDeleteModel, TimeStampedModel, UUIDModel
+from apps.core.validators import validate_saudi_phone
from apps.organizations.models import LocationType
@@ -363,13 +364,18 @@ class Observation(UUIDModel, TimeStampedModel, SoftDeleteModel):
# Optional reporter information (anonymous supported)
reporter_staff_id = models.CharField(max_length=50, blank=True, help_text="Optional staff ID of the reporter")
reporter_name = models.CharField(max_length=200, blank=True, help_text="Optional name of the reporter")
- reporter_phone = models.CharField(max_length=20, blank=True, help_text="Optional phone number for follow-up")
+ reporter_phone = models.CharField(max_length=20, blank=True, validators=[validate_saudi_phone], help_text="Optional phone number for follow-up")
reporter_email = models.EmailField(blank=True, help_text="Optional email for follow-up")
+ national_id = models.CharField(max_length=20, blank=True, help_text="Optional National ID/Iqama of the reporter")
# Patient / subject information
patient_file_number = models.CharField(
max_length=100, blank=True, help_text="Medical record number / file number of the patient"
)
+ patient_name = models.CharField(max_length=200, blank=True, help_text="Name of the patient involved")
+ patient_national_id = models.CharField(max_length=20, blank=True, help_text="National ID/Iqama of the patient")
+ patient_phone = models.CharField(max_length=20, blank=True, help_text="Phone number of the patient")
+ patient_email = models.EmailField(blank=True, help_text="Email of the patient")
# Status and workflow
status = models.CharField(
diff --git a/apps/observations/services.py b/apps/observations/services.py
index d869239..f866d93 100644
--- a/apps/observations/services.py
+++ b/apps/observations/services.py
@@ -55,6 +55,7 @@ class ObservationService:
reporter_name: str = "",
reporter_phone: str = "",
reporter_email: str = "",
+ national_id: str = "",
client_ip: str = None,
user_agent: str = "",
attachments: list = None,
@@ -99,6 +100,7 @@ class ObservationService:
reporter_name=reporter_name,
reporter_phone=reporter_phone,
reporter_email=reporter_email,
+ national_id=national_id,
client_ip=client_ip,
user_agent=user_agent,
hospital=hospital,
diff --git a/apps/observations/tasks.py b/apps/observations/tasks.py
index 20cf7e9..b5337bb 100644
--- a/apps/observations/tasks.py
+++ b/apps/observations/tasks.py
@@ -627,3 +627,39 @@ def send_observation_dept_response_reminders():
"first_reminder_count": first_reminder_count,
"second_reminder_count": second_reminder_count,
}
+
+
+@shared_task
+def send_observation_reminder_email(observation_id, reminder_type, recipients):
+ """Send a reminder email to department champion and manager for an observation."""
+ import logging
+
+ logger = logging.getLogger(__name__)
+
+ try:
+ from apps.observations.models import Observation
+ from apps.notifications.services import NotificationService, get_email_header_html
+
+ observation = Observation.objects.get(pk=observation_id)
+
+ for recipient in recipients:
+ NotificationService.send_email(
+ email=recipient["email"],
+ subject=f"Reminder: Observation {observation.tracking_code} - Response Required",
+ message=f"This is a reminder that observation {observation.tracking_code} is awaiting your department's response. Please submit your response as soon as possible.",
+ html_message=f"""
+
+ {get_email_header_html()}
+
+
Reminder: Response Required
+
This is a reminder that observation {observation.tracking_code} is awaiting your department's response.
+
Please submit your response as soon as possible.
+
+
+""",
+ related_object=observation,
+ )
+
+ logger.info(f"Observation {observation_id} {reminder_type} reminder sent to {len(recipients)} recipients")
+ except Exception as e:
+ logger.error(f"Failed to send observation reminder email for {observation_id}: {e}")
diff --git a/apps/observations/views.py b/apps/observations/views.py
index 09033f0..770d3e1 100644
--- a/apps/observations/views.py
+++ b/apps/observations/views.py
@@ -195,6 +195,7 @@ def observation_create_public(request):
reporter_name=form.cleaned_data.get("reporter_name", ""),
reporter_phone=form.cleaned_data.get("reporter_phone", ""),
reporter_email=form.cleaned_data.get("reporter_email", ""),
+ national_id=form.cleaned_data.get("national_id", ""),
client_ip=client_ip,
user_agent=user_agent,
attachments=attachments,
@@ -316,8 +317,8 @@ def observation_create(request):
description=form.cleaned_data["description"],
severity=form.cleaned_data.get("severity") or request.POST.get("severity", "medium"),
category=None,
- title="",
- location_text="",
+ title=form.cleaned_data.get("title", "") or "",
+ location_text=form.cleaned_data.get("location_text", "") or "",
incident_datetime=form.cleaned_data.get("incident_datetime"),
reporter_staff_id=user.employee_id or "",
reporter_name=user.get_full_name(),
@@ -332,23 +333,28 @@ def observation_create(request):
)
assigned_dept = form.cleaned_data.get("assigned_department")
- assigned_user = form.cleaned_data.get("assigned_to")
section = form.cleaned_data.get("section")
if assigned_dept:
observation.assigned_department = assigned_dept
- if assigned_user:
- observation.assigned_to = assigned_user
- observation.status = ObservationStatus.IN_PROGRESS
- ObservationStatusLog.objects.create(
- observation=observation,
- from_status=ObservationStatus.OPEN,
- to_status=ObservationStatus.IN_PROGRESS,
- comment="Auto-assigned during creation",
- )
if section:
observation.section = section
+
+ observation.patient_name = form.cleaned_data.get("patient_name", "") or ""
+ observation.patient_national_id = form.cleaned_data.get("patient_national_id", "") or ""
+ observation.patient_phone = form.cleaned_data.get("patient_phone", "") or ""
+ observation.patient_email = form.cleaned_data.get("patient_email", "") or ""
+
+ observation.assigned_to = user
+ observation.status = ObservationStatus.IN_PROGRESS
observation.save()
+ ObservationStatusLog.objects.create(
+ observation=observation,
+ from_status=ObservationStatus.OPEN,
+ to_status=ObservationStatus.IN_PROGRESS,
+ comment="Auto-assigned during creation",
+ )
+
comm_req_id = request.POST.get("comm_req")
if comm_req_id:
try:
@@ -644,13 +650,13 @@ def observation_detail(request, pk):
"status_form": status_form,
"note_form": note_form,
"status_choices": ObservationStatus.choices,
- "can_triage": user.has_perm("observations.triage_observation") or user.is_px_admin(),
+ "can_triage": user.has_perm("observations.triage_observation") or user.is_px_admin() or user.is_px_employee(),
"can_convert": user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee(),
"can_send_to_department": user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee() or user.is_department_manager() or user.is_px_management(),
"can_respond_to_department": user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee() or (user.is_champion() and observation.assigned_department == user.department),
"can_send_reminder": user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee(),
"can_delete": user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee(),
- "can_admin": user.is_px_admin() or user.is_hospital_admin(),
+ "can_admin": user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee(),
"linked_rcas": linked_rcas,
}
@@ -734,7 +740,7 @@ def observation_triage(request, pk):
# Check permission
user = request.user
- if not (user.has_perm("observations.triage_observation") or user.is_px_admin()):
+ if not (user.has_perm("observations.triage_observation") or user.is_px_admin() or user.is_px_employee()):
messages.error(request, "You don't have permission to triage observations.")
return redirect("observations:observation_detail", pk=pk)
@@ -770,7 +776,7 @@ def observation_change_status(request, pk):
# Check permission
user = request.user
- if not (user.has_perm("observations.triage_observation") or user.is_px_admin() or user.is_hospital_admin()):
+ if not (user.has_perm("observations.triage_observation") or user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()):
messages.error(request, "You don't have permission to change observation status.")
return redirect("observations:observation_detail", pk=pk)
@@ -1619,6 +1625,8 @@ def observation_department_response(request, pk):
if not (
user.is_px_admin()
or user.is_hospital_admin()
+ or user.is_px_management()
+ or user.is_px_employee()
or (user.is_champion() and observation.assigned_department == user.department)
):
messages.error(request, "You don't have permission to respond to this observation.")
@@ -1681,61 +1689,31 @@ Generate a JSON response with:
is_internal=True,
)
- try:
- from apps.notifications.services import NotificationService, get_email_header_html
- from apps.core.utils import build_public_track_url
-
- track_url = build_public_track_url("observation", observation.tracking_code)
-
- if observation.reporter_phone:
- NotificationService.send_sms(
- phone=observation.reporter_phone,
- message=f"PX360: Your observation {observation.tracking_code} has been responded to. View details: {track_url}",
- related_object=observation,
- metadata={"notification_type": "observation_department_response"},
- )
-
- if observation.reporter_email:
- email_subject = f"PX360: Response to Your Observation {observation.tracking_code}"
- email_body = (
- f"Dear Valued Reporter,\n\n"
- f"Your observation {observation.tracking_code} has been responded to.\n\n"
- f"To view the full response, please visit:\n{track_url}\n\n"
- f"Thank you for your contribution.\n\n"
- f"Tracking Code: {observation.tracking_code}\n"
- f"This is an automated message from PX 360."
- )
- NotificationService.send_email(
- email=observation.reporter_email,
- subject=email_subject,
- message=email_body,
- html_message=f"""
-
- {get_email_header_html()}
-
-
Response to Your Observation
-
Dear Valued Reporter,
-
Your observation {observation.tracking_code} has been responded to.
-
-
Tracking Code: {observation.tracking_code}
-
Thank you for your contribution.
-
-
-""",
- related_object=observation,
- metadata={"notification_type": "observation_department_response_email"},
- )
- except Exception as e:
- logger.warning(f"Failed to send observation response notification: {e}")
-
messages.success(request, "Department response submitted successfully.")
+ # Prefer a user-supplied relative next URL (e.g. back to the department
+ # observation detail page) over the default department landing page.
+ # Block protocol-relative URLs (``//host/`` or ``/\\host/``) to prevent
+ # open-redirect abuse.
+ next_url = request.POST.get("next")
+ if not (
+ next_url
+ and next_url.startswith("/")
+ and not next_url.startswith("//")
+ and not next_url.startswith("/\\")
+ ):
+ next_url = None
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
from django.http import JsonResponse
from django.urls import reverse
- redirect_url = reverse("organizations:department_detail", kwargs={"pk": user.department.pk}) if user.department else reverse("observations:observation_detail", kwargs={"pk": pk})
+ if next_url:
+ redirect_url = next_url
+ elif user.department:
+ redirect_url = reverse("organizations:department_detail", kwargs={"pk": user.department.pk})
+ else:
+ redirect_url = reverse("observations:observation_detail", kwargs={"pk": pk})
return JsonResponse({"success": True, "redirect_url": redirect_url})
+ if next_url:
+ return redirect(next_url)
if user.department:
return redirect("organizations:department_detail", pk=user.department.pk)
return redirect("observations:observation_detail", pk=pk)
@@ -1771,30 +1749,30 @@ def observation_send_dept_response_reminder(request, pk):
reminder_type = request.POST.get("reminder_type", "first")
+ if reminder_type == "first" and observation.dept_response_reminder_sent_at:
+ messages.warning(request, "First reminder already sent.")
+ return redirect("observations:observation_detail", pk=pk)
+ if reminder_type == "second" and observation.dept_response_second_reminder_sent_at:
+ messages.warning(request, "Second reminder already sent.")
+ return redirect("observations:observation_detail", pk=pk)
+ if reminder_type == "second" and not observation.dept_response_reminder_sent_at:
+ messages.warning(request, "Please send the first reminder before the second.")
+ return redirect("observations:observation_detail", pk=pk)
+
try:
from apps.notifications.services import NotificationService, get_email_header_html
+ from apps.organizations.department_contacts import get_champion_and_manager
+
+ targets = get_champion_and_manager(dept)
+ if not targets:
+ messages.error(request, f"No champion or manager assigned to {dept.name}.")
+ return redirect("observations:observation_detail", pk=pk)
recipients = []
- if dept.champion and dept.champion.user and dept.champion.user.email:
- recipients.append(dept.champion.user)
-
- for recipient in recipients:
- NotificationService.send_email(
- email=recipient.email,
- subject=f"Reminder: Observation {observation.tracking_code} - Response Required",
- message=f"This is a reminder that observation {observation.tracking_code} is awaiting your department's response. Please submit your response as soon as possible.",
- html_message=f"""
-
- {get_email_header_html()}
-
-
Reminder: Response Required
-
This is a reminder that observation {observation.tracking_code} is awaiting your department's response.
-
Please submit your response as soon as possible.
-
-
-""",
- related_object=observation,
- )
+ for target in targets:
+ email = target.get("email") or ""
+ if email:
+ recipients.append({"email": email, "name": target.get("display_name", "")})
if reminder_type == "first":
observation.dept_response_reminder_sent_at = timezone.now()
@@ -1802,6 +1780,9 @@ def observation_send_dept_response_reminder(request, pk):
observation.dept_response_second_reminder_sent_at = timezone.now()
observation.save()
+ from apps.observations.tasks import send_observation_reminder_email
+ send_observation_reminder_email.delay(str(observation.id), reminder_type, recipients)
+
ObservationNote.objects.create(
observation=observation,
note=f"Manual {reminder_type} reminder sent by {user.get_full_name()}",
@@ -1823,11 +1804,13 @@ def observation_send_dept_response_reminder(request, pk):
@login_required
-@permission_required("observations.manage_categories", raise_exception=True)
def category_list(request):
"""
List observation categories.
"""
+ if not (request.user.has_perm("observations.manage_categories") or request.user.is_px_admin() or request.user.is_px_employee()):
+ from django.core.exceptions import PermissionDenied
+ raise PermissionDenied
categories = ObservationCategory.objects.all().order_by("sort_order", "name_en")
context = {
@@ -1838,12 +1821,14 @@ def category_list(request):
@login_required
-@permission_required("observations.manage_categories", raise_exception=True)
@require_http_methods(["GET", "POST"])
def category_create(request):
"""
Create new observation category.
"""
+ if not (request.user.has_perm("observations.manage_categories") or request.user.is_px_admin() or request.user.is_px_employee()):
+ from django.core.exceptions import PermissionDenied
+ raise PermissionDenied
if request.method == "POST":
form = ObservationCategoryForm(request.POST)
if form.is_valid():
@@ -1862,12 +1847,14 @@ def category_create(request):
@login_required
-@permission_required("observations.manage_categories", raise_exception=True)
@require_http_methods(["GET", "POST"])
def category_edit(request, pk):
"""
Edit observation category.
"""
+ if not (request.user.has_perm("observations.manage_categories") or request.user.is_px_admin() or request.user.is_px_employee()):
+ from django.core.exceptions import PermissionDenied
+ raise PermissionDenied
category = get_object_or_404(ObservationCategory, pk=pk)
if request.method == "POST":
@@ -1889,12 +1876,14 @@ def category_edit(request, pk):
@login_required
-@permission_required("observations.manage_categories", raise_exception=True)
@require_http_methods(["POST"])
def category_delete(request, pk):
"""
Delete observation category.
"""
+ if not (request.user.has_perm("observations.manage_categories") or request.user.is_px_admin() or request.user.is_px_employee()):
+ from django.core.exceptions import PermissionDenied
+ raise PermissionDenied
category = get_object_or_404(ObservationCategory, pk=pk)
# Check if category is in use
diff --git a/apps/organizations/management/commands/export_sections.py b/apps/organizations/management/commands/export_sections.py
new file mode 100644
index 0000000..7e22adf
--- /dev/null
+++ b/apps/organizations/management/commands/export_sections.py
@@ -0,0 +1,59 @@
+"""
+Management command to export sections (English name, Arabic name, department) to a CSV file.
+
+Usage:
+ python manage.py export_sections --output-dir ./exports
+"""
+import csv
+import os
+
+from django.core.management.base import BaseCommand
+
+from apps.organizations.models import Section
+
+
+class Command(BaseCommand):
+ help = 'Export sections (English name, Arabic name, department) to a CSV file'
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ '--output-dir',
+ type=str,
+ default='./exports',
+ help='Directory to export the sections CSV file to',
+ )
+
+ def handle(self, *args, **options):
+ output_dir = options['output_dir']
+ os.makedirs(output_dir, exist_ok=True)
+
+ filepath = os.path.join(output_dir, 'sections.csv')
+ qs = (
+ Section.objects.all()
+ .select_related('department')
+ .order_by('department__name_en', 'name_en')
+ )
+
+ seen = set()
+ rows = []
+ for section in qs:
+ dept = section.department
+ row = (
+ section.name_en or '',
+ section.name_ar or '',
+ dept.name_en if dept else '',
+ dept.name_ar if dept else '',
+ )
+ if row in seen:
+ continue
+ seen.add(row)
+ rows.append(row)
+
+ with open(filepath, 'w', newline='', encoding='utf-8-sig') as f:
+ writer = csv.writer(f)
+ writer.writerow(['Name (EN)', 'Name (AR)', 'Department (EN)', 'Department (AR)'])
+ writer.writerows(rows)
+
+ self.stdout.write(self.style.SUCCESS(
+ f'Exported {len(rows)} unique sections to {filepath}'
+ ))
diff --git a/apps/organizations/ui_views.py b/apps/organizations/ui_views.py
index 6d07923..10b102e 100644
--- a/apps/organizations/ui_views.py
+++ b/apps/organizations/ui_views.py
@@ -1939,9 +1939,28 @@ def department_detail(request, pk):
"involved_staff__staff"
).distinct().order_by("-created_at")
- inquiries = Inquiry.objects.filter(
- Q(department=department, sent_to_department=True) | Q(outgoing_department=department)
- ).select_related("assigned_to", "patient").order_by("-created_at")
+ inquiry_direction = request.GET.get("inquiry_direction", "incoming")
+ sent_away = (
+ Q(department=department)
+ & Q(transferred_to_department__isnull=False)
+ & ~Q(transferred_to_department=department)
+ )
+ incoming_inquiries_qs = Inquiry.objects.filter(
+ Q(department=department, sent_to_department=True)
+ | Q(outgoing_department=department)
+ | Q(transferred_to_department=department)
+ ).exclude(sent_away)
+ outgoing_inquiries_qs = Inquiry.objects.filter(sent_away)
+
+ if inquiry_direction == "outgoing":
+ inquiries = outgoing_inquiries_qs
+ else:
+ inquiries = incoming_inquiries_qs
+ inquiries = inquiries.select_related("assigned_to", "patient", "department", "outgoing_department", "transferred_to_department").order_by("-created_at")
+
+ incoming_count = incoming_inquiries_qs.count()
+ outgoing_count = outgoing_inquiries_qs.count()
+ all_inquiries_count = incoming_count + outgoing_count
observations = Observation.objects.filter(
assigned_department=department, sent_to_department=True
@@ -2166,18 +2185,17 @@ def department_detail(request, pk):
pending_inquiries = Inquiry.objects.filter(
Q(outgoing_department=department) | Q(department=department, sent_to_department=True),
transferred_at__isnull=False,
- department_responded_at__isnull=True,
status__in=["open", "in_progress"],
).order_by("dept_response_sla_due_at")
for inq in pending_inquiries:
pending_actions.append({
"type": "inquiry_response",
- "type_label": _("Inquiry Response"),
+ "type_label": _("Inquiry to Handle"),
"reference": inq.reference_number or str(inq.id),
"subject": inq.subject or inq.message[:80] if inq.message else "",
"sla_due_at": inq.dept_response_sla_due_at,
"is_overdue": inq.dept_response_sla_due_at and inq.dept_response_sla_due_at < dj_tz.now(),
- "url": "/inquiries/{}/department-response/".format(inq.pk),
+ "url": "/organizations/departments/{}/inquiries/{}/".format(department.pk, inq.pk),
"badge_color": "cyan",
"item_id": str(inq.pk),
"existing_en": inq.department_response_en or "",
@@ -2241,6 +2259,10 @@ def department_detail(request, pk):
"staff_complaint_counts": staff_complaint_counts,
"complaints": complaints[:5],
"inquiries": inquiries[:5],
+ "inquiry_direction": inquiry_direction,
+ "incoming_count": incoming_count,
+ "outgoing_count": outgoing_count,
+ "all_inquiries_count": all_inquiries_count,
"observations": observations[:5],
"appreciations": appreciations[:50],
"suggestions": suggestions[:50],
@@ -2423,7 +2445,7 @@ def department_complaints_list(request, pk):
"complaint_dept_map": complaint_dept_map,
"status_choices": Complaint.Status.choices if hasattr(Complaint, 'Status') else [],
"list_stats": status_counts,
- "status_labels": {"open": _("Open"), "in_progress": _("In Progress"), "resolved": _("Resolved"), "closed": _("Closed")},
+ "status_labels": {"open": _("Open"), "in_progress": _("In Progress"), "resolved": _("Resolved"), "closed": _("Closed"), "contact_no_response": _("Contacted - No Response")},
}
return render(request, "organizations/department_complaints.html", context)
@@ -2442,9 +2464,26 @@ def department_inquiries_list(request, pk):
messages.error(request, _("You don't have permission to view this department."))
return redirect("organizations:department_list")
- queryset = Inquiry.objects.filter(
- Q(department=department, sent_to_department=True) | Q(outgoing_department=department)
- ).select_related("assigned_to", "patient").order_by("-created_at")
+ direction = request.GET.get("direction", "incoming")
+
+ # Direction split (department POV):
+ # outgoing = this department transferred the inquiry away to another department
+ # incoming = inquiries this department currently handles (owned & not sent away, or transferred TO it)
+ sent_away = (
+ Q(department=department)
+ & Q(transferred_to_department__isnull=False)
+ & ~Q(transferred_to_department=department)
+ )
+ if direction == "outgoing":
+ queryset = Inquiry.objects.filter(sent_away)
+ else:
+ queryset = Inquiry.objects.filter(
+ Q(department=department, sent_to_department=True)
+ | Q(outgoing_department=department)
+ | Q(transferred_to_department=department)
+ ).exclude(sent_away)
+
+ queryset = queryset.select_related("assigned_to", "patient").order_by("-created_at")
from django.db.models import Count
list_stats = queryset.values("status").annotate(count=Count("pk"))
@@ -2495,7 +2534,8 @@ def department_inquiries_list(request, pk):
"date_to": date_to or "",
"can_respond": can_respond,
"list_stats": status_counts,
- "status_labels": {"open": _("Open"), "in_progress": _("In Progress"), "resolved": _("Resolved"), "closed": _("Closed")},
+ "direction": direction,
+ "status_labels": {"open": _("Open"), "in_progress": _("In Progress"), "resolved": _("Resolved"), "closed": _("Closed"), "contact_no_response": _("Contacted - No Response")},
}
return render(request, "organizations/department_inquiries.html", context)
@@ -2731,12 +2771,8 @@ def department_inquiry_detail(request, pk, ipk):
can_respond = (
user.is_px_admin()
or user.is_hospital_admin()
- or (user.is_department_manager() and user.department == department)
- or (
- user.is_champion()
- and user.department
- and user.department in [inquiry.department, inquiry.outgoing_department]
- )
+ or (user.is_department_manager() and user.department_id == inquiry.handling_department_id)
+ or (user.is_champion() and user.department_id == inquiry.handling_department_id)
)
location_parts = []
@@ -2750,12 +2786,21 @@ def department_inquiry_detail(request, pk, ipk):
inquiry_ct = ContentType.objects.get_for_model(Inquiry)
notes = inquiry.notes.select_related("created_by").order_by("-created_at")
+ departments = (
+ Department.objects.filter(hospital=department.hospital, status="active")
+ .exclude(pk=department.pk)
+ .order_by("name_en")
+ )
+ users = User.objects.filter(hospital=department.hospital, is_active=True)
+
context = {
"department": department,
"inquiry": inquiry,
"location_str": " > ".join(location_parts) if location_parts else "",
"can_respond": can_respond,
"notes": notes,
+ "departments": departments,
+ "users": users,
"content_type_id": inquiry_ct.pk,
"object_id": inquiry.pk,
}
@@ -3084,6 +3129,17 @@ def department_record_inquiry(request, pk):
"created_at": inquiry.created_at.strftime("%Y-%m-%d %H:%M"),
"due_at": inquiry.due_at.strftime("%Y-%m-%d %H:%M") if hasattr(inquiry, 'due_at') and inquiry.due_at else None,
"ai_summary": inquiry.short_description_en if hasattr(inquiry, 'short_description_en') else "",
+ "department_response_en": inquiry.department_response_en or "",
+ "department_response_ar": inquiry.department_response_ar or "",
+ "department_responded_at": inquiry.department_responded_at.strftime("%Y-%m-%d %H:%M") if inquiry.department_responded_at else None,
+ "priority_display": inquiry.get_priority_display() if hasattr(inquiry, 'priority') and inquiry.priority else "",
+ "contact_status_display": inquiry.get_contact_status_display() if inquiry.contact_status else "",
+ "response": inquiry.response or inquiry.response_en or "",
+ "responded_at": inquiry.responded_at.strftime("%Y-%m-%d %H:%M") if inquiry.responded_at else None,
+ "responded_by": inquiry.responded_by.get_full_name() if inquiry.responded_by else None,
+ "resolved_at": inquiry.resolved_at.strftime("%Y-%m-%d %H:%M") if inquiry.resolved_at else None,
+ "resolved_by": inquiry.resolved_by.get_full_name() if inquiry.resolved_by else None,
+ "outgoing_department": inquiry.outgoing_department.get_localized_name() if inquiry.outgoing_department else None,
}
return JsonResponse(data)
diff --git a/apps/organizations/views.py b/apps/organizations/views.py
index d58e546..15efad6 100644
--- a/apps/organizations/views.py
+++ b/apps/organizations/views.py
@@ -928,7 +928,7 @@ def api_department_staff(request, department_id):
staff_qs = (
Staff.objects.select_related("user")
- .filter(department=dept, hospital_id=dept.hospital_id, user__is_active=True)
+ .filter(department=dept, hospital_id=dept.hospital_id, status="active")
.order_by("first_name", "last_name")
)
diff --git a/apps/presentations/views.py b/apps/presentations/views.py
index 9ce85bc..cd80e87 100644
--- a/apps/presentations/views.py
+++ b/apps/presentations/views.py
@@ -357,7 +357,33 @@ def presentation_generate(request):
@login_required
def template_list(request):
+ user = request.user
+ selected_hospital = getattr(request, "tenant_hospital", None)
+
templates = ReportTemplate.objects.select_related('created_by', 'hospital').prefetch_related('template_slides')
+
+ # Apply RBAC filters (previously missing entirely — every user saw all hospitals' templates)
+ if user.is_px_admin():
+ if selected_hospital:
+ templates = templates.filter(Q(hospital=selected_hospital) | Q(hospital__isnull=True))
+ elif user.is_hospital_admin() and user.hospital:
+ templates = templates.filter(Q(hospital=user.hospital) | Q(hospital__isnull=True))
+ elif user.is_px_management() or user.is_px_employee():
+ if user.hospital:
+ templates = templates.filter(Q(hospital=user.hospital) | Q(hospital__isnull=True))
+ else:
+ templates = templates.filter(hospital__isnull=True)
+ elif user.is_department_manager() and user.department:
+ templates = templates.filter(Q(hospital=user.hospital) | Q(hospital__isnull=True))
+ elif user.is_champion() and user.department:
+ templates = templates.filter(Q(hospital=user.hospital) | Q(hospital__isnull=True))
+ elif user.is_source_user():
+ templates = templates.filter(created_by=user)
+ elif user.hospital:
+ templates = templates.filter(Q(hospital=user.hospital) | Q(hospital__isnull=True))
+ else:
+ templates = templates.filter(hospital__isnull=True)
+
context = {
'templates': templates,
'data_sources': REPORT_DATA_SOURCES,
diff --git a/apps/projects/ui_views.py b/apps/projects/ui_views.py
index 1e0e1d5..2c1958a 100644
--- a/apps/projects/ui_views.py
+++ b/apps/projects/ui_views.py
@@ -52,7 +52,7 @@ def _notify_project_team(project):
if not recipient_ids:
return
- recipients = Staff.objects.filter(id__in=recipient_ids, user__isnull=False).select_related("user")
+ recipients = Staff.objects.filter(id__in=recipient_ids).select_related("user")
subject = f"New QI Project: {project.name}"
plain = (
@@ -71,7 +71,7 @@ def _notify_project_team(project):
"""
for sp in recipients:
- email = getattr(sp.user, "email", None)
+ email = sp.email or getattr(sp.user, "email", None)
if not email:
continue
try:
@@ -1165,7 +1165,12 @@ def template_list(request):
queryset = QIProject.objects.filter(is_template=True).select_related("hospital").prefetch_related("departments")
# Apply RBAC filters
- if not user.is_px_admin():
+ selected_hospital = getattr(request, "tenant_hospital", None)
+ if user.is_px_admin():
+ if selected_hospital:
+ queryset = queryset.filter(Q(hospital=selected_hospital) | Q(hospital__isnull=True))
+ else:
+ # hospital_admin path (only px_admin/hospital_admin reach this point — gated above)
queryset = queryset.filter(Q(hospital=user.hospital) | Q(hospital__isnull=True))
# Search
diff --git a/apps/surveys/his_views.py b/apps/surveys/his_views.py
index 59b20e3..41a618f 100644
--- a/apps/surveys/his_views.py
+++ b/apps/surveys/his_views.py
@@ -513,10 +513,21 @@ def bulk_job_list(request):
List all bulk survey jobs for the user.
"""
user = request.user
+ selected_hospital = getattr(request, "tenant_hospital", None)
- # Filter jobs
+ # Filter jobs by RBAC
if user.is_px_admin():
- jobs = BulkSurveyJob.objects.all()
+ if selected_hospital:
+ jobs = BulkSurveyJob.objects.filter(hospital=selected_hospital)
+ else:
+ jobs = BulkSurveyJob.objects.none()
+ elif user.is_hospital_admin() and user.hospital:
+ jobs = BulkSurveyJob.objects.filter(hospital=user.hospital)
+ elif user.is_px_management() or user.is_px_employee():
+ if user.hospital:
+ jobs = BulkSurveyJob.objects.filter(hospital=user.hospital)
+ else:
+ jobs = BulkSurveyJob.objects.none()
elif user.hospital:
jobs = BulkSurveyJob.objects.filter(hospital=user.hospital)
else:
diff --git a/apps/surveys/ui_views.py b/apps/surveys/ui_views.py
index 31a09bf..7f54b2e 100644
--- a/apps/surveys/ui_views.py
+++ b/apps/surveys/ui_views.py
@@ -599,10 +599,22 @@ def survey_comments_list(request):
# Apply RBAC filters
user = request.user
+ selected_hospital = getattr(request, "tenant_hospital", None)
+
if user.is_px_admin():
- pass # See all
+ if selected_hospital:
+ queryset = queryset.filter(survey_template__hospital=selected_hospital)
elif user.is_hospital_admin() and user.hospital:
queryset = queryset.filter(survey_template__hospital=user.hospital)
+ elif user.is_px_management() or user.is_px_employee():
+ if user.hospital:
+ queryset = queryset.filter(survey_template__hospital=user.hospital)
+ elif user.is_department_manager() and user.department:
+ queryset = queryset.filter(survey_template__department=user.department)
+ elif user.is_champion() and user.department:
+ queryset = queryset.filter(survey_template__department=user.department)
+ elif user.is_source_user():
+ queryset = queryset.filter(created_by=user)
elif user.hospital:
queryset = queryset.filter(survey_template__hospital=user.hospital)
else:
@@ -1817,8 +1829,19 @@ def survey_analytics_dashboard(request):
messages.error(request, "You don't have permission to view analytics.")
return redirect("surveys:instance_list")
- # Get available templates
- templates = SurveyTemplate.objects.filter(is_active=True).order_by("name")
+ # Get available templates (scoped to user's hospital; system-wide templates included)
+ selected_hospital = getattr(request, "tenant_hospital", None)
+ if user.is_px_admin():
+ scope_hospital = selected_hospital
+ else:
+ scope_hospital = user.hospital
+
+ templates = SurveyTemplate.objects.filter(is_active=True)
+ if scope_hospital:
+ templates = templates.filter(Q(hospital=scope_hospital) | Q(hospital__isnull=True))
+ else:
+ templates = templates.filter(hospital__isnull=True)
+ templates = templates.order_by("name")
# Get selected template from query param
selected_template_id = request.GET.get("template")
diff --git a/config/settings/base.py b/config/settings/base.py
index d709d02..2f209bf 100644
--- a/config/settings/base.py
+++ b/config/settings/base.py
@@ -441,7 +441,7 @@ DEFAULT_FROM_EMAIL = env("DEFAULT_FROM_EMAIL", default="noreply@px360.sa")
SITE_URL = env("SITE_URL", default="http://localhost:8000")
BASE_URL = SITE_URL
SURVEY_BASE_URL = SITE_URL
-EMAIL_LOGO_URL = env("EMAIL_LOGO_URL", default=f"{SITE_URL}{STATIC_URL}img/HH_P_H_Logo.png")
+EMAIL_LOGO_URL = env("EMAIL_LOGO_URL", default=f"{SITE_URL}{STATIC_URL}images/logos/HH_P_H_Logo_CMYK---LANDSCIP1.png")
# HIS Integration Settings
HIS_API_URL = env("HIS_API_URL", default="https://his.alhammadi.med.sa/SSRCE/API/FetchPatientVisitTimeStamps")
diff --git a/config/settings/dev.py b/config/settings/dev.py
index f8c5c8c..5532131 100644
--- a/config/settings/dev.py
+++ b/config/settings/dev.py
@@ -6,7 +6,7 @@ from .base import * # noqa
DEBUG = True
-ALLOWED_HOSTS = ["192.168.8.13","localhost", "127.0.0.1", "0.0.0.0", ".ngrok-free.app", "micha-nonparabolic-lovie.ngrok-free.dev"]
+ALLOWED_HOSTS = ["192.168.8.13","localhost", "127.0.0.1", "0.0.0.0"]
# Database - Use PostgreSQL even in dev for consistency
# Override with SQLite if needed for quick local testing
@@ -17,11 +17,7 @@ ALLOWED_HOSTS = ["192.168.8.13","localhost", "127.0.0.1", "0.0.0.0", ".ngrok-fre
# )
# }
-CSRF_TRUSTED_ORIGINS = [
- "https://*.ngrok-free.app",
- "https://*.ngrok.app",
- "https://micha-nonparabolic-lovie.ngrok-free.dev",
-]
+CSRF_TRUSTED_ORIGINS = []
# Database inherits from base.py (PostgreSQL)
# To temporarily switch back to SQLite for testing, uncomment:
diff --git a/deploy/compose.dev-home.yml b/deploy/compose.dev-home.yml
new file mode 100644
index 0000000..e44c869
--- /dev/null
+++ b/deploy/compose.dev-home.yml
@@ -0,0 +1,78 @@
+# PX360 — DEV compose for `target deploy` (bundled postgres + redis)
+#
+# target injects into the `web_app` service on deploy:
+# - container_name: