HH/apps/observations/tasks.py
2026-04-08 17:13:35 +03:00

432 lines
18 KiB
Python

"""
Observations Celery tasks - SLA tracking and notifications.
This module implements tasks for:
- Checking overdue observations
- Sending SLA reminder emails
- Escalation handling
"""
import logging
from celery import shared_task
from django.utils import timezone
logger = logging.getLogger(__name__)
@shared_task
def check_overdue_observations():
"""
Periodic task to check for overdue observations.
Runs every 15 minutes (configured in config/celery.py).
Updates is_overdue flag and sets breached_at for observations past their SLA deadline.
"""
from apps.observations.models import Observation, ObservationStatus
active_statuses = [
ObservationStatus.NEW,
ObservationStatus.TRIAGED,
ObservationStatus.ASSIGNED,
ObservationStatus.IN_PROGRESS,
]
active_observations = Observation.objects.filter(
status__in=active_statuses,
due_at__isnull=False,
).select_related("hospital", "assigned_department")
overdue_count = 0
for observation in active_observations:
if observation.check_overdue():
overdue_count += 1
logger.warning(
f"Observation {observation.id} is overdue: {observation.tracking_code} (due: {observation.due_at})"
)
if overdue_count > 0:
logger.info(f"Found {overdue_count} overdue observations")
return {"overdue_count": overdue_count}
@shared_task
def send_observation_sla_reminders():
"""
Periodic task to send SLA reminder emails for observations approaching deadline.
Runs every hour (configured in config/celery.py).
"""
from django.conf import settings
from django.template.loader import render_to_string
from apps.observations.models import Observation, ObservationStatus, ObservationSLAConfig
from apps.notifications.services import NotificationService
now = timezone.now()
active_statuses = [
ObservationStatus.NEW,
ObservationStatus.TRIAGED,
ObservationStatus.ASSIGNED,
ObservationStatus.IN_PROGRESS,
]
active_observations = Observation.objects.filter(
status__in=active_statuses,
due_at__isnull=False,
is_overdue=False,
).select_related("hospital", "assigned_to", "category")
first_reminder_count = 0
second_reminder_count = 0
for observation in active_observations:
config = observation.get_sla_config()
if not config:
continue
first_reminder_hours = config.get_first_reminder_hours_after()
second_reminder_hours = config.get_second_reminder_hours_after()
if first_reminder_hours > 0 and observation.reminder_sent_at is None:
hours_since_creation = (now - observation.created_at).total_seconds() / 3600
if hours_since_creation >= first_reminder_hours:
if observation.assigned_to and observation.assigned_to.email:
try:
hours_remaining = (observation.due_at - now).total_seconds() / 3600
hours_remaining = max(0, round(hours_remaining))
plain_message = (
f"Observation '{observation.title or observation.description[:50]}' "
f"(tracking code: {observation.tracking_code}) is due at "
f"{observation.due_at.strftime('%Y-%m-%d %H:%M')}. "
f"Time remaining: {hours_remaining} hours. Please take action."
)
try:
from django.contrib.sites.models import Site
site_url = Site.objects.get_current().domain
except Exception:
site_url = getattr(settings, "SITE_URL", "")
context = {
"recipient_name": observation.assigned_to.get_full_name(),
"observation": observation,
"observation_url": f"{site_url}/observations/{observation.id}/",
"site_url": site_url,
"due_date": observation.due_at,
"hours_remaining": hours_remaining,
"is_unassigned": False,
}
html_message = render_to_string("emails/observation_sla_reminder.html", context)
NotificationService.send_email(
email=observation.assigned_to.email,
subject=f"SLA Reminder - Observation {observation.tracking_code}",
message=plain_message,
html_message=html_message,
related_object=observation,
)
observation.reminder_sent_at = now
observation.save(update_fields=["reminder_sent_at"])
first_reminder_count += 1
except Exception as e:
logger.error(f"Failed to send observation reminder: {e}")
if second_reminder_hours > 0 and observation.second_reminder_sent_at is None:
hours_since_creation = (now - observation.created_at).total_seconds() / 3600
if hours_since_creation >= second_reminder_hours:
if observation.assigned_to and observation.assigned_to.email:
try:
hours_remaining = (observation.due_at - now).total_seconds() / 3600
hours_remaining = max(0, round(hours_remaining))
plain_message = (
f"URGENT: Observation '{observation.title or observation.description[:50]}' "
f"(tracking code: {observation.tracking_code}) is due at "
f"{observation.due_at.strftime('%Y-%m-%d %H:%M')}. "
f"Time remaining: {hours_remaining} hours. Immediate action required."
)
try:
from django.contrib.sites.models import Site
site_url = Site.objects.get_current().domain
except Exception:
site_url = getattr(settings, "SITE_URL", "")
context = {
"recipient_name": observation.assigned_to.get_full_name(),
"observation": observation,
"observation_url": f"{site_url}/observations/{observation.id}/",
"site_url": site_url,
"due_date": observation.due_at,
"hours_remaining": hours_remaining,
}
html_message = render_to_string("emails/observation_sla_second_reminder.html", context)
NotificationService.send_email(
email=observation.assigned_to.email,
subject=f"URGENT: SLA Reminder - Observation {observation.tracking_code}",
message=plain_message,
html_message=html_message,
related_object=observation,
)
observation.second_reminder_sent_at = now
observation.save(update_fields=["second_reminder_sent_at"])
second_reminder_count += 1
except Exception as e:
logger.error(f"Failed to send second observation reminder: {e}")
logger.info(
f"Sent {first_reminder_count} first reminders and {second_reminder_count} second reminders for observations"
)
return {
"first_reminder_count": first_reminder_count,
"second_reminder_count": second_reminder_count,
}
@shared_task
def schedule_monthly_followups():
from apps.observations.models import Observation, ObservationStatus
now = timezone.now()
resolved_observations = Observation.objects.filter(
status__in=[ObservationStatus.RESOLVED, ObservationStatus.CLOSED],
monthly_follow_up_due_at__isnull=True,
resolved_at__isnull=False,
)
scheduled = 0
for obs in resolved_observations:
obs.monthly_follow_up_due_at = obs.resolved_at + __import__("datetime").timedelta(days=30)
obs.save(update_fields=["monthly_follow_up_due_at"])
scheduled += 1
logger.info(f"Scheduled {scheduled} monthly observation follow-ups")
return {"scheduled": scheduled}
@shared_task
def process_monthly_followups():
from django.conf import settings
from django.template.loader import render_to_string
from apps.observations.models import Observation, ObservationStatus
from apps.notifications.services import NotificationService
now = timezone.now()
due_observations = Observation.objects.filter(
status__in=[ObservationStatus.RESOLVED, ObservationStatus.CLOSED],
monthly_follow_up_due_at__lte=now,
monthly_follow_up_completed_at__isnull=True,
).select_related("hospital", "assigned_to", "assigned_department", "category")
notified = 0
for obs in due_observations:
if obs.assigned_to and obs.assigned_to.email:
try:
plain_message = (
f"Observation '{obs.title or obs.description[:50]}' "
f"(tracking code: {obs.tracking_code}) requires monthly follow-up review. "
f"Please verify that the issue has been fully addressed and sustained."
)
try:
from django.contrib.sites.models import Site
site_url = Site.objects.get_current().domain
except Exception:
site_url = getattr(settings, "SITE_URL", "")
context = {
"recipient_name": obs.assigned_to.get_full_name(),
"observation": obs,
"observation_url": f"{site_url}/observations/{obs.id}/",
"site_url": site_url,
}
html_message = render_to_string("emails/observation_monthly_followup.html", context)
NotificationService.send_email(
email=obs.assigned_to.email,
subject=f"Monthly Follow-Up Due: Observation {obs.tracking_code}",
message=plain_message,
html_message=html_message,
related_object=obs,
)
notified += 1
except Exception as e:
logger.error(f"Failed to send follow-up notification for observation {obs.id}: {e}")
logger.info(f"Sent {notified} monthly follow-up notifications for observations")
return {"notified": notified}
@shared_task
def send_new_observation_notification(observation_id):
"""
Send notification for a new observation to PX360 triage team.
Runs as a background task to avoid blocking the observation creation response.
"""
from apps.observations.models import Observation
from apps.observations.services import ObservationService
try:
observation = Observation.objects.get(id=observation_id)
ObservationService.notify_new_observation(observation)
except Observation.DoesNotExist:
logger.error(f"Observation {observation_id} not found for notification")
@shared_task
def analyze_observation_with_ai(observation_id):
"""
Analyze an observation using AI to determine severity, category, and generate
bilingual summaries and suggested PX actions.
This task is triggered when an observation is created.
It uses the AI service to analyze the observation content and classify it.
Args:
observation_id: UUID of the Observation
Returns:
dict: Result with severity, category, suggested actions, and summaries
"""
from apps.observations.models import Observation, ObservationCategory, ObservationNote
from apps.core.ai_service import AIService, AIServiceError
try:
observation = Observation.objects.select_related("hospital").get(id=observation_id)
logger.info(f"Starting AI analysis for observation {observation_id}")
try:
analysis = AIService.analyze_observation(
title=observation.title or None,
description=observation.description,
hospital_id=observation.hospital.id if observation.hospital else None,
)
emotion_analysis = AIService.analyze_emotion(text=observation.description)
old_severity = observation.severity
old_category = observation.category
# Update severity from AI
observation.severity = analysis["severity"]
# Match AI category to ObservationCategory
ai_category_name = analysis.get("category", "")
if ai_category_name:
category = ObservationCategory.objects.filter(name_en__iexact=ai_category_name, is_active=True).first()
if not category:
category = ObservationCategory.objects.filter(
name_en__icontains=ai_category_name, is_active=True
).first()
if category:
observation.category = category
logger.info(f"AI matched observation category: {category.name_en}")
else:
logger.warning(f"AI suggested category '{ai_category_name}' but no match found")
# Update title from AI
if analysis.get("title_en"):
observation.title = analysis["title_en"]
elif analysis.get("title"):
observation.title = analysis["title"]
# Store full analysis in metadata
if not observation.metadata:
observation.metadata = {}
old_category_name = old_category.name_en if old_category else None
observation.metadata["ai_analysis"] = {
"title_en": analysis.get("title_en", ""),
"title_ar": analysis.get("title_ar", ""),
"brief_summary_en": analysis.get("brief_summary_en", ""),
"brief_summary_ar": analysis.get("brief_summary_ar", ""),
"short_description_en": analysis.get("short_description_en", ""),
"short_description_ar": analysis.get("short_description_ar", ""),
"suggested_actions": analysis.get("suggested_actions", []),
"suggested_action_en": analysis.get("suggested_action_en", ""),
"suggested_action_ar": analysis.get("suggested_action_ar", ""),
"severity": analysis.get("severity", ""),
"category": analysis.get("category", ""),
"department": analysis.get("department", ""),
"reasoning_en": analysis.get("reasoning_en", ""),
"reasoning_ar": analysis.get("reasoning_ar", ""),
"emotion": emotion_analysis.get("emotion", "neutral"),
"emotion_intensity": emotion_analysis.get("intensity", 0.0),
"emotion_confidence": emotion_analysis.get("confidence", 0.0),
"analyzed_at": timezone.now().isoformat(),
"old_severity": old_severity,
"old_category": old_category_name,
}
observation.save(
update_fields=[
"severity",
"category",
"title",
"metadata",
]
)
# Create timeline note with bilingual AI completion message
emotion_display = emotion_analysis.get("emotion", "neutral")
category_display = observation.category.name_en if observation.category else analysis.get("category", "N/A")
message_en = (
f"AI analysis complete: Severity={analysis['severity']}, "
f"Category={category_display}, "
f"Emotion={emotion_display}"
)
message_ar = (
f"اكتمل تحليل الذكاء الاصطناعي: الشدة={analysis['severity']}, "
f"الفئة={category_display}, "
f"العاطفة={emotion_display}"
)
ObservationNote.objects.create(
observation=observation,
note=f"{message_en}\n{message_ar}",
is_internal=True,
created_by=None,
)
logger.info(f"AI analysis completed for observation {observation_id}")
return {
"status": "success",
"observation_id": str(observation_id),
"severity": analysis.get("severity"),
"category": analysis.get("category"),
"suggested_actions_count": len(analysis.get("suggested_actions", [])),
}
except AIServiceError as e:
logger.error(f"AI analysis failed for observation {observation_id}: {e}")
if not observation.metadata:
observation.metadata = {}
observation.metadata["ai_analysis"] = {
"error": str(e),
"analyzed_at": timezone.now().isoformat(),
"reasoning_en": f"AI service unavailable: {str(e)}",
"reasoning_ar": f"خدمة الذكاء الاصطناعي غير متوفرة: {str(e)}",
}
observation.save(update_fields=["metadata"])
return {"status": "error", "reason": f"AI service error: {str(e)}"}
except Observation.DoesNotExist:
logger.error(f"Observation {observation_id} not found for AI analysis")
return {"status": "error", "reason": "Observation not found"}
except Exception as e:
logger.error(f"Unexpected error analyzing observation {observation_id}: {e}", exc_info=True)
return {"status": "error", "reason": str(e)}