548 lines
19 KiB
Python
548 lines
19 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 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
|
|
|
|
@property
|
|
def name(self):
|
|
"""Return English name as default."""
|
|
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)
|
|
|
|
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):
|
|
"""
|
|
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,
|
|
db_index=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"
|
|
)
|
|
|
|
# Content
|
|
title = models.CharField(max_length=300, blank=True, help_text="Optional short title")
|
|
description = models.TextField(help_text="Detailed 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.)"
|
|
)
|
|
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")
|
|
|
|
# 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,
|
|
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
|
|
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",
|
|
)
|
|
|
|
# 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)
|
|
|
|
# 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)
|
|
|
|
# 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=["status", "-created_at"]),
|
|
models.Index(fields=["severity", "-created_at"]),
|
|
models.Index(fields=["tracking_code"]),
|
|
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_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"]
|
|
|
|
|
|
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}"
|