HH/apps/appreciation/models.py
ismail 295ff298a8 refactor(appreciation): collapse to 3-state DRAFT → ACTIVATED → SENT
Drops the dead AI_ANALYZED status (a prior data migration already converted
existing rows; the choice was never removed from the model) and the
unreachable ACKNOWLEDGED status (is_recipient was hardcoded False so the
acknowledge button never rendered).

Changes:
- AppreciationStatus: DRAFT, ACTIVATED, SENT only. SENT is terminal.
- VALID_APPRECIATION_TRANSITIONS: DRAFT→ACTIVATED→SENT, SENT terminal.
- Removed the model.acknowledge() method.
- DRF viewset acknowledge action: returns 410 Gone (deprecated).
- UI appreciation_acknowledge view: redirects with deprecation message.
- Removed URL routes: appreciation_acknowledge, appreciation_send_dept_reminder.
- appreciation_send_dept_reminder status check no longer references ACKNOWLEDGED.
- appreciation_list stats + status filter + badge: dropped ai_analyzed/acknowledged.
- appreciation_detail status badge + sent banner: dropped acknowledged references.
- Migration 0010_drop_acknowledged_status: AlterField on status choices.

AI analysis still runs on activation and is stored in ai_analysis JSON; it
just no longer has its own status. Badges and leaderboard fire at SENT.
6 tests lock in the new lifecycle and removed URLs.
2026-07-20 22:20:29 +03:00

534 lines
19 KiB
Python

