450 lines
13 KiB
Python
450 lines
13 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'
|
|
|
|
|
|
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 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=20,
|
|
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"
|
|
)
|
|
|
|
# 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)
|
|
|
|
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')
|
|
|
|
|
|
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=20,
|
|
choices=ObservationStatus.choices,
|
|
blank=True
|
|
)
|
|
to_status = models.CharField(
|
|
max_length=20,
|
|
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}"
|