HH/apps/feedback/models.py
2026-03-28 14:03:56 +03:00

645 lines
21 KiB
Python

"""
Feedback models - Patient feedback and suggestions management
This module implements the feedback management system that:
- Tracks patient feedback (compliments, suggestions, general feedback)
- Manages feedback workflow (submitted → reviewed → acknowledged → closed)
- Maintains feedback responses and timeline
- Supports attachments and ratings
"""
from django.conf import settings
from django.db import models
from django.utils import timezone
from apps.core.models import PriorityChoices, TimeStampedModel, UUIDModel
class FeedbackType(models.TextChoices):
"""Feedback type choices"""
COMPLIMENT = "compliment", "Compliment"
SUGGESTION = "suggestion", "Suggestion"
GENERAL = "general", "General Feedback"
INQUIRY = "inquiry", "Inquiry"
SATISFACTION_CHECK = "satisfaction_check", "Satisfaction Check"
class FeedbackStatus(models.TextChoices):
"""Feedback status choices"""
SUBMITTED = "submitted", "Submitted"
REVIEWED = "reviewed", "Reviewed"
ACKNOWLEDGED = "acknowledged", "Acknowledged"
CLOSED = "closed", "Closed"
class FeedbackCategory(models.TextChoices):
"""Feedback category choices"""
CLINICAL_CARE = "clinical_care", "Clinical Care"
STAFF_SERVICE = "staff_service", "Staff Service"
FACILITY = "facility", "Facility & Environment"
COMMUNICATION = "communication", "Communication"
APPOINTMENT = "appointment", "Appointment & Scheduling"
BILLING = "billing", "Billing & Insurance"
FOOD_SERVICE = "food_service", "Food Service"
CLEANLINESS = "cleanliness", "Cleanliness"
TECHNOLOGY = "technology", "Technology & Systems"
OTHER = "other", "Other"
class SentimentChoices(models.TextChoices):
"""Sentiment analysis choices"""
POSITIVE = "positive", "Positive"
NEUTRAL = "neutral", "Neutral"
NEGATIVE = "negative", "Negative"
class Feedback(UUIDModel, TimeStampedModel):
"""
Feedback model for patient feedback, compliments, and suggestions.
Workflow:
1. SUBMITTED - Feedback received
2. REVIEWED - Being reviewed by staff
3. ACKNOWLEDGED - Response provided
4. CLOSED - Feedback closed
"""
# Patient and encounter information
patient = models.ForeignKey(
"organizations.Patient",
on_delete=models.CASCADE,
related_name="feedbacks",
null=True,
blank=True,
help_text="Patient who provided feedback (optional for anonymous feedback)",
)
# Anonymous feedback support
is_anonymous = models.BooleanField(default=False)
contact_name = models.CharField(max_length=200, blank=True)
contact_email = models.EmailField(blank=True)
contact_phone = models.CharField(max_length=20, blank=True)
encounter_id = models.CharField(
max_length=100, blank=True, db_index=True, help_text="Related encounter ID if applicable"
)
# Survey linkage (for satisfaction checks after negative surveys)
related_survey = models.ForeignKey(
"surveys.SurveyInstance",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="follow_up_feedbacks",
help_text="Survey that triggered this satisfaction check feedback",
)
# Organization
hospital = models.ForeignKey("organizations.Hospital", on_delete=models.CASCADE, related_name="feedbacks")
department = models.ForeignKey(
"organizations.Department", on_delete=models.SET_NULL, null=True, blank=True, related_name="feedbacks"
)
staff = models.ForeignKey(
"organizations.Staff",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="feedbacks",
help_text="Staff member being mentioned in feedback",
)
# Feedback details
feedback_type = models.CharField(
max_length=20, choices=FeedbackType.choices, default=FeedbackType.GENERAL, db_index=True
)
title = models.CharField(max_length=500)
message = models.TextField(help_text="Feedback message")
# Classification
category = models.CharField(max_length=50, choices=FeedbackCategory.choices, db_index=True)
subcategory = models.CharField(max_length=100, blank=True)
# Rating (1-5 stars)
rating = models.IntegerField(null=True, blank=True, help_text="Rating from 1 to 5 stars")
# Priority
priority = models.CharField(
max_length=20, choices=PriorityChoices.choices, default=PriorityChoices.MEDIUM, db_index=True
)
# Sentiment analysis
sentiment = models.CharField(
max_length=20,
choices=SentimentChoices.choices,
default=SentimentChoices.NEUTRAL,
db_index=True,
help_text="Sentiment analysis result",
)
sentiment_score = models.FloatField(
null=True, blank=True, help_text="Sentiment score from -1 (negative) to 1 (positive)"
)
# Status and workflow
status = models.CharField(
max_length=20, choices=FeedbackStatus.choices, default=FeedbackStatus.SUBMITTED, db_index=True
)
# Assignment
assigned_to = models.ForeignKey(
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_feedbacks"
)
assigned_at = models.DateTimeField(null=True, blank=True)
# Review tracking
reviewed_at = models.DateTimeField(null=True, blank=True)
reviewed_by = models.ForeignKey(
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="reviewed_feedbacks"
)
# Acknowledgment
acknowledged_at = models.DateTimeField(null=True, blank=True)
acknowledged_by = models.ForeignKey(
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="acknowledged_feedbacks"
)
# Closure
closed_at = models.DateTimeField(null=True, blank=True)
closed_by = models.ForeignKey(
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="closed_feedbacks"
)
# Flags
is_featured = models.BooleanField(default=False, help_text="Feature this feedback (e.g., for testimonials)")
is_public = models.BooleanField(default=False, help_text="Make this feedback public")
requires_follow_up = models.BooleanField(default=False)
# Source
source = models.ForeignKey(
"px_sources.PXSource",
on_delete=models.PROTECT,
related_name="feedbacks",
null=True,
blank=True,
help_text="Source of feedback",
)
# Metadata
metadata = models.JSONField(default=dict, blank=True)
# Soft delete
is_deleted = models.BooleanField(default=False, db_index=True)
deleted_at = models.DateTimeField(null=True, blank=True)
deleted_by = models.ForeignKey(
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="deleted_feedbacks"
)
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["status", "-created_at"]),
models.Index(fields=["hospital", "status", "-created_at"]),
models.Index(fields=["feedback_type", "-created_at"]),
models.Index(fields=["sentiment", "-created_at"]),
models.Index(fields=["is_deleted", "-created_at"]),
]
verbose_name_plural = "Feedback"
def __str__(self):
if self.patient:
return f"{self.title} - {self.patient.get_full_name()} ({self.feedback_type})"
return f"{self.title} - Anonymous ({self.feedback_type})"
def get_contact_name(self):
"""Get contact name (patient or anonymous)"""
if self.patient:
return self.patient.get_full_name()
return self.contact_name or "Anonymous"
def soft_delete(self, user=None):
"""Soft delete feedback"""
self.is_deleted = True
self.deleted_at = timezone.now()
self.deleted_by = user
self.save(update_fields=["is_deleted", "deleted_at", "deleted_by"])
class FeedbackAttachment(UUIDModel, TimeStampedModel):
"""Feedback attachment (images, documents, etc.)"""
feedback = models.ForeignKey(Feedback, on_delete=models.CASCADE, related_name="attachments")
file = models.FileField(upload_to="feedback/%Y/%m/%d/")
filename = models.CharField(max_length=500)
file_type = models.CharField(max_length=100, blank=True)
file_size = models.IntegerField(help_text="File size in bytes")
uploaded_by = models.ForeignKey(
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="feedback_attachments"
)
description = models.TextField(blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"{self.feedback} - {self.filename}"
class FeedbackResponse(UUIDModel, TimeStampedModel):
"""
Feedback response/timeline entry.
Tracks all responses, status changes, and communications.
"""
feedback = models.ForeignKey(Feedback, on_delete=models.CASCADE, related_name="responses")
# Response details
response_type = models.CharField(
max_length=50,
choices=[
("status_change", "Status Change"),
("assignment", "Assignment"),
("note", "Internal Note"),
("response", "Response to Patient"),
("acknowledgment", "Acknowledgment"),
],
db_index=True,
)
message = models.TextField()
# User who made the response
created_by = models.ForeignKey(
"accounts.User", on_delete=models.SET_NULL, null=True, related_name="feedback_responses"
)
# Status change tracking
old_status = models.CharField(max_length=20, blank=True)
new_status = models.CharField(max_length=20, blank=True)
# Visibility
is_internal = models.BooleanField(default=False, help_text="Internal note (not visible to patient)")
# Metadata
metadata = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["feedback", "-created_at"]),
]
def __str__(self):
return f"{self.feedback} - {self.response_type} - {self.created_at.strftime('%Y-%m-%d %H:%M')}"
class CommentSourceCategory(models.TextChoices):
"""Source category from IT department data export (Step 0)"""
APPOINTMENT = "appointment", "Appointment"
INPATIENT = "inpatient", "Inpatient"
OUTPATIENT = "outpatient", "Outpatient"
class CommentClassification(models.TextChoices):
"""Comment classification categories (Step 1 — Colors sheet)"""
HOSPITAL = "hospital", "Hospital"
MEDICAL = "medical", "Medical"
NON_MEDICAL = "non_medical", "Non-Medical"
NURSING = "nursing", "Nursing"
ER = "er", "ER"
SUPPORT_SERVICES = "support_services", "Support Services"
class CommentSubCategory(models.TextChoices):
"""Comment sub-categories (Step 1 — Colors sheet dropdown)"""
PHARMACY = "pharmacy", "Pharmacy"
RAD = "rad", "RAD"
LAB = "lab", "LAB"
PHYSIOTHERAPY = "physiotherapy", "Physiotherapy"
DOCTORS = "doctors", "Doctors"
MEDICAL_REPORTS = "medical_reports", "Medical Reports"
RECEPTION = "reception", "Reception"
INSURANCE_APPROVALS = "insurance_approvals", "Insurance/Approvals"
OPD_CLINICS = "opd_clinics", "OPD - Clinics"
APPOINTMENTS = "appointments", "Appointments"
IT_APP = "it_app", "IT - App"
ADMINISTRATION = "administration", "Administration"
BILLING = "billing", "Billing"
FACILITIES = "facilities", "Facilities"
FOOD_SERVICES = "food_services", "Food Services"
PARKING = "parking", "Parking"
HOUSEKEEPING = "housekeeping", "Housekeeping"
OTHER = "other", "Other"
COMMENT_SUB_CATEGORY_MAP = {
CommentClassification.MEDICAL: [
CommentSubCategory.PHARMACY,
CommentSubCategory.RAD,
CommentSubCategory.LAB,
CommentSubCategory.PHYSIOTHERAPY,
CommentSubCategory.DOCTORS,
CommentSubCategory.MEDICAL_REPORTS,
],
CommentClassification.NON_MEDICAL: [
CommentSubCategory.RECEPTION,
CommentSubCategory.INSURANCE_APPROVALS,
CommentSubCategory.OPD_CLINICS,
CommentSubCategory.APPOINTMENTS,
CommentSubCategory.IT_APP,
CommentSubCategory.ADMINISTRATION,
CommentSubCategory.BILLING,
],
CommentClassification.NURSING: [],
CommentClassification.ER: [
CommentSubCategory.DOCTORS,
CommentSubCategory.RECEPTION,
],
CommentClassification.SUPPORT_SERVICES: [
CommentSubCategory.FACILITIES,
CommentSubCategory.FOOD_SERVICES,
CommentSubCategory.PARKING,
CommentSubCategory.HOUSEKEEPING,
],
CommentClassification.HOSPITAL: [],
}
class CommentImport(UUIDModel, TimeStampedModel):
"""
Tracks IT department comment data imports (Step 0).
Each import represents a monthly batch of raw patient comments
exported from the IT system.
"""
IMPORT_STATUS_CHOICES = [
("pending", "Pending"),
("processing", "Processing"),
("completed", "Completed"),
("failed", "Failed"),
]
hospital = models.ForeignKey(
"organizations.Hospital",
on_delete=models.CASCADE,
related_name="comment_imports",
)
month = models.IntegerField(help_text="Month number (1-12)")
year = models.IntegerField(help_text="Year")
source_file = models.FileField(
upload_to="comments/imports/%Y/%m/",
blank=True,
help_text="Uploaded source file from IT department",
)
status = models.CharField(max_length=20, choices=IMPORT_STATUS_CHOICES, default="pending")
total_rows = models.IntegerField(default=0, help_text="Total rows in the import file")
imported_count = models.IntegerField(default=0, help_text="Number of comments successfully imported")
error_count = models.IntegerField(default=0)
error_log = models.TextField(blank=True)
imported_by = models.ForeignKey(
"accounts.User",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="comment_imports",
)
class Meta:
ordering = ["-year", "-month"]
unique_together = [["hospital", "year", "month"]]
verbose_name = "Comment Import"
verbose_name_plural = "Comment Imports"
def __str__(self):
return f"{self.hospital} - {self.year}-{self.month:02d} ({self.imported_count} comments)"
class PatientComment(UUIDModel, TimeStampedModel):
"""
Patient comment from IT department data export.
Workflow (Steps 0-5):
- Step 0: Raw import from IT (source_category, comment_text)
- Step 1: Classification (classification, sub_category, sentiment keywords)
- Step 2: Department filtering (grouped by department for review)
- Step 3: Action plan creation (linked action plans)
- Step 5: Action plan tracking (status, timeframe, evidence)
One comment can have multiple classifications (multi-category).
Use PatientCommentClassification for multi-category support.
"""
comment_import = models.ForeignKey(
CommentImport,
on_delete=models.CASCADE,
related_name="comments",
null=True,
blank=True,
help_text="Import batch this comment came from",
)
hospital = models.ForeignKey(
"organizations.Hospital",
on_delete=models.CASCADE,
related_name="patient_comments",
)
serial_number = models.IntegerField(null=True, blank=True, help_text="Serial number from the source file")
source_category = models.CharField(
max_length=20,
choices=CommentSourceCategory.choices,
blank=True,
db_index=True,
help_text="Source category from IT export (Appointment/Inpatient/Outpatient)",
)
comment_text = models.TextField(help_text="Original comment text (Arabic/English)")
comment_text_en = models.TextField(blank=True, help_text="English translation of the comment")
classification = models.CharField(
max_length=20,
choices=CommentClassification.choices,
blank=True,
db_index=True,
help_text="Primary classification (Hospital/Medical/Non-Medical/Nursing/ER/Support)",
)
sub_category = models.CharField(
max_length=30,
choices=CommentSubCategory.choices,
blank=True,
db_index=True,
help_text="Sub-category classification (e.g., Pharmacy, Reception, etc.)",
)
negative_keywords = models.TextField(blank=True, help_text="Negative sentiment keywords/phrases extracted")
positive_keywords = models.TextField(blank=True, help_text="Positive sentiment keywords/phrases extracted")
gratitude_keywords = models.TextField(blank=True, help_text="Gratitude keywords/phrases extracted")
suggestions = models.TextField(blank=True, help_text="Suggestion text extracted from the comment")
sentiment = models.CharField(
max_length=20,
choices=SentimentChoices.choices,
blank=True,
db_index=True,
help_text="Overall sentiment classification",
)
is_classified = models.BooleanField(
default=False,
db_index=True,
help_text="Whether this comment has been classified",
)
mentioned_doctor_name = models.CharField(
max_length=200,
blank=True,
help_text="Name of doctor mentioned in the comment",
)
mentioned_doctor_name_en = models.CharField(
max_length=200,
blank=True,
help_text="English name of mentioned doctor",
)
frequency = models.IntegerField(
default=1,
help_text="How many times this comment/problem has been reported",
)
month = models.IntegerField(null=True, blank=True, help_text="Month the comment was collected")
year = models.IntegerField(null=True, blank=True, help_text="Year the comment was collected")
metadata = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ["-year", "-month", "-serial_number"]
indexes = [
models.Index(fields=["hospital", "classification", "sub_category"]),
models.Index(fields=["hospital", "year", "month"]),
models.Index(fields=["hospital", "source_category"]),
models.Index(fields=["sentiment"]),
]
verbose_name = "Patient Comment"
verbose_name_plural = "Patient Comments"
def __str__(self):
text = self.comment_text[:50] if self.comment_text else "No text"
return f"#{self.serial_number or ''} {text}"
class CommentActionPlanStatus(models.TextChoices):
COMPLETED = "completed", "Completed"
ON_PROCESS = "on_process", "On Process"
PENDING = "pending", "Pending"
class CommentActionPlan(UUIDModel, TimeStampedModel):
"""
Action plan derived from classified patient comments (Step 3/5).
Tracks recommendations/action plans with responsible departments,
timeframes, and completion status.
"""
comment = models.ForeignKey(
PatientComment,
on_delete=models.CASCADE,
related_name="action_plans",
null=True,
blank=True,
help_text="Source comment (may be null if aggregated from multiple)",
)
hospital = models.ForeignKey(
"organizations.Hospital",
on_delete=models.CASCADE,
related_name="comment_action_plans",
)
department = models.ForeignKey(
"organizations.Department",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="comment_action_plans",
help_text="Responsible department",
)
department_label = models.CharField(
max_length=200,
blank=True,
help_text="Free-text department name (for when no FK exists)",
)
problem_number = models.IntegerField(null=True, blank=True, help_text="Problem number within the department")
comment_text = models.TextField(blank=True, help_text="Related comment text")
comment_text_en = models.TextField(blank=True, help_text="English translation of the comment")
frequency = models.IntegerField(default=1, help_text="Number of times this problem was reported")
recommendation = models.TextField(help_text="Recommendation / Action Plan")
recommendation_en = models.TextField(blank=True, help_text="English translation of the recommendation")
responsible_department = models.CharField(
max_length=200,
blank=True,
help_text="Free-text responsible department name",
)
status = models.CharField(
max_length=20,
choices=CommentActionPlanStatus.choices,
default=CommentActionPlanStatus.PENDING,
db_index=True,
)
timeframe = models.CharField(
max_length=100,
blank=True,
help_text="Target timeframe (e.g., Q3, 3 months, 2025-Q1)",
)
evidences = models.TextField(blank=True, help_text="Evidence of completion / notes")
month = models.IntegerField(null=True, blank=True)
year = models.IntegerField(null=True, blank=True)
class Meta:
ordering = ["department_label", "problem_number"]
indexes = [
models.Index(fields=["hospital", "status"]),
models.Index(fields=["hospital", "year", "month"]),
]
verbose_name = "Comment Action Plan"
verbose_name_plural = "Comment Action Plans"
def __str__(self):
dept = self.department_label or (self.department.name if self.department else "")
return f"{dept} - Problem #{self.problem_number or ''} ({self.get_status_display()})"