"""
Appreciation models - Send and track appreciation to users and physicians
This module implements the appreciation system that:
- Allows sending appreciation messages to users and physicians
- Tracks appreciation statistics and leaderboards
- Manages appreciation categories and badges
- Integrates with the notification system
"""
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.db import models
from apps.core.encryption import EncryptedCharField, compute_national_id_hash
from apps.core.models import SoftDeleteModel, TimeStampedModel, UUIDModel
class AppreciationStatus(models.TextChoices):
"""Appreciation status choices.
Collapsed to a 3-state lifecycle: an appreciation is drafted, PX activates
it, and sending it to the department/manager is terminal. AI analysis still
runs on activation but is stored in ``ai_analysis`` JSON rather than
surfaced as a separate status. There is no acknowledge step — the recipient
is notified on send, and recognition (badges, leaderboard) fires at SENT.
"""
DRAFT = "draft", "Draft"
ACTIVATED = "activated", "Activated"
SENT = "sent", "Sent"
VALID_APPRECIATION_TRANSITIONS = {
AppreciationStatus.DRAFT: {AppreciationStatus.ACTIVATED},
AppreciationStatus.ACTIVATED: {AppreciationStatus.SENT},
AppreciationStatus.SENT: set(), # terminal
}
class AppreciationVisibility(models.TextChoices):
"""Appreciation visibility choices"""
PRIVATE = "private", "Private"
DEPARTMENT = "department", "Department"
HOSPITAL = "hospital", "Hospital"
PUBLIC = "public", "Public"
class AppreciationCategory(UUIDModel, TimeStampedModel):
"""
Appreciation category with bilingual support.
Pre-defined categories for sending appreciation (e.g., "Excellent Care", "Team Player").
Can be hospital-specific or system-wide.
"""
# Organization
hospital = models.ForeignKey(
"organizations.Hospital",
on_delete=models.CASCADE,
related_name="appreciation_categories",
null=True,
blank=True,
help_text="Leave blank for system-wide categories",
)
# Category details
code = models.CharField(max_length=50, help_text="Unique code for this category")
name_en = models.CharField(max_length=200)
name_ar = models.CharField(max_length=200, blank=True)
description_en = models.TextField(blank=True)
description_ar = models.TextField(blank=True)
# Visual elements
icon = models.CharField(max_length=50, blank=True, help_text="Icon class (e.g., 'fa-heart', 'fa-star')")
color = models.CharField(max_length=7, blank=True, help_text="Hex color code (e.g., '#FF5733')")
# Display order
order = models.IntegerField(default=0, help_text="Display order")
# Status
is_active = models.BooleanField(default=True)
class Meta:
ordering = ["hospital", "order", "name_en"]
unique_together = [["hospital", "code"]]
verbose_name_plural = "Appreciation Categories"
indexes = [
models.Index(fields=["hospital", "is_active"]),
models.Index(fields=["code"]),
]
def __str__(self):
hospital_name = self.hospital.name if self.hospital else "System-wide"
return f"{hospital_name} - {self.name_en}"
def get_localized_name(self):
from django.utils.translation import get_language
if get_language() == "ar" and self.name_ar:
return self.name_ar
return self.name_en
def get_localized_description(self):
from django.utils.translation import get_language
if get_language() == "ar" and self.description_ar:
return self.description_ar
return self.description_en
class Appreciation(UUIDModel, TimeStampedModel, SoftDeleteModel):
"""
Appreciation model for sending appreciation to users and physicians.
Workflow:
1. DRAFT - Created but not yet sent
2. SENT - Sent to recipient (triggers notification)
3. ACKNOWLEDGED - Recipient has acknowledged/thanked sender
Visibility:
- PRIVATE - Only sender and recipient can view
- DEPARTMENT - Visible to department members
- HOSPITAL - Visible to all hospital staff
- PUBLIC - Can be displayed publicly (e.g., on appreciation wall)
"""
# Sender
sender = models.ForeignKey(
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="sent_appreciations"
)
# Recipient (Generic FK to User or Physician)
recipient_content_type = models.ForeignKey(
ContentType, on_delete=models.SET_NULL, null=True, blank=True, related_name="appreciation_recipients"
)
recipient_object_id = models.UUIDField(null=True, blank=True)
recipient = GenericForeignKey("recipient_content_type", "recipient_object_id")
# Organization context
hospital = models.ForeignKey("organizations.Hospital", on_delete=models.CASCADE, related_name="appreciations")
legacy_location = models.ForeignKey(
"organizations.LegacyLocation",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="appreciations",
help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.",
)
legacy_main_section = models.ForeignKey(
"organizations.LegacyMainSection",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="appreciations",
help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.",
)
legacy_subsection = models.ForeignKey(
"organizations.LegacySubSection",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="appreciations",
help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.",
)
# New hierarchy (from 4th Version Excel)
section = models.ForeignKey(
"organizations.Section",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="appreciations_new",
help_text="Section within department",
)
department = models.ForeignKey(
"organizations.Department",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="appreciations",
help_text="Department context (if applicable)",
)
# Category and message
category = models.ForeignKey(
AppreciationCategory, on_delete=models.SET_NULL, null=True, blank=True, related_name="appreciations"
)
message_en = models.TextField()
message_ar = models.TextField(blank=True)
# Visibility and status
visibility = models.CharField(
max_length=20, choices=AppreciationVisibility.choices, default=AppreciationVisibility.PRIVATE, db_index=True
)
status = models.CharField(
max_length=20, choices=AppreciationStatus.choices, default=AppreciationStatus.DRAFT, db_index=True
)
# Reference number (unified format APR-YYYYMM-HOSP-NNNN; internal-only, not publicly trackable)
reference_number = models.CharField(max_length=40, unique=True, blank=True, null=True, db_index=True)
# Anonymous option
is_anonymous = models.BooleanField(default=False, help_text="Hide sender identity from recipient")
# Timestamps
activated_at = models.DateTimeField(null=True, blank=True)
activated_by = models.ForeignKey(
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="activated_appreciations"
)
ai_analyzed_at = models.DateTimeField(null=True, blank=True)
sent_at = models.DateTimeField(null=True, blank=True)
acknowledged_at = models.DateTimeField(null=True, blank=True)
# Send options
send_to_manager = models.BooleanField(default=False, help_text="Send notification to department manager")
send_to_department = models.BooleanField(default=True, help_text="Send notification to department")
cc_list = models.JSONField(default=list, blank=True, help_text="List of user IDs to CC")
custom_message = models.TextField(blank=True, help_text="Custom message to include when sending")
ai_analysis = models.JSONField(default=dict, blank=True, help_text="AI analysis results")
# Notification tracking
notification_sent = models.BooleanField(default=False)
notification_sent_at = models.DateTimeField(null=True, blank=True)
# Reminder tracking
dept_response_reminder_sent_at = models.DateTimeField(null=True, blank=True)
dept_response_second_reminder_sent_at = models.DateTimeField(null=True, blank=True)
# Submitter identity
national_id = EncryptedCharField(max_length=512, blank=True, help_text="National ID/Iqama of the submitter")
national_id_hash = models.CharField(max_length=64, blank=True, db_index=True)
# Metadata
metadata = models.JSONField(default=dict, blank=True)
notes = GenericRelation("core.Note")
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["status", "-created_at"]),
models.Index(fields=["hospital", "status"]),
models.Index(fields=["department", "-created_at"]),
models.Index(fields=["sender", "-created_at"]),
models.Index(fields=["recipient_content_type", "recipient_object_id", "-created_at"]),
models.Index(fields=["visibility", "-created_at"]),
]
def __str__(self):
recipient_name = self.get_recipient_name()
return f"Appreciation to {recipient_name} ({self.status})"
def save(self, *args, **kwargs):
if not self.reference_number:
from apps.core.reference import generate_reference
self.reference_number = generate_reference("APR", self.hospital)
if self.national_id:
self.national_id_hash = compute_national_id_hash(self.national_id)
else:
self.national_id_hash = ""
super().save(*args, **kwargs)
def get_owner(self):
"""
Returns the owner of this appreciation.
Cascade: section(champion, supervisor, deputy_supervisor)
-> department(champion, deputy_manager, supervisor,
deputy_supervisor, manager_2nd, manager_3rd).
Returns: Staff instance or None.
"""
if self.section:
for role in ("champion", "supervisor", "deputy_supervisor"):
owner = getattr(self.section, role, None)
if owner:
return owner
if self.department:
dept = self.department
for role in ("champion", "deputy_manager",
"supervisor", "deputy_supervisor",
"manager_2nd", "manager_3rd"):
owner = getattr(dept, role, None)
if owner:
return owner
return None
def get_localized_message(self):
from django.utils.translation import get_language
if get_language() == "ar" and self.message_ar:
return self.message_ar
return self.message_en
def get_recipient_name(self):
"""Get recipient's name"""
try:
return str(self.recipient)
except Exception:
return "Unknown"
def get_recipient_email(self):
"""Get recipient's email"""
try:
if hasattr(self.recipient, "email"):
return self.recipient.email
elif hasattr(self.recipient, "user"):
return self.recipient.user.email
except Exception:
pass
return None
def get_recipient_phone(self):
"""Get recipient's phone"""
try:
if hasattr(self.recipient, "phone"):
return self.recipient.phone
elif hasattr(self.recipient, "user"):
return self.recipient.user.phone
except Exception:
pass
return None
def activate(self, activated_by=None):
from django.utils import timezone
if self.status != AppreciationStatus.DRAFT:
raise ValueError(f"Cannot activate appreciation in '{self.status}' status. Must be in 'draft'.")
self.status = AppreciationStatus.ACTIVATED
self.activated_at = timezone.now()
self.activated_by = activated_by
self.save(update_fields=["status", "activated_at", "activated_by"])
def mark_ai_analyzed(self, analysis_data):
from django.utils import timezone
self.ai_analyzed_at = timezone.now()
self.ai_analysis = analysis_data
self.save(update_fields=["ai_analyzed_at", "ai_analysis"])
def send(self):
"""Send appreciation and trigger notification"""
from django.utils import timezone
if self.status not in (AppreciationStatus.ACTIVATED,):
raise ValueError(f"Cannot send appreciation in '{self.status}' status. Must be 'activated'.")
self.status = AppreciationStatus.SENT
self.sent_at = timezone.now()
self.save(update_fields=["status", "sent_at"])
self.send_notification()
def send_notification(self):
"""Send notification to recipient — handled by post_save signal."""
pass
class AppreciationBadge(UUIDModel, TimeStampedModel):
"""
Appreciation badge for gamification.
Badges are awarded based on achievements (e.g., "10 Appreciations Received").
"""
# Organization
hospital = models.ForeignKey(
"organizations.Hospital",
on_delete=models.CASCADE,
related_name="appreciation_badges",
null=True,
blank=True,
help_text="Leave blank for system-wide badges",
)
# Badge details
code = models.CharField(max_length=50, unique=True, help_text="Unique badge code")
name_en = models.CharField(max_length=200)
name_ar = models.CharField(max_length=200, blank=True)
description_en = models.TextField(blank=True)
description_ar = models.TextField(blank=True)
# Visual elements
icon = models.CharField(max_length=50, blank=True, help_text="Icon class")
color = models.CharField(max_length=7, blank=True, help_text="Hex color code")
# Achievement criteria
criteria_type = models.CharField(
max_length=50,
choices=[
("received_count", "Total Appreciations Received"),
("received_month", "Appreciations Received in a Month"),
("streak_weeks", "Consecutive Weeks with Appreciation"),
("diverse_senders", "Appreciations from Different Senders"),
],
db_index=True,
)
criteria_value = models.IntegerField(help_text="Value to achieve (e.g., 10 for 10 appreciations)")
# Display order
order = models.IntegerField(default=0)
# Status
is_active = models.BooleanField(default=True)
class Meta:
ordering = ["hospital", "order", "name_en"]
indexes = [
models.Index(fields=["hospital", "is_active"]),
]
def __str__(self):
hospital_name = self.hospital.name if self.hospital else "System-wide"
return f"{hospital_name} - {self.name_en}"
def get_localized_name(self):
from django.utils.translation import get_language
if get_language() == "ar" and self.name_ar:
return self.name_ar
return self.name_en
def get_localized_description(self):
from django.utils.translation import get_language
if get_language() == "ar" and self.description_ar:
return self.description_ar
return self.description_en
class UserBadge(UUIDModel, TimeStampedModel):
"""
User badge - tracks badges earned by users.
Links users (or physicians) to badges they've earned.
"""
# Recipient (Generic FK to User or Physician)
recipient_content_type = models.ForeignKey(
ContentType, on_delete=models.SET_NULL, null=True, blank=True, related_name="earned_badges_recipients"
)
recipient_object_id = models.UUIDField(null=True, blank=True)
recipient = GenericForeignKey("recipient_content_type", "recipient_object_id")
# Badge
badge = models.ForeignKey(AppreciationBadge, on_delete=models.CASCADE, related_name="earned_by")
# Achievement details
earned_at = models.DateTimeField(auto_now_add=True)
# Context
appreciation_count = models.IntegerField(help_text="Count when badge was earned")
# Metadata
metadata = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ["-earned_at"]
indexes = [
models.Index(fields=["recipient_content_type", "recipient_object_id", "-earned_at"]),
]
def __str__(self):
recipient_name = self.get_recipient_name()
return f"{recipient_name} - {self.badge.name_en}"
def get_recipient_name(self):
"""Get recipient's name"""
try:
return str(self.recipient)
except Exception:
return "Unknown"
class AppreciationStats(UUIDModel, TimeStampedModel):
"""
Appreciation statistics - aggregated monthly.
Provides monthly statistics for users and physicians.
"""
# Recipient (Generic FK to User or Physician)
recipient_content_type = models.ForeignKey(
ContentType, on_delete=models.SET_NULL, null=True, blank=True, related_name="appreciation_stats_recipients"
)
recipient_object_id = models.UUIDField(null=True, blank=True)
recipient = GenericForeignKey("recipient_content_type", "recipient_object_id")
# Time period
year = models.IntegerField(db_index=True)
month = models.IntegerField(db_index=True, help_text="1-12")
# Organization
hospital = models.ForeignKey("organizations.Hospital", on_delete=models.CASCADE, related_name="appreciation_stats")
department = models.ForeignKey(
"organizations.Department", on_delete=models.SET_NULL, null=True, blank=True, related_name="appreciation_stats"
)
# Statistics
received_count = models.IntegerField(default=0)
sent_count = models.IntegerField(default=0)
acknowledged_count = models.IntegerField(default=0)
# Ranking
hospital_rank = models.IntegerField(null=True, blank=True, help_text="Rank within hospital")
department_rank = models.IntegerField(null=True, blank=True, help_text="Rank within department")
# Breakdown by category
category_breakdown = models.JSONField(default=dict, blank=True, help_text="Breakdown by category ID and count")
# Metadata
metadata = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ["-year", "-month", "-received_count"]
unique_together = [["recipient_content_type", "recipient_object_id", "year", "month"]]
indexes = [
models.Index(fields=["hospital", "year", "month", "-received_count"]),
models.Index(fields=["department", "year", "month", "-received_count"]),
]
def __str__(self):
recipient_name = self.get_recipient_name()
return f"{recipient_name} - {self.year}-{self.month:02d}: {self.received_count} received"
def get_recipient_name(self):
"""Get recipient's name"""
try:
return str(self.recipient)
except Exception:
return "Unknown"