""" 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()})"