646 lines
26 KiB
Python
646 lines
26 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,
|
|
}
|
|
|
|
taxonomy_mapping = analysis.get("taxonomy_mapping", {})
|
|
if taxonomy_mapping:
|
|
if taxonomy_mapping.get("domain"):
|
|
observation.taxonomy_domain_id = taxonomy_mapping["domain"].get("id")
|
|
if taxonomy_mapping.get("category"):
|
|
observation.taxonomy_category_id = taxonomy_mapping["category"].get("id")
|
|
if taxonomy_mapping.get("subcategory"):
|
|
observation.taxonomy_subcategory_id = taxonomy_mapping["subcategory"].get("id")
|
|
if taxonomy_mapping.get("classification"):
|
|
observation.taxonomy_classification_id = taxonomy_mapping["classification"].get("id")
|
|
observation.metadata["taxonomy"] = analysis.get("taxonomy")
|
|
|
|
observation.save(
|
|
update_fields=[
|
|
"severity",
|
|
"category",
|
|
"title",
|
|
"metadata",
|
|
"taxonomy_domain",
|
|
"taxonomy_category",
|
|
"taxonomy_subcategory",
|
|
"taxonomy_classification",
|
|
]
|
|
)
|
|
|
|
# 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)}
|
|
|
|
|
|
@shared_task
|
|
def check_overdue_observation_dept_responses():
|
|
"""
|
|
Periodic task to check for overdue department responses on observations.
|
|
|
|
Runs every 15 minutes. Flags observations where department response SLA has passed.
|
|
Auto-escalates to department manager if configured.
|
|
"""
|
|
from apps.observations.models import Observation, ObservationSLAConfig, ObservationStatusLog, ObservationNote
|
|
from apps.notifications.services import NotificationService
|
|
|
|
now = timezone.now()
|
|
|
|
pending_observations = Observation.objects.filter(
|
|
dept_response_sla_due_at__isnull=False,
|
|
dept_response_sla_due_at__lte=now,
|
|
department_responded_at__isnull=True,
|
|
).select_related("hospital", "assigned_department")
|
|
|
|
overdue_count = 0
|
|
escalated_count = 0
|
|
|
|
for observation in pending_observations:
|
|
if not observation.dept_response_is_overdue:
|
|
observation.dept_response_is_overdue = True
|
|
observation.save(update_fields=["dept_response_is_overdue"])
|
|
overdue_count += 1
|
|
logger.warning(f"Observation {observation.tracking_code} dept response is overdue")
|
|
|
|
dept = observation.assigned_department
|
|
if not dept:
|
|
continue
|
|
|
|
sla_config = observation.get_sla_config()
|
|
if not sla_config or not sla_config.dept_response_auto_escalate_enabled:
|
|
continue
|
|
|
|
escalation_delay = sla_config.dept_response_escalation_hours_overdue
|
|
if escalation_delay > 0:
|
|
escalation_threshold = observation.dept_response_sla_due_at + __import__(
|
|
"datetime"
|
|
).timedelta(hours=escalation_delay)
|
|
if now < escalation_threshold:
|
|
continue
|
|
|
|
if observation.dept_response_escalated_at:
|
|
continue
|
|
|
|
if not (
|
|
observation.dept_response_reminder_sent_at
|
|
or observation.dept_response_second_reminder_sent_at
|
|
):
|
|
continue
|
|
|
|
if dept.manager and dept.manager.email:
|
|
try:
|
|
NotificationService.send_email(
|
|
email=dept.manager.email,
|
|
subject=f"ESCALATION: Observation {observation.tracking_code} - Department Response Overdue",
|
|
message=(
|
|
f"The department response for observation {observation.tracking_code} "
|
|
f"is overdue. The response deadline was "
|
|
f"{observation.dept_response_sla_due_at.strftime('%Y-%m-%d %H:%M')}. "
|
|
f"Please ensure the department submits a response immediately."
|
|
),
|
|
related_object=observation,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Failed to send escalation email: {e}")
|
|
|
|
observation.dept_response_escalated_at = now
|
|
observation.save(update_fields=["dept_response_escalated_at"])
|
|
escalated_count += 1
|
|
|
|
ObservationStatusLog.objects.create(
|
|
observation=observation,
|
|
from_status="",
|
|
to_status=observation.status,
|
|
changed_by=None,
|
|
comment=f"Department response SLA escalated to {dept.get_localized_name()} manager",
|
|
)
|
|
|
|
ObservationNote.objects.create(
|
|
observation=observation,
|
|
note=f"Department response SLA escalated to {dept.get_localized_name()} manager",
|
|
created_by=None,
|
|
is_internal=True,
|
|
)
|
|
|
|
if overdue_count > 0 or escalated_count > 0:
|
|
logger.info(
|
|
f"Observation dept response: {overdue_count} overdue, {escalated_count} escalated"
|
|
)
|
|
|
|
return {"overdue_count": overdue_count, "escalated_count": escalated_count}
|
|
|
|
|
|
@shared_task
|
|
def send_observation_dept_response_reminders():
|
|
"""
|
|
Periodic task to send SLA reminder emails for department responses on observations.
|
|
|
|
Runs every hour. Sends first and second reminders based on config.
|
|
"""
|
|
from apps.observations.models import Observation, ObservationSLAConfig
|
|
from apps.notifications.services import NotificationService
|
|
|
|
now = timezone.now()
|
|
|
|
pending_observations = Observation.objects.filter(
|
|
dept_response_sla_due_at__isnull=False,
|
|
department_responded_at__isnull=True,
|
|
dept_response_is_overdue=False,
|
|
).select_related("hospital", "assigned_department")
|
|
|
|
first_reminder_count = 0
|
|
second_reminder_count = 0
|
|
|
|
for observation in pending_observations:
|
|
sla_config = observation.get_sla_config()
|
|
if not sla_config:
|
|
continue
|
|
|
|
dept = observation.assigned_department
|
|
if not dept:
|
|
continue
|
|
|
|
recipients = []
|
|
if dept.respondent and dept.respondent.user and dept.respondent.user.email:
|
|
recipients.append(dept.respondent.user)
|
|
|
|
if not recipients:
|
|
continue
|
|
|
|
hours_remaining = (observation.dept_response_sla_due_at - now).total_seconds() / 3600
|
|
|
|
if (
|
|
observation.dept_response_reminder_sent_at is None
|
|
and hours_remaining <= sla_config.dept_response_reminder_hours_before
|
|
and hours_remaining > 0
|
|
):
|
|
for recipient in recipients:
|
|
try:
|
|
NotificationService.send_email(
|
|
email=recipient.email,
|
|
subject=f"Reminder: Observation {observation.tracking_code} - Response Required",
|
|
message=(
|
|
f"This is a reminder that observation {observation.tracking_code} "
|
|
f"is awaiting your department's response. "
|
|
f"Time remaining: {max(0, round(hours_remaining))} hours. "
|
|
f"Please submit your response before the deadline."
|
|
),
|
|
related_object=observation,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Failed to send observation dept reminder: {e}")
|
|
|
|
observation.dept_response_reminder_sent_at = now
|
|
observation.save(update_fields=["dept_response_reminder_sent_at"])
|
|
first_reminder_count += 1
|
|
|
|
elif (
|
|
sla_config.dept_response_second_reminder_enabled
|
|
and observation.dept_response_reminder_sent_at is not None
|
|
and observation.dept_response_second_reminder_sent_at is None
|
|
and hours_remaining <= sla_config.dept_response_second_reminder_hours_before
|
|
and hours_remaining > 0
|
|
):
|
|
for recipient in recipients:
|
|
try:
|
|
NotificationService.send_email(
|
|
email=recipient.email,
|
|
subject=f"URGENT: Observation {observation.tracking_code} - Response Overdue Soon",
|
|
message=(
|
|
f"URGENT: Observation {observation.tracking_code} "
|
|
f"response deadline is approaching. "
|
|
f"Time remaining: {max(0, round(hours_remaining))} hours. "
|
|
f"Please submit your response immediately."
|
|
),
|
|
related_object=observation,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Failed to send observation dept 2nd reminder: {e}")
|
|
|
|
observation.dept_response_second_reminder_sent_at = now
|
|
observation.save(update_fields=["dept_response_second_reminder_sent_at"])
|
|
second_reminder_count += 1
|
|
|
|
logger.info(
|
|
f"Observation dept response reminders: {first_reminder_count} first, {second_reminder_count} second"
|
|
)
|
|
|
|
return {
|
|
"first_reminder_count": first_reminder_count,
|
|
"second_reminder_count": second_reminder_count,
|
|
}
|