HH/apps/appreciation/signals.py
ismail 7369d08012
All checks were successful
Build and Push Docker Image / build (push) Successful in 4m14s
feat: unified reference numbers + feedback modules QA audit
Reference numbers (unified scheme PREFIX-YYYYMM-HOSP-NNNN, e.g. CMP-202606-HHN-0001):
- new ReferenceSequence model + generate_reference() helper (apps/core)
- Complaint/Inquiry/Observation/Appreciation/Suggestion emit unified refs via save()
- prefix-based auto-routing in public track API (CMP/INQ/OBS trackable; APR/SGT internal-only)
- removed legacy CMP-/INQ- generators in ui_views, integrations, px_sources
- migrations: core.0003_referencesequence, appreciation.0006, feedback.0008, observations.0012
- unit tests (format, sanitization, monthly reset, 40-thread concurrency)

QA audit:
- isolated E2E hospital sandbox mirroring HH-N + 10 role users (create_e2e_isolated_env)
- feedback-modules-audit.spec.ts + audit helper (headed, run-to-completion)
- reports/feedback-modules-qa-report.md

Also bundles accumulated in-progress work across complaints, observations,
organizations, templates, and other modules.
2026-06-14 14:29:23 +03:00

540 lines
19 KiB
Python

"""
Appreciation signals - Signal handlers for appreciation events
This module handles:
- Sending notifications when appreciations are sent
- Updating statistics when appreciations are created
- Checking and awarding badges
"""
import logging
from django.db.models import Q
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
logger = logging.getLogger(__name__)
from apps.appreciation.models import (
Appreciation,
AppreciationBadge,
AppreciationStats,
AppreciationStatus,
UserBadge,
)
@receiver(post_save, sender=Appreciation)
def handle_appreciation_sent(sender, instance, created, **kwargs):
"""
Handle appreciation sent events.
This signal triggers:
1. Send notification to recipient
2. Update statistics
3. Check for badge awards
"""
# Only process when appreciation is sent (status changes to 'sent')
if instance.status == AppreciationStatus.SENT and not instance.notification_sent:
# Send notification
send_appreciation_notification(instance)
# Update statistics
update_appreciation_stats(instance)
# Check for badge awards
check_and_award_badges(instance)
def send_appreciation_notification(appreciation):
"""
Send notification to recipient when appreciation is sent.
Uses the notification system to send email/SMS/WhatsApp.
Also sends email to department heads.
"""
notification_success = False
try:
from apps.notifications.services import send_email, send_sms, get_email_header_html
recipient_email = appreciation.get_recipient_email()
recipient_phone = appreciation.get_recipient_phone()
sender_name = "Anonymous" if appreciation.is_anonymous else (
appreciation.sender.get_full_name() if appreciation.sender
else (appreciation.metadata or {}).get("submitted_by_name", "Anonymous")
)
html_message = f"""
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
{get_email_header_html()}
<div style="padding: 30px;">
<h2 style="color: #005696; margin-top: 0;">&#127775; Staff Appreciation</h2>
<div style="background: #eef6fb; border-left: 4px solid #005696; padding: 20px; margin: 20px 0;">
<strong>From:</strong> {sender_name}<br>
<strong>Hospital:</strong> {appreciation.hospital.name}<br>
{f'<strong>Category:</strong> {appreciation.category.name_en}<br>' if appreciation.category else ''}
<br>
<strong>Message:</strong><br>
{appreciation.message_en}
</div>
{f'<div style="background: #fffbeb; border-left: 4px solid #f59e0b; padding: 20px; margin: 20px 0;"><strong>Personal Note from {sender_name}:</strong><br>{appreciation.custom_message}</div>' if appreciation.custom_message else ''}
<p>Congratulations on this recognition! Your dedication is truly appreciated.</p>
</div>
</div>
"""
message_en = f"You've received an appreciation from {sender_name}!"
message_ar = f"لقد تلقيت تكريماً من {sender_name}!"
if appreciation.category:
message_en += f"\n\nCategory: {appreciation.category.name_en}"
message_ar += f"\n\nالفئة: {appreciation.category.name_ar}"
message_en += f"\n\nMessage: {appreciation.message_en}"
message_ar += f"\n\nالرسالة: {appreciation.message_ar}"
if appreciation.custom_message:
message_en += f"\n\nPersonal note: {appreciation.custom_message}"
message_ar += f"\n\nملاحظة شخصية: {appreciation.custom_message}"
if recipient_email:
try:
send_email(
email=recipient_email,
subject=f"New Appreciation Received - {appreciation.hospital.name}",
message=message_en,
html_message=html_message,
related_object=appreciation,
)
notification_success = True
except Exception as e:
logger.error(f"Failed to send appreciation email: {e}")
if recipient_phone:
try:
send_sms(
phone=recipient_phone,
message=message_en,
related_object=appreciation,
)
notification_success = True
except Exception as e:
logger.error(f"Failed to send appreciation SMS: {e}")
_send_department_head_notification(appreciation, sender_name)
_send_cc_notifications(appreciation, sender_name, html_message, message_en)
notification_success = True
except ImportError as e:
logger.warning(f"Notification service not available: {e}")
except Exception as e:
logger.error(f"Error sending appreciation notification: {e}")
if notification_success:
appreciation.notification_sent = True
appreciation.notification_sent_at = timezone.now()
appreciation.save(update_fields=['notification_sent', 'notification_sent_at'])
def _send_department_head_notification(appreciation, sender_name):
from apps.notifications.services import send_email as notify_send_email, get_email_header_html
from apps.organizations.models import Staff
if not appreciation.department:
return
dept = appreciation.department
recipient_emails = set()
if dept.manager and dept.manager.email:
recipient_emails.add(dept.manager.email)
heads = Staff.objects.filter(
department=dept, is_head=True
).select_related("user")
for head in heads:
if head.user and head.user.email:
recipient_emails.add(head.user.email)
if not recipient_emails:
return
recipient_name = appreciation.get_recipient_name() if appreciation.recipient else "N/A"
html = f"""
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
{get_email_header_html()}
<div style="padding: 30px;">
<h2 style="color: #005696; margin-top: 0;">&#127775; New Appreciation in Your Department</h2>
<div style="background: #ecfdf5; border-left: 4px solid #10b981; padding: 20px; margin: 20px 0;">
<strong>Staff Member:</strong> {recipient_name}<br>
<strong>Department:</strong> {dept.name_en or dept.name}<br>
<strong>From:</strong> {sender_name}<br>
{f'<strong>Category:</strong> {appreciation.category.name_en}<br>' if appreciation.category else ''}
<br>
<strong>Message:</strong><br>
{appreciation.message_en}
</div>
<p>A staff member in your department has received an appreciation. Please acknowledge their good work.</p>
</div>
</div>
"""
plain_message = (
f"New appreciation for {recipient_name} in {dept.name_en or dept.name}.\n"
f"From: {sender_name}\n\n"
f"Message: {appreciation.message_en}"
)
for email in recipient_emails:
try:
notify_send_email(
email=email,
subject=f"New Appreciation for {recipient_name} - {dept.name_en or dept.name}",
message=plain_message,
html_message=html,
related_object=appreciation,
)
except Exception as e:
logger.error(f"Failed to send department head notification: {e}")
def _send_cc_notifications(appreciation, sender_name, html_message, plain_message):
from apps.notifications.services import send_email as notify_send_email
cc_list = appreciation.cc_list or []
if not cc_list:
return
recipient_name = appreciation.get_recipient_name() if appreciation.recipient else "N/A"
for cc_email in cc_list:
if not cc_email:
continue
try:
notify_send_email(
email=cc_email.strip(),
subject=f"CC: Appreciation sent to {recipient_name} - {appreciation.hospital.name}",
message=plain_message,
html_message=html_message,
related_object=appreciation,
)
except Exception as e:
logger.error(f"Failed to send CC notification to {cc_email}: {e}")
def update_appreciation_stats(instance):
"""
Update appreciation statistics for the recipient.
Creates or updates monthly statistics.
"""
from django.db.models import F
now = timezone.now()
year = now.year
month = now.month
stats, created = AppreciationStats.objects.get_or_create(
recipient_content_type=instance.recipient_content_type,
recipient_object_id=instance.recipient_object_id,
year=year,
month=month,
defaults={
'hospital': instance.hospital,
'department': instance.department,
'received_count': 0,
'sent_count': 0,
'acknowledged_count': 0,
'category_breakdown': {},
}
)
AppreciationStats.objects.filter(pk=stats.pk).update(
received_count=F('received_count') + 1,
sent_count=F('sent_count') + 1,
)
stats.refresh_from_db()
if instance.category:
category_breakdown = stats.category_breakdown or {}
category_id_str = str(instance.category.id)
category_breakdown[category_id_str] = category_breakdown.get(category_id_str, 0) + 1
AppreciationStats.objects.filter(pk=stats.pk).update(category_breakdown=category_breakdown)
recalculate_rankings(instance.hospital, year, month, instance.department)
def recalculate_rankings(hospital, year, month, department=None):
"""
Recalculate rankings for a given period.
Updates hospital_rank and department_rank for all recipients.
"""
# Get all stats for the period
if department:
stats_queryset = AppreciationStats.objects.filter(
hospital=hospital,
department=department,
year=year,
month=month,
)
else:
stats_queryset = AppreciationStats.objects.filter(
hospital=hospital,
year=year,
month=month,
)
# Order by received count
stats_queryset = stats_queryset.order_by('-received_count')
# Update hospital rank
for rank, stat in enumerate(stats_queryset, start=1):
stat.hospital_rank = rank
stat.save(update_fields=['hospital_rank'])
# Update department rank if department is specified
if department:
dept_stats = stats_queryset.filter(department=department)
for rank, stat in enumerate(dept_stats, start=1):
stat.department_rank = rank
stat.save(update_fields=['department_rank'])
def check_and_award_badges(instance):
"""
Check if recipient qualifies for any badges and award them.
Checks all active badge criteria and awards badges if criteria are met.
"""
from django.contrib.contenttypes.models import ContentType
# Get recipient
recipient_content_type = instance.recipient_content_type
recipient_object_id = instance.recipient_object_id
# Get all active badges for the hospital
badges = AppreciationBadge.objects.filter(
Q(hospital=instance.hospital) | Q(hospital__isnull=True),
is_active=True,
)
for badge in badges:
# Check if badge already earned
if UserBadge.objects.filter(
recipient_content_type=recipient_content_type,
recipient_object_id=recipient_object_id,
badge=badge,
).exists():
continue # Already earned
# Check badge criteria
qualifies = check_badge_criteria(
badge,
recipient_content_type,
recipient_object_id,
instance.hospital,
)
if qualifies:
# Calculate current count
count = get_appreciation_count(
recipient_content_type,
recipient_object_id,
badge.criteria_type
)
# Award badge
UserBadge.objects.create(
recipient_content_type=recipient_content_type,
recipient_object_id=recipient_object_id,
badge=badge,
appreciation_count=count,
)
# Notify the user
try:
from django.contrib.auth import get_user_model
User = get_user_model()
user = User.objects.filter(pk=recipient_object_id).first()
if user and user.email:
from apps.notifications.services import NotificationService, get_email_header_html
NotificationService.send_email(
email=user.email,
subject=f"You earned a badge: {badge.name_en}!",
message=f"Congratulations! You've earned the \"{badge.name_en}\" badge for receiving {count} appreciations.",
html_message=f"""
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
{get_email_header_html()}
<div style="padding: 20px;">
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Congratulations!</h2>
<p style="margin: 0 0 12px 0;">You've earned the <strong>{badge.name_en}</strong> badge for receiving <strong>{count}</strong> appreciations.</p>
<p style="margin: 0; color: #6b7280;">Keep up the great work!</p>
</div>
</div>
""",
)
except Exception as e:
logger.error(f"Failed to send badge award notification: {e}")
def check_badge_criteria(badge, content_type, object_id, hospital):
"""
Check if recipient meets badge criteria.
Returns True if criteria is met, False otherwise.
"""
criteria_type = badge.criteria_type
criteria_value = badge.criteria_value
if criteria_type == 'received_count':
# Check total appreciation count
count = Appreciation.objects.filter(
recipient_content_type=content_type,
recipient_object_id=object_id,
status=AppreciationStatus.SENT,
).count()
return count >= criteria_value
elif criteria_type == 'received_month':
# Check appreciation count in current month
now = timezone.now()
count = Appreciation.objects.filter(
recipient_content_type=content_type,
recipient_object_id=object_id,
status=AppreciationStatus.SENT,
sent_at__year=now.year,
sent_at__month=now.month,
).count()
return count >= criteria_value
elif criteria_type == 'streak_weeks':
# Check consecutive weeks with appreciation
return check_appreciation_streak(
content_type,
object_id,
criteria_value
)
elif criteria_type == 'diverse_senders':
# Check appreciation from different senders
sender_count = Appreciation.objects.filter(
recipient_content_type=content_type,
recipient_object_id=object_id,
status=AppreciationStatus.SENT,
).values('sender').distinct().count()
return sender_count >= criteria_value
return False
def get_appreciation_count(content_type, object_id, criteria_type):
"""
Get appreciation count based on criteria type.
"""
if criteria_type == 'received_count':
return Appreciation.objects.filter(
recipient_content_type=content_type,
recipient_object_id=object_id,
status=AppreciationStatus.SENT,
).count()
elif criteria_type == 'received_month':
now = timezone.now()
return Appreciation.objects.filter(
recipient_content_type=content_type,
recipient_object_id=object_id,
status=AppreciationStatus.SENT,
sent_at__year=now.year,
sent_at__month=now.month,
).count()
elif criteria_type == 'diverse_senders':
return Appreciation.objects.filter(
recipient_content_type=content_type,
recipient_object_id=object_id,
status=AppreciationStatus.SENT,
).values('sender').distinct().count()
elif criteria_type == 'streak_weeks':
return check_appreciation_streak_count(content_type, object_id)
return 0
def check_appreciation_streak_count(content_type, object_id):
"""Count consecutive weeks of appreciation ending at current week."""
from datetime import timedelta
now = timezone.now()
streak = 0
current_week_start = now - timedelta(days=now.weekday())
current_week_start = current_week_start.replace(hour=0, minute=0, second=0, microsecond=0)
if Appreciation.objects.filter(
recipient_content_type=content_type,
recipient_object_id=object_id,
status=AppreciationStatus.SENT,
sent_at__gte=current_week_start,
sent_at__lte=now,
).exists():
streak += 1
for i in range(1, 52):
week_start = current_week_start - timedelta(weeks=i)
week_end = current_week_start - timedelta(weeks=i - 1)
if Appreciation.objects.filter(
recipient_content_type=content_type,
recipient_object_id=object_id,
status=AppreciationStatus.SENT,
sent_at__gte=week_start,
sent_at__lt=week_end,
).exists():
streak += 1
else:
break
return streak
def check_appreciation_streak(content_type, object_id, required_weeks):
"""
Check if recipient has appreciation streak for required weeks.
Returns True if streak meets or exceeds required_weeks.
Includes the current partial week.
"""
from datetime import timedelta
now = timezone.now()
current_week_start = now - timedelta(days=now.weekday())
current_week_start = current_week_start.replace(hour=0, minute=0, second=0, microsecond=0)
streak = 0
if Appreciation.objects.filter(
recipient_content_type=content_type,
recipient_object_id=object_id,
status=AppreciationStatus.SENT,
sent_at__gte=current_week_start,
sent_at__lte=now,
).exists():
streak += 1
for i in range(1, required_weeks):
week_start = current_week_start - timedelta(weeks=i)
week_end = current_week_start - timedelta(weeks=i - 1)
has_appreciation = Appreciation.objects.filter(
recipient_content_type=content_type,
recipient_object_id=object_id,
status=AppreciationStatus.SENT,
sent_at__gte=week_start,
sent_at__lt=week_end,
).exists()
if has_appreciation:
streak += 1
else:
break
return streak >= required_weeks