847 lines
29 KiB
Python
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}"
|