HH/apps/observations/models.py
ismail c5f76b3855
Some checks are pending
Build and Push Docker Image / build (push) Waiting to run
updates
2026-05-11 14:45:30 +03:00

847 lines
29 KiB
Python

"""
Observations models - Staff observation reporting for Al Hammadi.
This module implements the observation reporting system that:
- Allows anonymous submission (no login required)
- Supports optional staff identification
- Tracks observation lifecycle with status changes
- Links to departments and action center
- Maintains audit trail with notes and status logs
"""
import secrets
import string
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.utils import timezone
from apps.core.models import SoftDeleteModel, TimeStampedModel, UUIDModel
def generate_tracking_code():
"""Generate a unique tracking code for observations."""
# Format: OBS-XXXXXX (6 alphanumeric characters)
chars = string.ascii_uppercase + string.digits
code = "".join(secrets.choice(chars) for _ in range(6))
return f"OBS-{code}"
class ObservationSeverity(models.TextChoices):
"""Observation severity choices."""
LOW = "low", "Low"
MEDIUM = "medium", "Medium"
HIGH = "high", "High"
CRITICAL = "critical", "Critical"
class ObservationStatus(models.TextChoices):
"""Observation status choices."""
NEW = "new", "New"
TRIAGED = "triaged", "Triaged"
ASSIGNED = "assigned", "Assigned"
IN_PROGRESS = "in_progress", "In Progress"
RESOLVED = "resolved", "Resolved"
CLOSED = "closed", "Closed"
REJECTED = "rejected", "Rejected"
DUPLICATE = "duplicate", "Duplicate"
CONTACTED = "contacted", "Contacted"
CONTACTED_NO_RESPONSE = "contacted_no_response", "Contacted, No Response"
class ObservationCategory(UUIDModel, TimeStampedModel):
"""
Observation category for classifying reported issues.
Supports bilingual names (English and Arabic).
"""
name_en = models.CharField(max_length=200, verbose_name="Name (English)")
name_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)")
description = models.TextField(blank=True)
# Status and ordering
is_active = models.BooleanField(default=True, db_index=True)
sort_order = models.IntegerField(default=0, help_text="Lower numbers appear first")
# Icon for UI (optional)
icon = models.CharField(max_length=50, blank=True, help_text="Bootstrap icon class")
class Meta:
ordering = ["sort_order", "name_en"]
verbose_name = "Observation Category"
verbose_name_plural = "Observation Categories"
def __str__(self):
return self.name_en
def get_localized_name(self):
from django.utils.translation import get_language
if get_language() == "ar" and self.name_ar:
return self.name_ar
return self.name_en
@property
def name(self):
"""Return English name as default."""
return self.name_en
class ObservationSubCategory(UUIDModel, TimeStampedModel):
"""
Observation sub-category for finer-grained classification.
Each sub-category belongs to a parent ObservationCategory.
Supports bilingual names (English and Arabic).
"""
category = models.ForeignKey(
ObservationCategory, on_delete=models.CASCADE, related_name="sub_categories"
)
name_en = models.CharField(max_length=200, verbose_name="Name (English)")
name_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)")
description = models.TextField(blank=True)
is_active = models.BooleanField(default=True, db_index=True)
sort_order = models.IntegerField(default=0, help_text="Lower numbers appear first")
class Meta:
ordering = ["sort_order", "name_en"]
verbose_name = "Observation Sub-Category"
verbose_name_plural = "Observation Sub-Categories"
def __str__(self):
return f"{self.category.name_en} / {self.name_en}"
def get_localized_name(self):
from django.utils.translation import get_language
if get_language() == "ar" and self.name_ar:
return self.name_ar
return self.name_en
@property
def name(self):
return self.name_en
class ObservationSLAConfig(UUIDModel, TimeStampedModel):
"""
SLA configuration for observations per hospital and severity.
Allows flexible SLA configuration for observation resolution times.
"""
hospital = models.ForeignKey(
"organizations.Hospital", on_delete=models.CASCADE, related_name="observation_sla_configs"
)
severity = models.CharField(
max_length=20,
choices=ObservationSeverity.choices,
null=True,
blank=True,
help_text="Severity level for this SLA (optional = default config)",
)
sla_hours = models.IntegerField(help_text="Number of hours until SLA deadline")
first_reminder_hours_after = models.IntegerField(
default=0, help_text="Send 1st reminder X hours after observation creation (0 = use reminder_hours_before)"
)
second_reminder_hours_after = models.IntegerField(
default=0,
help_text="Send 2nd reminder X hours after observation creation (0 = use second_reminder_hours_before)",
)
escalation_hours_after = models.IntegerField(
default=0, help_text="Escalate observation X hours after creation if unresolved (0 = use overdue logic)"
)
reminder_hours_before = models.IntegerField(default=24, help_text="Send first reminder X hours before deadline")
second_reminder_enabled = models.BooleanField(default=False, help_text="Enable sending a second reminder")
second_reminder_hours_before = models.IntegerField(
default=6, help_text="Send second reminder X hours before deadline"
)
is_active = models.BooleanField(default=True)
# Department response SLA fields
dept_response_hours = models.IntegerField(
default=48, help_text="Hours for department to submit a response"
)
dept_response_reminder_hours_before = models.IntegerField(
default=12, help_text="Send 1st reminder X hours before dept response deadline"
)
dept_response_second_reminder_enabled = models.BooleanField(
default=True, help_text="Enable sending a second reminder for dept response"
)
dept_response_second_reminder_hours_before = models.IntegerField(
default=4, help_text="Send 2nd reminder X hours before dept response deadline"
)
dept_response_auto_escalate_enabled = models.BooleanField(
default=True, help_text="Auto-escalate to department manager if response overdue"
)
dept_response_escalation_hours_overdue = models.IntegerField(
default=0, help_text="Escalate X hours after dept response deadline (0 = immediately)"
)
class Meta:
ordering = ["hospital", "severity"]
verbose_name = "Observation SLA Config"
verbose_name_plural = "Observation SLA Configs"
indexes = [
models.Index(fields=["hospital", "is_active"]),
models.Index(fields=["hospital", "severity", "is_active"]),
]
def __str__(self):
sev_display = self.severity if self.severity else "Default"
return f"{self.hospital.name} - {sev_display} - {self.sla_hours}h"
def get_first_reminder_hours_after(self, observation_created_at=None):
if self.first_reminder_hours_after > 0:
return self.first_reminder_hours_after
else:
return max(0, self.sla_hours - self.reminder_hours_before)
def get_second_reminder_hours_after(self, observation_created_at=None):
if self.second_reminder_hours_after > 0:
return self.second_reminder_hours_after
elif self.second_reminder_enabled:
return max(0, self.sla_hours - self.second_reminder_hours_before)
else:
return 0
def get_escalation_hours_after(self, observation_created_at=None):
if self.escalation_hours_after > 0:
return self.escalation_hours_after
else:
return None
class Observation(UUIDModel, TimeStampedModel, SoftDeleteModel):
"""
Observation - Staff-reported issue or concern.
Key features:
- Anonymous submission supported (no login required)
- Optional reporter identification (staff_id, name)
- Unique tracking code for public lookup
- Full lifecycle management with status tracking
- Links to departments and action center
"""
# Tracking
tracking_code = models.CharField(
max_length=20,
unique=True,
default=generate_tracking_code,
help_text="Unique code for tracking this observation",
)
# Classification
category = models.ForeignKey(
ObservationCategory, on_delete=models.SET_NULL, null=True, blank=True, related_name="observations"
)
sub_category = models.ForeignKey(
ObservationSubCategory,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="observations",
help_text="Observation sub-category for finer classification",
)
# AI Taxonomy — 4-level SHCT taxonomy (reuse ComplaintCategory hierarchy)
taxonomy_domain = models.ForeignKey(
"complaints.ComplaintCategory",
on_delete=models.SET_NULL,
related_name="observations_domain",
null=True,
blank=True,
help_text="Level 1: Domain",
)
taxonomy_category = models.ForeignKey(
"complaints.ComplaintCategory",
on_delete=models.SET_NULL,
related_name="observations_category",
null=True,
blank=True,
help_text="Level 2: Category",
)
taxonomy_subcategory = models.ForeignKey(
"complaints.ComplaintCategory",
on_delete=models.SET_NULL,
related_name="observations_subcategory",
null=True,
blank=True,
help_text="Level 3: Subcategory",
)
taxonomy_classification = models.ForeignKey(
"complaints.ComplaintCategory",
on_delete=models.SET_NULL,
related_name="observations_classification",
null=True,
blank=True,
help_text="Level 4: Classification",
)
# Content
title = models.CharField(max_length=300, blank=True, help_text="Optional short title")
description = models.TextField(help_text="Detailed description of the observation")
description_en = models.TextField(blank=True, help_text="English description of the observation")
# Severity
severity = models.CharField(
max_length=20, choices=ObservationSeverity.choices, default=ObservationSeverity.MEDIUM, db_index=True
)
# Location and timing
location_text = models.CharField(
max_length=500, blank=True, help_text="Where the issue was observed (building, floor, room, etc.)"
)
location = models.ForeignKey(
"organizations.Location",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="observations",
help_text="Location where the observation was made",
)
main_section = models.ForeignKey(
"organizations.MainSection",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="observations",
help_text="Main section within the location",
)
subsection = models.ForeignKey(
"organizations.SubSection",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="observations",
help_text="Specific subsection",
)
incident_datetime = models.DateTimeField(default=timezone.now, help_text="When the issue was observed")
# 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_email = models.EmailField(blank=True, help_text="Optional email for follow-up")
# Patient / subject information
patient_file_number = models.CharField(
max_length=100, blank=True, help_text="Medical record number / file number of the patient"
)
# Status and workflow
status = models.CharField(
max_length=25, choices=ObservationStatus.choices, default=ObservationStatus.NEW, db_index=True
)
# Organization (required for tenant isolation)
hospital = models.ForeignKey(
"organizations.Hospital",
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="observations",
help_text="Hospital where observation was made",
)
# Staff member mentioned in observation (optional, for AI-matching like complaints)
staff = models.ForeignKey(
"organizations.Staff",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="observations",
help_text="Staff member mentioned in observation",
)
# Source tracking - FK to PXSource
px_source = models.ForeignKey(
"px_sources.PXSource",
on_delete=models.PROTECT,
related_name="observations",
null=True,
blank=True,
help_text="Source of observation",
)
# Legacy source field for backward compatibility
source = models.CharField(
max_length=50,
choices=[
("staff_portal", "Staff Portal"),
("web_form", "Web Form"),
("mobile_app", "Mobile App"),
("email", "Email"),
("call_center", "Call Center"),
("other", "Other"),
],
default="staff_portal",
help_text="How the observation was submitted (legacy)",
)
# Internal routing
assigned_department = models.ForeignKey(
"organizations.Department",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="assigned_observations",
help_text="Department responsible for handling this observation",
)
assigned_to = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="assigned_observations",
help_text="User assigned to handle this observation",
)
assigned_at = models.DateTimeField(null=True, blank=True)
# Communication tracking (person/department notified + how/when)
person_noted = models.CharField(
max_length=300, blank=True, help_text="Person who was informed/notified about the observation"
)
department_noted = models.ForeignKey(
"organizations.Department",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="noted_observations",
help_text="Department that was notified about this observation",
)
communication_method = models.CharField(
max_length=50, blank=True, help_text="How communication was made (extension, mobile, office, etc.)"
)
communication_datetime = models.DateTimeField(
null=True, blank=True, help_text="When the person/department was contacted"
)
# Activation tracking
activated_at = models.DateTimeField(
null=True,
blank=True,
db_index=True,
help_text="Timestamp when observation was first activated (moved to IN_PROGRESS)",
)
# SLA tracking
due_at = models.DateTimeField(null=True, blank=True, db_index=True, help_text="SLA deadline")
is_overdue = models.BooleanField(default=False, db_index=True)
breached_at = models.DateTimeField(
null=True, blank=True, db_index=True, help_text="Timestamp when observation first breached SLA"
)
reminder_sent_at = models.DateTimeField(null=True, blank=True, help_text="First SLA reminder timestamp")
second_reminder_sent_at = models.DateTimeField(null=True, blank=True, help_text="Second SLA reminder timestamp")
escalated_at = models.DateTimeField(null=True, blank=True)
# Triage information
triaged_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="triaged_observations"
)
triaged_at = models.DateTimeField(null=True, blank=True)
# Department response (for outgoing observations)
department_response_en = models.TextField(blank=True, verbose_name="Department Response (English)")
department_response_ar = models.TextField(blank=True, verbose_name="Department Response (Arabic)")
department_response_summary_en = models.TextField(blank=True, verbose_name="AI Summary of Dept Response (EN)")
department_response_summary_ar = models.TextField(blank=True, verbose_name="AI Summary of Dept Response (AR)")
department_responded_at = models.DateTimeField(null=True, blank=True)
department_responded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="department_observation_responses",
)
# Forwarded to department tracking
forwarded_to_dept_at = models.DateTimeField(
null=True, blank=True, help_text="When the observation was sent to the department",
)
# Department response SLA tracking
dept_response_sla_due_at = models.DateTimeField(
null=True, blank=True, db_index=True,
help_text="SLA deadline for department response",
)
dept_response_is_overdue = models.BooleanField(
default=False, db_index=True,
help_text="Department response is overdue",
)
dept_response_reminder_sent_at = models.DateTimeField(
null=True, blank=True, help_text="First SLA reminder for dept response",
)
dept_response_second_reminder_sent_at = models.DateTimeField(
null=True, blank=True, help_text="Second SLA reminder for dept response",
)
dept_response_escalated_at = models.DateTimeField(
null=True, blank=True, help_text="When dept response was escalated to manager",
)
# Department response acceptance review
dept_response_acceptance_status = models.CharField(
max_length=20,
choices=[
("pending", "Pending Review"),
("acceptable", "Acceptable"),
("not_acceptable", "Not Acceptable"),
],
default="pending",
help_text="Review status of the department response",
)
dept_response_accepted_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="reviewed_observation_dept_responses",
help_text="User who reviewed the department response",
)
dept_response_accepted_at = models.DateTimeField(
null=True, blank=True, help_text="When the department response was reviewed",
)
dept_response_acceptance_notes = models.TextField(
blank=True, help_text="Notes about the acceptance decision",
)
# Resolution
resolved_at = models.DateTimeField(null=True, blank=True)
resolved_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="resolved_observations"
)
resolution_notes = models.TextField(blank=True)
# Closure
closed_at = models.DateTimeField(null=True, blank=True)
closed_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="closed_observations"
)
# Link to Action Center (if converted to action)
# Using GenericForeignKey on PXAction side, store action_id here for quick reference
action_id = models.UUIDField(null=True, blank=True, help_text="ID of linked PX Action if converted")
# Metadata
client_ip = models.GenericIPAddressField(null=True, blank=True)
user_agent = models.TextField(blank=True)
metadata = models.JSONField(default=dict, blank=True)
# Notification tracking
submitter_notified_at = models.DateTimeField(
null=True, blank=True, help_text="When confirmation message was sent to submitter"
)
responsible_person_notified_at = models.DateTimeField(
null=True, blank=True, help_text="When email was sent to responsible person"
)
# Monthly follow-up tracking
monthly_follow_up_due_at = models.DateTimeField(
null=True, blank=True, db_index=True, help_text="When monthly follow-up is due"
)
monthly_follow_up_completed_at = models.DateTimeField(
null=True, blank=True, help_text="When monthly follow-up was completed"
)
monthly_follow_up_completed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="completed_observation_followups",
help_text="User who completed the monthly follow-up",
)
monthly_follow_up_notes = models.TextField(blank=True, help_text="Notes from monthly follow-up")
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["hospital", "status", "-created_at"]),
models.Index(fields=["severity", "-created_at"]),
models.Index(fields=["assigned_department", "status"]),
models.Index(fields=["assigned_to", "status"]),
]
permissions = [
("triage_observation", "Can triage observations"),
("manage_categories", "Can manage observation categories"),
]
def __str__(self):
return f"{self.tracking_code} - {self.title or self.description[:50]}"
def save(self, *args, **kwargs):
"""Ensure tracking code is unique."""
if not self.tracking_code:
self.tracking_code = generate_tracking_code()
# Ensure uniqueness
while Observation.objects.filter(tracking_code=self.tracking_code).exclude(pk=self.pk).exists():
self.tracking_code = generate_tracking_code()
super().save(*args, **kwargs)
@property
def is_anonymous(self):
"""Check if the observation was submitted anonymously."""
return not (self.reporter_staff_id or self.reporter_name)
@property
def reporter_display(self):
"""Get display name for reporter."""
if self.reporter_name:
return self.reporter_name
if self.reporter_staff_id:
return f"Staff ID: {self.reporter_staff_id}"
return "Anonymous"
def get_absolute_url(self):
from django.urls import reverse
return reverse("observations:observation_detail", kwargs={"pk": self.pk})
def get_severity_color(self):
"""Get Bootstrap color class for severity."""
colors = {
"low": "success",
"medium": "warning",
"high": "danger",
"critical": "dark",
}
return colors.get(self.severity, "secondary")
def get_status_color(self):
"""Get Bootstrap color class for status."""
colors = {
"new": "primary",
"triaged": "info",
"assigned": "info",
"in_progress": "warning",
"resolved": "success",
"closed": "secondary",
"rejected": "danger",
"duplicate": "secondary",
}
return colors.get(self.status, "secondary")
def get_sla_config(self):
"""Get SLA configuration for this observation based on hospital and severity."""
try:
if self.severity:
config = ObservationSLAConfig.objects.filter(
hospital=self.hospital,
severity=self.severity,
is_active=True,
).first()
if config:
return config
config = ObservationSLAConfig.objects.filter(
hospital=self.hospital,
is_active=True,
).first()
if config:
return config
except Exception:
pass
return None
def check_overdue(self):
"""Check if observation is overdue and update status"""
inactive_statuses = ["resolved", "closed", "rejected", "duplicate"]
if self.status in inactive_statuses:
return False
if self.due_at and timezone.now() > self.due_at:
if not self.is_overdue:
self.is_overdue = True
self.breached_at = timezone.now()
self.save(update_fields=["is_overdue", "breached_at"])
return True
return False
@property
def is_active_status(self):
"""
Check if observation is in an active status (can be worked on).
Active statuses: new, triaged, assigned, in_progress
Inactive statuses: resolved, closed, rejected, duplicate
"""
return self.status in ["new", "triaged", "assigned", "in_progress"]
@property
def has_ai_analysis(self):
return bool(self.metadata and self.metadata.get("ai_analysis"))
@property
def ai_analysis(self):
return self.metadata.get("ai_analysis", {}) if self.metadata else {}
@property
def short_description_en(self):
return self.ai_analysis.get("short_description_en", "")
@property
def short_description_ar(self):
return self.ai_analysis.get("short_description_ar", "")
@property
def ai_brief_en(self):
return self.ai_analysis.get("brief_summary_en", "")
@property
def ai_brief_ar(self):
return self.ai_analysis.get("brief_summary_ar", "")
@property
def suggested_action_en(self):
return self.ai_analysis.get("suggested_action_en", "")
@property
def suggested_action_ar(self):
return self.ai_analysis.get("suggested_action_ar", "")
@property
def suggested_actions(self):
return self.ai_analysis.get("suggested_actions", [])
@property
def emotion(self):
return self.ai_analysis.get("emotion", "")
@property
def emotion_intensity(self):
return self.ai_analysis.get("emotion_intensity", 0.0)
@property
def emotion_confidence(self):
return self.ai_analysis.get("emotion_confidence", 0.0)
@property
def emotion_intensity_percent(self):
return round(self.emotion_intensity * 100)
@property
def emotion_confidence_percent(self):
return round(self.emotion_confidence * 100)
@property
def reasoning_en(self):
return self.ai_analysis.get("reasoning_en", "")
@property
def reasoning_ar(self):
return self.ai_analysis.get("reasoning_ar", "")
class ObservationAttachment(UUIDModel, TimeStampedModel):
"""
Attachment for an observation (photos, documents, etc.).
"""
observation = models.ForeignKey(Observation, on_delete=models.CASCADE, related_name="attachments")
file = models.FileField(upload_to="observations/%Y/%m/%d/", help_text="Uploaded file")
filename = models.CharField(max_length=500, blank=True)
file_type = models.CharField(max_length=100, blank=True)
file_size = models.IntegerField(default=0, help_text="File size in bytes")
description = models.CharField(max_length=500, blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"{self.observation.tracking_code} - {self.filename}"
def save(self, *args, **kwargs):
"""Extract file metadata on save."""
if self.file:
if not self.filename:
self.filename = self.file.name
if not self.file_size and hasattr(self.file, "size"):
self.file_size = self.file.size
if not self.file_type:
import mimetypes
self.file_type = mimetypes.guess_type(self.file.name)[0] or ""
super().save(*args, **kwargs)
class ObservationNote(UUIDModel, TimeStampedModel):
"""
Internal note on an observation.
Used by PX360 staff to add comments and updates.
"""
observation = models.ForeignKey(Observation, on_delete=models.CASCADE, related_name="notes")
note = models.TextField()
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="observation_notes"
)
# Flag for internal-only notes
is_internal = models.BooleanField(default=True, help_text="Internal notes are not visible to public")
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"Note on {self.observation.tracking_code} by {self.created_by}"
class ObservationStatusLog(UUIDModel, TimeStampedModel):
"""
Status change log for observations.
Tracks all status transitions for audit trail.
"""
observation = models.ForeignKey(Observation, on_delete=models.CASCADE, related_name="status_logs")
from_status = models.CharField(max_length=25, choices=ObservationStatus.choices, blank=True)
to_status = models.CharField(max_length=25, choices=ObservationStatus.choices)
changed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="observation_status_changes",
)
comment = models.TextField(blank=True, help_text="Optional comment about the status change")
class Meta:
ordering = ["-created_at"]
verbose_name = "Observation Status Log"
verbose_name_plural = "Observation Status Logs"
def __str__(self):
return f"{self.observation.tracking_code}: {self.from_status}{self.to_status}"