596 lines
20 KiB
Python
596 lines
20 KiB
Python
"""
|
|
Observations services - Business logic for observation management.
|
|
|
|
This module provides services for:
|
|
- Creating and managing observations
|
|
- Converting observations to PX Actions
|
|
- Sending notifications
|
|
- Status management with audit logging
|
|
"""
|
|
|
|
import logging
|
|
from typing import Optional
|
|
|
|
from django.contrib.auth import get_user_model
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.db import transaction
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
|
|
from apps.notifications.services import NotificationService
|
|
from apps.organizations.models import Department, Hospital
|
|
|
|
from .models import (
|
|
Observation,
|
|
ObservationAttachment,
|
|
ObservationNote,
|
|
ObservationStatus,
|
|
ObservationStatusLog,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
User = get_user_model()
|
|
|
|
|
|
class ObservationService:
|
|
"""
|
|
Service class for observation management.
|
|
"""
|
|
|
|
@staticmethod
|
|
@transaction.atomic
|
|
def create_observation(
|
|
description: str,
|
|
severity: str = "medium",
|
|
category=None,
|
|
title: str = "",
|
|
location_text: str = "",
|
|
incident_datetime=None,
|
|
reporter_staff_id: str = "",
|
|
reporter_name: str = "",
|
|
reporter_phone: str = "",
|
|
reporter_email: str = "",
|
|
client_ip: str = None,
|
|
user_agent: str = "",
|
|
attachments: list = None,
|
|
) -> Observation:
|
|
"""
|
|
Create a new observation.
|
|
|
|
Args:
|
|
description: Detailed description of the observation
|
|
severity: Severity level (low, medium, high, critical)
|
|
category: ObservationCategory instance (optional)
|
|
title: Short title (optional)
|
|
location_text: Location description (optional)
|
|
incident_datetime: When the incident occurred (optional, defaults to now)
|
|
reporter_staff_id: Staff ID of reporter (optional)
|
|
reporter_name: Name of reporter (optional)
|
|
reporter_phone: Phone of reporter (optional)
|
|
reporter_email: Email of reporter (optional)
|
|
client_ip: IP address of submitter (optional)
|
|
user_agent: Browser user agent (optional)
|
|
attachments: List of uploaded files (optional)
|
|
|
|
Returns:
|
|
Created Observation instance
|
|
"""
|
|
observation = Observation.objects.create(
|
|
description=description,
|
|
severity=severity,
|
|
category=category,
|
|
title=title,
|
|
location_text=location_text,
|
|
incident_datetime=incident_datetime or timezone.now(),
|
|
reporter_staff_id=reporter_staff_id,
|
|
reporter_name=reporter_name,
|
|
reporter_phone=reporter_phone,
|
|
reporter_email=reporter_email,
|
|
client_ip=client_ip,
|
|
user_agent=user_agent,
|
|
)
|
|
|
|
# Create initial status log
|
|
ObservationStatusLog.objects.create(
|
|
observation=observation, from_status="", to_status=ObservationStatus.NEW, comment="Observation submitted"
|
|
)
|
|
|
|
# Handle attachments
|
|
if attachments:
|
|
for file in attachments:
|
|
ObservationAttachment.objects.create(
|
|
observation=observation,
|
|
file=file,
|
|
)
|
|
|
|
# Send notification to PX360 triage team
|
|
ObservationService.notify_new_observation(observation)
|
|
|
|
logger.info(f"Created observation {observation.tracking_code}")
|
|
return observation
|
|
|
|
@staticmethod
|
|
@transaction.atomic
|
|
def change_status(
|
|
observation: Observation,
|
|
new_status: str,
|
|
changed_by: User = None,
|
|
comment: str = "",
|
|
) -> ObservationStatusLog:
|
|
"""
|
|
Change observation status with audit logging.
|
|
|
|
Args:
|
|
observation: Observation instance
|
|
new_status: New status value
|
|
changed_by: User making the change (optional)
|
|
comment: Comment about the change (optional)
|
|
|
|
Returns:
|
|
Created ObservationStatusLog instance
|
|
"""
|
|
old_status = observation.status
|
|
|
|
# Update observation
|
|
observation.status = new_status
|
|
|
|
# Handle status-specific updates
|
|
if new_status == ObservationStatus.TRIAGED:
|
|
observation.triaged_at = timezone.now()
|
|
observation.triaged_by = changed_by
|
|
elif new_status == ObservationStatus.IN_PROGRESS:
|
|
if not observation.activated_at:
|
|
observation.activated_at = timezone.now()
|
|
if not observation.due_at:
|
|
sla_config = observation.get_sla_config()
|
|
if sla_config:
|
|
from datetime import timedelta
|
|
|
|
observation.due_at = observation.activated_at + timedelta(hours=sla_config.sla_hours)
|
|
elif new_status == ObservationStatus.RESOLVED:
|
|
observation.resolved_at = timezone.now()
|
|
observation.resolved_by = changed_by
|
|
elif new_status == ObservationStatus.CLOSED:
|
|
observation.closed_at = timezone.now()
|
|
observation.closed_by = changed_by
|
|
|
|
observation.save()
|
|
|
|
# Create status log
|
|
status_log = ObservationStatusLog.objects.create(
|
|
observation=observation,
|
|
from_status=old_status,
|
|
to_status=new_status,
|
|
changed_by=changed_by,
|
|
comment=comment,
|
|
)
|
|
|
|
# Send notifications based on status change
|
|
if new_status == ObservationStatus.ASSIGNED and observation.assigned_to:
|
|
ObservationService.notify_assignment(observation)
|
|
elif new_status in [ObservationStatus.RESOLVED, ObservationStatus.CLOSED]:
|
|
ObservationService.notify_resolution(observation)
|
|
|
|
logger.info(
|
|
f"Observation {observation.tracking_code} status changed: {old_status} -> {new_status} by {changed_by}"
|
|
)
|
|
|
|
return status_log
|
|
|
|
@staticmethod
|
|
@transaction.atomic
|
|
def triage_observation(
|
|
observation: Observation,
|
|
triaged_by: User,
|
|
assigned_department: Department = None,
|
|
assigned_to: User = None,
|
|
new_status: str = None,
|
|
note: str = "",
|
|
) -> Observation:
|
|
"""
|
|
Triage an observation - assign department/owner and update status.
|
|
|
|
Args:
|
|
observation: Observation instance
|
|
triaged_by: User performing the triage
|
|
assigned_department: Department to assign (optional)
|
|
assigned_to: User to assign (optional)
|
|
new_status: New status (optional, defaults to TRIAGED or ASSIGNED)
|
|
note: Triage note (optional)
|
|
|
|
Returns:
|
|
Updated Observation instance
|
|
"""
|
|
# Update assignment
|
|
if assigned_department:
|
|
observation.assigned_department = assigned_department
|
|
if assigned_to:
|
|
observation.assigned_to = assigned_to
|
|
|
|
# Determine new status
|
|
if not new_status:
|
|
if assigned_to:
|
|
new_status = ObservationStatus.ASSIGNED
|
|
else:
|
|
new_status = ObservationStatus.TRIAGED
|
|
|
|
observation.save()
|
|
|
|
# Change status
|
|
ObservationService.change_status(
|
|
observation=observation,
|
|
new_status=new_status,
|
|
changed_by=triaged_by,
|
|
comment=note or f"Triaged by {triaged_by.get_full_name()}",
|
|
)
|
|
|
|
# Add note if provided
|
|
if note:
|
|
ObservationNote.objects.create(
|
|
observation=observation,
|
|
note=note,
|
|
created_by=triaged_by,
|
|
is_internal=True,
|
|
)
|
|
|
|
return observation
|
|
|
|
@staticmethod
|
|
@transaction.atomic
|
|
def convert_to_action(
|
|
observation: Observation,
|
|
created_by: User,
|
|
title: str = None,
|
|
description: str = None,
|
|
category: str = "other",
|
|
priority: str = None,
|
|
assigned_department: Department = None,
|
|
assigned_to: User = None,
|
|
):
|
|
"""
|
|
Convert an observation to a PX Action.
|
|
|
|
Args:
|
|
observation: Observation instance
|
|
created_by: User creating the action
|
|
title: Action title (optional, defaults to observation title)
|
|
description: Action description (optional)
|
|
category: Action category
|
|
priority: Action priority (optional, derived from severity)
|
|
assigned_department: Department to assign (optional)
|
|
assigned_to: User to assign (optional)
|
|
|
|
Returns:
|
|
Created PXAction instance
|
|
"""
|
|
from apps.px_action_center.models import ActionSource, PXAction
|
|
|
|
# Get hospital from department or use first hospital
|
|
hospital = None
|
|
if assigned_department:
|
|
hospital = assigned_department.hospital
|
|
elif observation.assigned_department:
|
|
hospital = observation.assigned_department.hospital
|
|
else:
|
|
hospital = Hospital.objects.first()
|
|
|
|
if not hospital:
|
|
raise ValueError("No hospital found for creating action")
|
|
|
|
# Prepare title and description
|
|
if not title:
|
|
title = f"Observation {observation.tracking_code}"
|
|
if observation.title:
|
|
title += f" - {observation.title}"
|
|
elif observation.category:
|
|
title += f" - {observation.category.name_en}"
|
|
|
|
if not description:
|
|
description = f"""
|
|
Observation Details:
|
|
- Tracking Code: {observation.tracking_code}
|
|
- Category: {observation.category.name_en if observation.category else "N/A"}
|
|
- Severity: {observation.get_severity_display()}
|
|
- Location: {observation.location_text or "N/A"}
|
|
- Incident Date: {observation.incident_datetime.strftime("%Y-%m-%d %H:%M")}
|
|
- Reporter: {observation.reporter_display}
|
|
|
|
Description:
|
|
{observation.description}
|
|
|
|
View observation: /observations/{observation.id}/
|
|
""".strip()
|
|
|
|
# Map severity to priority
|
|
if not priority:
|
|
severity_to_priority = {
|
|
"low": "low",
|
|
"medium": "medium",
|
|
"high": "high",
|
|
"critical": "critical",
|
|
}
|
|
priority = severity_to_priority.get(observation.severity, "medium")
|
|
|
|
# Create PX Action
|
|
action = PXAction.objects.create(
|
|
source_type=ActionSource.MANUAL,
|
|
content_type=ContentType.objects.get_for_model(Observation),
|
|
object_id=observation.id,
|
|
title=title,
|
|
description=description,
|
|
hospital=hospital,
|
|
department=assigned_department or observation.assigned_department,
|
|
category=category,
|
|
priority=priority,
|
|
severity=observation.severity,
|
|
assigned_to=assigned_to or observation.assigned_to,
|
|
metadata={
|
|
"observation_tracking_code": observation.tracking_code,
|
|
"observation_id": str(observation.id),
|
|
},
|
|
)
|
|
|
|
# Update observation with action link
|
|
observation.action_id = action.id
|
|
observation.save(update_fields=["action_id"])
|
|
|
|
# Add note to observation
|
|
ObservationNote.objects.create(
|
|
observation=observation,
|
|
note=f"Converted to PX Action: {action.id}",
|
|
created_by=created_by,
|
|
is_internal=True,
|
|
)
|
|
|
|
logger.info(f"Observation {observation.tracking_code} converted to action {action.id}")
|
|
|
|
return action
|
|
|
|
@staticmethod
|
|
def add_note(
|
|
observation: Observation,
|
|
note: str,
|
|
created_by: User,
|
|
is_internal: bool = True,
|
|
) -> ObservationNote:
|
|
"""
|
|
Add a note to an observation.
|
|
|
|
Args:
|
|
observation: Observation instance
|
|
note: Note text
|
|
created_by: User creating the note
|
|
is_internal: Whether the note is internal-only
|
|
|
|
Returns:
|
|
Created ObservationNote instance
|
|
"""
|
|
return ObservationNote.objects.create(
|
|
observation=observation,
|
|
note=note,
|
|
created_by=created_by,
|
|
is_internal=is_internal,
|
|
)
|
|
|
|
@staticmethod
|
|
def add_attachment(
|
|
observation: Observation,
|
|
file,
|
|
description: str = "",
|
|
) -> ObservationAttachment:
|
|
"""
|
|
Add an attachment to an observation.
|
|
|
|
Args:
|
|
observation: Observation instance
|
|
file: Uploaded file
|
|
description: File description (optional)
|
|
|
|
Returns:
|
|
Created ObservationAttachment instance
|
|
"""
|
|
return ObservationAttachment.objects.create(
|
|
observation=observation,
|
|
file=file,
|
|
description=description,
|
|
)
|
|
|
|
@staticmethod
|
|
def notify_new_observation(observation: Observation):
|
|
"""
|
|
Send notification for new observation to PX360 triage team.
|
|
|
|
Args:
|
|
observation: Observation instance
|
|
"""
|
|
try:
|
|
# Get PX Admin users to notify
|
|
px_admins = User.objects.filter(is_active=True, groups__name="PX Admin")
|
|
|
|
subject = f"New Observation: {observation.tracking_code}"
|
|
message = f"""
|
|
A new observation has been submitted and requires triage.
|
|
|
|
Tracking Code: {observation.tracking_code}
|
|
Category: {observation.category.name_en if observation.category else "N/A"}
|
|
Severity: {observation.get_severity_display()}
|
|
Location: {observation.location_text or "N/A"}
|
|
Reporter: {observation.reporter_display}
|
|
|
|
Description:
|
|
{observation.description[:500]}{"..." if len(observation.description) > 500 else ""}
|
|
|
|
Please review and triage this observation.
|
|
""".strip()
|
|
|
|
for admin in px_admins:
|
|
if admin.email:
|
|
NotificationService.send_email(
|
|
email=admin.email,
|
|
subject=subject,
|
|
message=message,
|
|
related_object=observation,
|
|
metadata={
|
|
"observation_id": str(observation.id),
|
|
"tracking_code": observation.tracking_code,
|
|
"notification_type": "new_observation",
|
|
},
|
|
)
|
|
|
|
logger.info(f"Sent new observation notification for {observation.tracking_code}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to send new observation notification: {e}")
|
|
|
|
@staticmethod
|
|
def notify_assignment(observation: Observation):
|
|
"""
|
|
Send notification when observation is assigned.
|
|
|
|
Args:
|
|
observation: Observation instance
|
|
"""
|
|
try:
|
|
if not observation.assigned_to:
|
|
return
|
|
|
|
subject = f"Observation Assigned: {observation.tracking_code}"
|
|
message = f"""
|
|
An observation has been assigned to you.
|
|
|
|
Tracking Code: {observation.tracking_code}
|
|
Category: {observation.category.name_en if observation.category else "N/A"}
|
|
Severity: {observation.get_severity_display()}
|
|
Location: {observation.location_text or "N/A"}
|
|
|
|
Description:
|
|
{observation.description[:500]}{"..." if len(observation.description) > 500 else ""}
|
|
|
|
Please review and take appropriate action.
|
|
""".strip()
|
|
|
|
if observation.assigned_to.email:
|
|
NotificationService.send_email(
|
|
email=observation.assigned_to.email,
|
|
subject=subject,
|
|
message=message,
|
|
related_object=observation,
|
|
metadata={
|
|
"observation_id": str(observation.id),
|
|
"tracking_code": observation.tracking_code,
|
|
"notification_type": "observation_assigned",
|
|
},
|
|
)
|
|
|
|
logger.info(
|
|
f"Sent assignment notification for {observation.tracking_code} to {observation.assigned_to.email}"
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to send assignment notification: {e}")
|
|
|
|
@staticmethod
|
|
def notify_resolution(observation: Observation):
|
|
"""
|
|
Send internal notification when observation is resolved/closed.
|
|
|
|
Args:
|
|
observation: Observation instance
|
|
"""
|
|
try:
|
|
# Notify assigned user and department manager
|
|
recipients = []
|
|
|
|
if observation.assigned_to and observation.assigned_to.email:
|
|
recipients.append(observation.assigned_to.email)
|
|
|
|
if observation.assigned_department and observation.assigned_department.manager:
|
|
if observation.assigned_department.manager.email:
|
|
recipients.append(observation.assigned_department.manager.email)
|
|
|
|
if not recipients:
|
|
return
|
|
|
|
subject = f"Observation {observation.get_status_display()}: {observation.tracking_code}"
|
|
message = f"""
|
|
An observation has been {observation.get_status_display().lower()}.
|
|
|
|
Tracking Code: {observation.tracking_code}
|
|
Category: {observation.category.name_en if observation.category else "N/A"}
|
|
Status: {observation.get_status_display()}
|
|
|
|
Resolution Notes:
|
|
{observation.resolution_notes or "No resolution notes provided."}
|
|
""".strip()
|
|
|
|
for email in set(recipients):
|
|
NotificationService.send_email(
|
|
email=email,
|
|
subject=subject,
|
|
message=message,
|
|
related_object=observation,
|
|
metadata={
|
|
"observation_id": str(observation.id),
|
|
"tracking_code": observation.tracking_code,
|
|
"notification_type": "observation_resolved",
|
|
},
|
|
)
|
|
|
|
logger.info(f"Sent resolution notification for {observation.tracking_code}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to send resolution notification: {e}")
|
|
|
|
@staticmethod
|
|
def get_statistics(hospital=None, department=None, date_from=None, date_to=None):
|
|
"""
|
|
Get observation statistics.
|
|
|
|
Args:
|
|
hospital: Filter by hospital (optional)
|
|
department: Filter by department (optional)
|
|
date_from: Start date (optional)
|
|
date_to: End date (optional)
|
|
|
|
Returns:
|
|
Dictionary with statistics
|
|
"""
|
|
from django.db.models import Count, Q
|
|
|
|
queryset = Observation.objects.all()
|
|
|
|
if hospital:
|
|
queryset = queryset.filter(assigned_department__hospital=hospital)
|
|
if department:
|
|
queryset = queryset.filter(assigned_department=department)
|
|
if date_from:
|
|
queryset = queryset.filter(created_at__gte=date_from)
|
|
if date_to:
|
|
queryset = queryset.filter(created_at__lte=date_to)
|
|
|
|
# Status counts
|
|
status_counts = queryset.values("status").annotate(count=Count("id"))
|
|
status_dict = {item["status"]: item["count"] for item in status_counts}
|
|
|
|
# Severity counts
|
|
severity_counts = queryset.values("severity").annotate(count=Count("id"))
|
|
severity_dict = {item["severity"]: item["count"] for item in severity_counts}
|
|
|
|
# Category counts
|
|
category_counts = queryset.values("category__name_en").annotate(count=Count("id")).order_by("-count")[:10]
|
|
|
|
return {
|
|
"total": queryset.count(),
|
|
"new": status_dict.get("new", 0),
|
|
"triaged": status_dict.get("triaged", 0),
|
|
"assigned": status_dict.get("assigned", 0),
|
|
"in_progress": status_dict.get("in_progress", 0),
|
|
"resolved": status_dict.get("resolved", 0),
|
|
"closed": status_dict.get("closed", 0),
|
|
"rejected": status_dict.get("rejected", 0),
|
|
"duplicate": status_dict.get("duplicate", 0),
|
|
"anonymous_count": queryset.filter(Q(reporter_staff_id="") & Q(reporter_name="")).count(),
|
|
"severity": severity_dict,
|
|
"top_categories": list(category_counts),
|
|
}
|