HH/apps/observations/models.py

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}"