HH/apps/observations/services.py
2026-03-28 14:03:56 +03:00

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),
}