diff --git a/apps/feedback/admin.py b/apps/feedback/admin.py index 3265659..2dc5998 100644 --- a/apps/feedback/admin.py +++ b/apps/feedback/admin.py @@ -1,6 +1,109 @@ """ -Feedback admin +Feedback admin configuration """ from django.contrib import admin -# TODO: Register models for feedback +from .models import Feedback, FeedbackAttachment, FeedbackResponse + + +@admin.register(Feedback) +class FeedbackAdmin(admin.ModelAdmin): + """Admin interface for Feedback model""" + list_display = [ + 'id', 'feedback_type', 'title', 'get_contact_name', 'hospital', + 'status', 'sentiment', 'rating', 'is_featured', 'created_at' + ] + list_filter = [ + 'feedback_type', 'status', 'sentiment', 'category', + 'priority', 'is_featured', 'is_deleted', 'created_at' + ] + search_fields = [ + 'title', 'message', 'patient__first_name', 'patient__last_name', + 'patient__mrn', 'contact_name', 'contact_email' + ] + readonly_fields = [ + 'id', 'created_at', 'updated_at', 'assigned_at', 'reviewed_at', + 'acknowledged_at', 'closed_at', 'deleted_at' + ] + fieldsets = ( + ('Basic Information', { + 'fields': ( + 'id', 'feedback_type', 'title', 'message', 'category', + 'subcategory', 'rating', 'priority' + ) + }), + ('Patient/Contact', { + 'fields': ( + 'patient', 'is_anonymous', 'contact_name', 'contact_email', + 'contact_phone' + ) + }), + ('Organization', { + 'fields': ('hospital', 'department', 'physician', 'encounter_id') + }), + ('Status & Workflow', { + 'fields': ( + 'status', 'assigned_to', 'assigned_at', 'reviewed_by', + 'reviewed_at', 'acknowledged_by', 'acknowledged_at', + 'closed_by', 'closed_at' + ) + }), + ('Sentiment Analysis', { + 'fields': ('sentiment', 'sentiment_score') + }), + ('Flags', { + 'fields': ( + 'is_featured', 'is_public', 'requires_follow_up', + 'is_deleted', 'deleted_at', 'deleted_by' + ) + }), + ('Metadata', { + 'fields': ('source', 'metadata', 'created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + date_hierarchy = 'created_at' + ordering = ['-created_at'] + + +@admin.register(FeedbackAttachment) +class FeedbackAttachmentAdmin(admin.ModelAdmin): + """Admin interface for FeedbackAttachment model""" + list_display = [ + 'id', 'feedback', 'filename', 'file_type', 'file_size', + 'uploaded_by', 'created_at' + ] + list_filter = ['file_type', 'created_at'] + search_fields = ['filename', 'feedback__title'] + readonly_fields = ['id', 'created_at', 'updated_at'] + date_hierarchy = 'created_at' + ordering = ['-created_at'] + + +@admin.register(FeedbackResponse) +class FeedbackResponseAdmin(admin.ModelAdmin): + """Admin interface for FeedbackResponse model""" + list_display = [ + 'id', 'feedback', 'response_type', 'created_by', + 'is_internal', 'created_at' + ] + list_filter = ['response_type', 'is_internal', 'created_at'] + search_fields = ['message', 'feedback__title'] + readonly_fields = ['id', 'created_at', 'updated_at'] + fieldsets = ( + ('Response Information', { + 'fields': ( + 'id', 'feedback', 'response_type', 'message', + 'created_by', 'is_internal' + ) + }), + ('Status Change', { + 'fields': ('old_status', 'new_status') + }), + ('Metadata', { + 'fields': ('metadata', 'created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + date_hierarchy = 'created_at' + ordering = ['-created_at'] diff --git a/apps/feedback/forms.py b/apps/feedback/forms.py new file mode 100644 index 0000000..18805aa --- /dev/null +++ b/apps/feedback/forms.py @@ -0,0 +1,339 @@ +""" +Feedback forms - Forms for feedback management +""" +from django import forms + +from apps.organizations.models import Department, Hospital, Patient, Physician + +from .models import Feedback, FeedbackResponse, FeedbackStatus, FeedbackType, FeedbackCategory + + +class FeedbackForm(forms.ModelForm): + """Form for creating and editing feedback""" + + class Meta: + model = Feedback + fields = [ + 'patient', + 'is_anonymous', + 'contact_name', + 'contact_email', + 'contact_phone', + 'hospital', + 'department', + 'physician', + 'feedback_type', + 'title', + 'message', + 'category', + 'subcategory', + 'rating', + 'priority', + 'encounter_id', + ] + widgets = { + 'patient': forms.Select(attrs={ + 'class': 'form-select', + 'id': 'id_patient' + }), + 'is_anonymous': forms.CheckboxInput(attrs={ + 'class': 'form-check-input', + 'id': 'id_is_anonymous' + }), + 'contact_name': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Enter contact name' + }), + 'contact_email': forms.EmailInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Enter email address' + }), + 'contact_phone': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Enter phone number' + }), + 'hospital': forms.Select(attrs={ + 'class': 'form-select', + 'required': True + }), + 'department': forms.Select(attrs={ + 'class': 'form-select' + }), + 'physician': forms.Select(attrs={ + 'class': 'form-select' + }), + 'feedback_type': forms.Select(attrs={ + 'class': 'form-select', + 'required': True + }), + 'title': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Enter feedback title', + 'required': True + }), + 'message': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 5, + 'placeholder': 'Enter your feedback message...', + 'required': True + }), + 'category': forms.Select(attrs={ + 'class': 'form-select', + 'required': True + }), + 'subcategory': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Enter subcategory (optional)' + }), + 'rating': forms.NumberInput(attrs={ + 'class': 'form-control', + 'min': 1, + 'max': 5, + 'placeholder': 'Rate from 1 to 5' + }), + 'priority': forms.Select(attrs={ + 'class': 'form-select' + }), + 'encounter_id': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Enter encounter ID (optional)' + }), + } + + def __init__(self, *args, **kwargs): + user = kwargs.pop('user', None) + super().__init__(*args, **kwargs) + + # Filter hospitals based on user permissions + if user: + if not user.is_px_admin() and user.hospital: + self.fields['hospital'].queryset = Hospital.objects.filter( + id=user.hospital.id, + status='active' + ) + else: + self.fields['hospital'].queryset = Hospital.objects.filter(status='active') + + # Set initial hospital if user has one + if user and user.hospital and not self.instance.pk: + self.fields['hospital'].initial = user.hospital + + # Filter departments and physicians based on selected hospital + if self.instance.pk and self.instance.hospital: + self.fields['department'].queryset = Department.objects.filter( + hospital=self.instance.hospital, + status='active' + ) + self.fields['physician'].queryset = Physician.objects.filter( + hospital=self.instance.hospital, + status='active' + ) + else: + self.fields['department'].queryset = Department.objects.none() + self.fields['physician'].queryset = Physician.objects.none() + + # Make patient optional if anonymous + if self.data.get('is_anonymous'): + self.fields['patient'].required = False + + def clean(self): + cleaned_data = super().clean() + is_anonymous = cleaned_data.get('is_anonymous') + patient = cleaned_data.get('patient') + contact_name = cleaned_data.get('contact_name') + + # Validate anonymous feedback + if is_anonymous: + if not contact_name: + raise forms.ValidationError( + "Contact name is required for anonymous feedback." + ) + else: + if not patient: + raise forms.ValidationError( + "Please select a patient or mark as anonymous." + ) + + # Validate rating + rating = cleaned_data.get('rating') + if rating is not None and (rating < 1 or rating > 5): + raise forms.ValidationError("Rating must be between 1 and 5.") + + return cleaned_data + + +class FeedbackResponseForm(forms.ModelForm): + """Form for adding responses to feedback""" + + class Meta: + model = FeedbackResponse + fields = ['response_type', 'message', 'is_internal'] + widgets = { + 'response_type': forms.Select(attrs={ + 'class': 'form-select', + 'required': True + }), + 'message': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 4, + 'placeholder': 'Enter your response...', + 'required': True + }), + 'is_internal': forms.CheckboxInput(attrs={ + 'class': 'form-check-input' + }), + } + + +class FeedbackFilterForm(forms.Form): + """Form for filtering feedback list""" + + search = forms.CharField( + required=False, + widget=forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Search by title, message, patient...' + }) + ) + + feedback_type = forms.ChoiceField( + required=False, + choices=[('', 'All Types')] + list(FeedbackType.choices), + widget=forms.Select(attrs={'class': 'form-select'}) + ) + + status = forms.ChoiceField( + required=False, + choices=[('', 'All Statuses')] + list(FeedbackStatus.choices), + widget=forms.Select(attrs={'class': 'form-select'}) + ) + + category = forms.ChoiceField( + required=False, + choices=[('', 'All Categories')] + list(FeedbackCategory.choices), + widget=forms.Select(attrs={'class': 'form-select'}) + ) + + sentiment = forms.ChoiceField( + required=False, + choices=[ + ('', 'All Sentiments'), + ('positive', 'Positive'), + ('neutral', 'Neutral'), + ('negative', 'Negative'), + ], + widget=forms.Select(attrs={'class': 'form-select'}) + ) + + priority = forms.ChoiceField( + required=False, + choices=[ + ('', 'All Priorities'), + ('low', 'Low'), + ('medium', 'Medium'), + ('high', 'High'), + ('urgent', 'Urgent'), + ], + widget=forms.Select(attrs={'class': 'form-select'}) + ) + + hospital = forms.ModelChoiceField( + required=False, + queryset=Hospital.objects.filter(status='active'), + widget=forms.Select(attrs={'class': 'form-select'}), + empty_label='All Hospitals' + ) + + department = forms.ModelChoiceField( + required=False, + queryset=Department.objects.filter(status='active'), + widget=forms.Select(attrs={'class': 'form-select'}), + empty_label='All Departments' + ) + + rating_min = forms.IntegerField( + required=False, + min_value=1, + max_value=5, + widget=forms.NumberInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Min rating' + }) + ) + + rating_max = forms.IntegerField( + required=False, + min_value=1, + max_value=5, + widget=forms.NumberInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Max rating' + }) + ) + + date_from = forms.DateField( + required=False, + widget=forms.DateInput(attrs={ + 'class': 'form-control', + 'type': 'date' + }) + ) + + date_to = forms.DateField( + required=False, + widget=forms.DateInput(attrs={ + 'class': 'form-control', + 'type': 'date' + }) + ) + + is_featured = forms.BooleanField( + required=False, + widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}) + ) + + requires_follow_up = forms.BooleanField( + required=False, + widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}) + ) + + +class FeedbackStatusChangeForm(forms.Form): + """Form for changing feedback status""" + + status = forms.ChoiceField( + choices=FeedbackStatus.choices, + widget=forms.Select(attrs={ + 'class': 'form-select', + 'required': True + }) + ) + + note = forms.CharField( + required=False, + widget=forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 3, + 'placeholder': 'Add a note about this status change (optional)...' + }) + ) + + +class FeedbackAssignForm(forms.Form): + """Form for assigning feedback to a user""" + + user_id = forms.UUIDField( + widget=forms.Select(attrs={ + 'class': 'form-select', + 'required': True + }) + ) + + note = forms.CharField( + required=False, + widget=forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 2, + 'placeholder': 'Add a note about this assignment (optional)...' + }) + ) diff --git a/apps/feedback/migrations/0001_initial.py b/apps/feedback/migrations/0001_initial.py new file mode 100644 index 0000000..12d1d76 --- /dev/null +++ b/apps/feedback/migrations/0001_initial.py @@ -0,0 +1,127 @@ +# Generated by Django 5.0.14 on 2025-12-24 10:22 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('organizations', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Feedback', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('is_anonymous', models.BooleanField(default=False)), + ('contact_name', models.CharField(blank=True, max_length=200)), + ('contact_email', models.EmailField(blank=True, max_length=254)), + ('contact_phone', models.CharField(blank=True, max_length=20)), + ('encounter_id', models.CharField(blank=True, db_index=True, help_text='Related encounter ID if applicable', max_length=100)), + ('feedback_type', models.CharField(choices=[('compliment', 'Compliment'), ('suggestion', 'Suggestion'), ('general', 'General Feedback'), ('inquiry', 'Inquiry')], db_index=True, default='general', max_length=20)), + ('title', models.CharField(max_length=500)), + ('message', models.TextField(help_text='Feedback message')), + ('category', models.CharField(choices=[('clinical_care', 'Clinical Care'), ('staff_service', 'Staff Service'), ('facility', 'Facility & Environment'), ('communication', 'Communication'), ('appointment', 'Appointment & Scheduling'), ('billing', 'Billing & Insurance'), ('food_service', 'Food Service'), ('cleanliness', 'Cleanliness'), ('technology', 'Technology & Systems'), ('other', 'Other')], db_index=True, max_length=50)), + ('subcategory', models.CharField(blank=True, max_length=100)), + ('rating', models.IntegerField(blank=True, help_text='Rating from 1 to 5 stars', null=True)), + ('priority', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], db_index=True, default='medium', max_length=20)), + ('sentiment', models.CharField(choices=[('positive', 'Positive'), ('neutral', 'Neutral'), ('negative', 'Negative')], db_index=True, default='neutral', help_text='Sentiment analysis result', max_length=20)), + ('sentiment_score', models.FloatField(blank=True, help_text='Sentiment score from -1 (negative) to 1 (positive)', null=True)), + ('status', models.CharField(choices=[('submitted', 'Submitted'), ('reviewed', 'Reviewed'), ('acknowledged', 'Acknowledged'), ('closed', 'Closed')], db_index=True, default='submitted', max_length=20)), + ('assigned_at', models.DateTimeField(blank=True, null=True)), + ('reviewed_at', models.DateTimeField(blank=True, null=True)), + ('acknowledged_at', models.DateTimeField(blank=True, null=True)), + ('closed_at', models.DateTimeField(blank=True, null=True)), + ('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', models.CharField(default='web', help_text='Source of feedback (web, mobile, kiosk, etc.)', max_length=50)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('is_deleted', models.BooleanField(db_index=True, default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('acknowledged_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='acknowledged_feedbacks', to=settings.AUTH_USER_MODEL)), + ('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_feedbacks', to=settings.AUTH_USER_MODEL)), + ('closed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='closed_feedbacks', to=settings.AUTH_USER_MODEL)), + ('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_feedbacks', to=settings.AUTH_USER_MODEL)), + ('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.department')), + ('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feedbacks', to='organizations.hospital')), + ('patient', models.ForeignKey(blank=True, help_text='Patient who provided feedback (optional for anonymous feedback)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='feedbacks', to='organizations.patient')), + ('physician', models.ForeignKey(blank=True, help_text='Physician being mentioned in feedback', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.physician')), + ('reviewed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_feedbacks', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name_plural': 'Feedback', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='FeedbackAttachment', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('file', models.FileField(upload_to='feedback/%Y/%m/%d/')), + ('filename', models.CharField(max_length=500)), + ('file_type', models.CharField(blank=True, max_length=100)), + ('file_size', models.IntegerField(help_text='File size in bytes')), + ('description', models.TextField(blank=True)), + ('feedback', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='feedback.feedback')), + ('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedback_attachments', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='FeedbackResponse', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('response_type', models.CharField(choices=[('status_change', 'Status Change'), ('assignment', 'Assignment'), ('note', 'Internal Note'), ('response', 'Response to Patient'), ('acknowledgment', 'Acknowledgment')], db_index=True, max_length=50)), + ('message', models.TextField()), + ('old_status', models.CharField(blank=True, max_length=20)), + ('new_status', models.CharField(blank=True, max_length=20)), + ('is_internal', models.BooleanField(default=False, help_text='Internal note (not visible to patient)')), + ('metadata', models.JSONField(blank=True, default=dict)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedback_responses', to=settings.AUTH_USER_MODEL)), + ('feedback', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='feedback.feedback')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.AddIndex( + model_name='feedback', + index=models.Index(fields=['status', '-created_at'], name='feedback_fe_status_212662_idx'), + ), + migrations.AddIndex( + model_name='feedback', + index=models.Index(fields=['hospital', 'status', '-created_at'], name='feedback_fe_hospita_4c1146_idx'), + ), + migrations.AddIndex( + model_name='feedback', + index=models.Index(fields=['feedback_type', '-created_at'], name='feedback_fe_feedbac_6b63a4_idx'), + ), + migrations.AddIndex( + model_name='feedback', + index=models.Index(fields=['sentiment', '-created_at'], name='feedback_fe_sentime_443190_idx'), + ), + migrations.AddIndex( + model_name='feedback', + index=models.Index(fields=['is_deleted', '-created_at'], name='feedback_fe_is_dele_f543d5_idx'), + ), + migrations.AddIndex( + model_name='feedbackresponse', + index=models.Index(fields=['feedback', '-created_at'], name='feedback_fe_feedbac_bc9e33_idx'), + ), + ] diff --git a/apps/feedback/migrations/__init__.py b/apps/feedback/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/feedback/models.py b/apps/feedback/models.py index aea4c2b..33d034f 100644 --- a/apps/feedback/models.py +++ b/apps/feedback/models.py @@ -1,6 +1,350 @@ """ -Feedback models -""" -from django.db import models +Feedback models - Patient feedback and suggestions management -# TODO: Add models for feedback +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' + + +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" + ) + + # 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' + ) + physician = models.ForeignKey( + 'organizations.Physician', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='feedbacks', + help_text="Physician 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) + + # Metadata + source = models.CharField( + max_length=50, + default='web', + help_text="Source of feedback (web, mobile, kiosk, etc.)" + ) + 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')}" diff --git a/apps/feedback/urls.py b/apps/feedback/urls.py index 11d49d7..7f2b766 100644 --- a/apps/feedback/urls.py +++ b/apps/feedback/urls.py @@ -1,7 +1,28 @@ +""" +Feedback URL Configuration +""" from django.urls import path +from . import views + app_name = 'feedback' urlpatterns = [ - # TODO: Add URL patterns + # List and detail views + path('', views.feedback_list, name='feedback_list'), + path('/', views.feedback_detail, name='feedback_detail'), + + # CRUD operations + path('create/', views.feedback_create, name='feedback_create'), + path('/update/', views.feedback_update, name='feedback_update'), + path('/delete/', views.feedback_delete, name='feedback_delete'), + + # Workflow actions + path('/assign/', views.feedback_assign, name='feedback_assign'), + path('/change-status/', views.feedback_change_status, name='feedback_change_status'), + path('/add-response/', views.feedback_add_response, name='feedback_add_response'), + + # Toggle actions + path('/toggle-featured/', views.feedback_toggle_featured, name='feedback_toggle_featured'), + path('/toggle-follow-up/', views.feedback_toggle_follow_up, name='feedback_toggle_follow_up'), ] diff --git a/apps/feedback/views.py b/apps/feedback/views.py index e0333b2..f7c468f 100644 --- a/apps/feedback/views.py +++ b/apps/feedback/views.py @@ -1,6 +1,589 @@ """ -Feedback views +Feedback views - Server-rendered templates for feedback management """ -from django.shortcuts import render +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.core.paginator import Paginator +from django.db.models import Q, Count, Avg +from django.shortcuts import get_object_or_404, redirect, render +from django.utils import timezone +from django.views.decorators.http import require_http_methods -# TODO: Add views for feedback +from apps.accounts.models import User +from apps.core.services import AuditService +from apps.organizations.models import Department, Hospital, Patient, Physician + +from .models import ( + Feedback, + FeedbackAttachment, + FeedbackResponse, + FeedbackStatus, + FeedbackType, + FeedbackCategory, +) +from .forms import ( + FeedbackForm, + FeedbackResponseForm, + FeedbackStatusChangeForm, +) + + +@login_required +def feedback_list(request): + """ + Feedback list view with advanced filters and pagination. + + Features: + - Server-side pagination + - Advanced filters (status, type, sentiment, category, hospital, etc.) + - Search by title, message, patient name + - Statistics dashboard + - Export capability + """ + # Base queryset with optimizations + queryset = Feedback.objects.select_related( + 'patient', 'hospital', 'department', 'physician', + 'assigned_to', 'reviewed_by', 'acknowledged_by', 'closed_by' + ).filter(is_deleted=False) + + # Apply RBAC filters + user = request.user + if user.is_px_admin(): + pass # See all + elif user.is_hospital_admin() and user.hospital: + queryset = queryset.filter(hospital=user.hospital) + elif user.is_department_manager() and user.department: + queryset = queryset.filter(department=user.department) + elif user.hospital: + queryset = queryset.filter(hospital=user.hospital) + else: + queryset = queryset.none() + + # Apply filters from request + feedback_type_filter = request.GET.get('feedback_type') + if feedback_type_filter: + queryset = queryset.filter(feedback_type=feedback_type_filter) + + status_filter = request.GET.get('status') + if status_filter: + queryset = queryset.filter(status=status_filter) + + category_filter = request.GET.get('category') + if category_filter: + queryset = queryset.filter(category=category_filter) + + sentiment_filter = request.GET.get('sentiment') + if sentiment_filter: + queryset = queryset.filter(sentiment=sentiment_filter) + + priority_filter = request.GET.get('priority') + if priority_filter: + queryset = queryset.filter(priority=priority_filter) + + hospital_filter = request.GET.get('hospital') + if hospital_filter: + queryset = queryset.filter(hospital_id=hospital_filter) + + department_filter = request.GET.get('department') + if department_filter: + queryset = queryset.filter(department_id=department_filter) + + physician_filter = request.GET.get('physician') + if physician_filter: + queryset = queryset.filter(physician_id=physician_filter) + + assigned_to_filter = request.GET.get('assigned_to') + if assigned_to_filter: + queryset = queryset.filter(assigned_to_id=assigned_to_filter) + + rating_min = request.GET.get('rating_min') + if rating_min: + queryset = queryset.filter(rating__gte=rating_min) + + rating_max = request.GET.get('rating_max') + if rating_max: + queryset = queryset.filter(rating__lte=rating_max) + + is_featured = request.GET.get('is_featured') + if is_featured == 'true': + queryset = queryset.filter(is_featured=True) + + requires_follow_up = request.GET.get('requires_follow_up') + if requires_follow_up == 'true': + queryset = queryset.filter(requires_follow_up=True) + + # Search + search_query = request.GET.get('search') + if search_query: + queryset = queryset.filter( + Q(title__icontains=search_query) | + Q(message__icontains=search_query) | + Q(patient__mrn__icontains=search_query) | + Q(patient__first_name__icontains=search_query) | + Q(patient__last_name__icontains=search_query) | + Q(contact_name__icontains=search_query) + ) + + # Date range filters + date_from = request.GET.get('date_from') + if date_from: + queryset = queryset.filter(created_at__gte=date_from) + + date_to = request.GET.get('date_to') + if date_to: + queryset = queryset.filter(created_at__lte=date_to) + + # Ordering + order_by = request.GET.get('order_by', '-created_at') + queryset = queryset.order_by(order_by) + + # Pagination + page_size = int(request.GET.get('page_size', 25)) + paginator = Paginator(queryset, page_size) + page_number = request.GET.get('page', 1) + page_obj = paginator.get_page(page_number) + + # Get filter options + hospitals = Hospital.objects.filter(status='active') + if not user.is_px_admin() and user.hospital: + hospitals = hospitals.filter(id=user.hospital.id) + + departments = Department.objects.filter(status='active') + if not user.is_px_admin() and user.hospital: + departments = departments.filter(hospital=user.hospital) + + # Get assignable users + assignable_users = User.objects.filter(is_active=True) + if user.hospital: + assignable_users = assignable_users.filter(hospital=user.hospital) + + # Statistics + stats = { + 'total': queryset.count(), + 'submitted': queryset.filter(status=FeedbackStatus.SUBMITTED).count(), + 'reviewed': queryset.filter(status=FeedbackStatus.REVIEWED).count(), + 'acknowledged': queryset.filter(status=FeedbackStatus.ACKNOWLEDGED).count(), + 'compliments': queryset.filter(feedback_type=FeedbackType.COMPLIMENT).count(), + 'suggestions': queryset.filter(feedback_type=FeedbackType.SUGGESTION).count(), + 'avg_rating': queryset.aggregate(Avg('rating'))['rating__avg'] or 0, + 'positive': queryset.filter(sentiment='positive').count(), + 'negative': queryset.filter(sentiment='negative').count(), + } + + context = { + 'page_obj': page_obj, + 'feedbacks': page_obj.object_list, + 'stats': stats, + 'hospitals': hospitals, + 'departments': departments, + 'assignable_users': assignable_users, + 'status_choices': FeedbackStatus.choices, + 'type_choices': FeedbackType.choices, + 'category_choices': FeedbackCategory.choices, + 'filters': request.GET, + } + + return render(request, 'feedback/feedback_list.html', context) + + +@login_required +def feedback_detail(request, pk): + """ + Feedback detail view with timeline, attachments, and actions. + + Features: + - Full feedback details + - Timeline of all responses + - Attachments management + - Workflow actions (assign, status change, add response) + """ + feedback = get_object_or_404( + Feedback.objects.select_related( + 'patient', 'hospital', 'department', 'physician', + 'assigned_to', 'reviewed_by', 'acknowledged_by', 'closed_by' + ).prefetch_related( + 'attachments', + 'responses__created_by' + ), + pk=pk, + is_deleted=False + ) + + # Check access + user = request.user + if not user.is_px_admin(): + if user.is_hospital_admin() and feedback.hospital != user.hospital: + messages.error(request, "You don't have permission to view this feedback.") + return redirect('feedback:feedback_list') + elif user.is_department_manager() and feedback.department != user.department: + messages.error(request, "You don't have permission to view this feedback.") + return redirect('feedback:feedback_list') + elif user.hospital and feedback.hospital != user.hospital: + messages.error(request, "You don't have permission to view this feedback.") + return redirect('feedback:feedback_list') + + # Get timeline (responses) + timeline = feedback.responses.all().order_by('-created_at') + + # Get attachments + attachments = feedback.attachments.all().order_by('-created_at') + + # Get assignable users + assignable_users = User.objects.filter(is_active=True) + if feedback.hospital: + assignable_users = assignable_users.filter(hospital=feedback.hospital) + + context = { + 'feedback': feedback, + 'timeline': timeline, + 'attachments': attachments, + 'assignable_users': assignable_users, + 'status_choices': FeedbackStatus.choices, + 'can_edit': user.is_px_admin() or user.is_hospital_admin(), + } + + return render(request, 'feedback/feedback_detail.html', context) + + +@login_required +@require_http_methods(["GET", "POST"]) +def feedback_create(request): + """Create new feedback""" + if request.method == 'POST': + form = FeedbackForm(request.POST, user=request.user) + if form.is_valid(): + try: + feedback = form.save(commit=False) + + # Set default sentiment if not set + if not feedback.sentiment: + feedback.sentiment = 'neutral' + + feedback.save() + + # Create initial response + FeedbackResponse.objects.create( + feedback=feedback, + response_type='note', + message=f"Feedback submitted by {request.user.get_full_name()}", + created_by=request.user, + is_internal=True + ) + + # Log audit + AuditService.log_event( + event_type='feedback_created', + description=f"Feedback created: {feedback.title}", + user=request.user, + content_object=feedback, + metadata={ + 'feedback_type': feedback.feedback_type, + 'category': feedback.category, + 'rating': feedback.rating + } + ) + + messages.success(request, f"Feedback #{feedback.id} created successfully.") + return redirect('feedback:feedback_detail', pk=feedback.id) + + except Exception as e: + messages.error(request, f"Error creating feedback: {str(e)}") + else: + messages.error(request, "Please correct the errors below.") + else: + form = FeedbackForm(user=request.user) + + # Get patients for selection + patients = Patient.objects.filter(status='active') + if request.user.hospital: + patients = patients.filter(primary_hospital=request.user.hospital) + + context = { + 'form': form, + 'patients': patients, + 'is_create': True, + } + + return render(request, 'feedback/feedback_form.html', context) + + +@login_required +@require_http_methods(["GET", "POST"]) +def feedback_update(request, pk): + """Update existing feedback""" + feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False) + + # Check permission + user = request.user + if not (user.is_px_admin() or user.is_hospital_admin()): + messages.error(request, "You don't have permission to edit feedback.") + return redirect('feedback:feedback_detail', pk=pk) + + if request.method == 'POST': + form = FeedbackForm(request.POST, instance=feedback, user=request.user) + if form.is_valid(): + try: + feedback = form.save() + + # Create update response + FeedbackResponse.objects.create( + feedback=feedback, + response_type='note', + message=f"Feedback updated by {request.user.get_full_name()}", + created_by=request.user, + is_internal=True + ) + + # Log audit + AuditService.log_event( + event_type='feedback_updated', + description=f"Feedback updated: {feedback.title}", + user=request.user, + content_object=feedback + ) + + messages.success(request, "Feedback updated successfully.") + return redirect('feedback:feedback_detail', pk=feedback.id) + + except Exception as e: + messages.error(request, f"Error updating feedback: {str(e)}") + else: + messages.error(request, "Please correct the errors below.") + else: + form = FeedbackForm(instance=feedback, user=request.user) + + # Get patients for selection + patients = Patient.objects.filter(status='active') + if request.user.hospital: + patients = patients.filter(primary_hospital=request.user.hospital) + + context = { + 'form': form, + 'feedback': feedback, + 'patients': patients, + 'is_create': False, + } + + return render(request, 'feedback/feedback_form.html', context) + + +@login_required +@require_http_methods(["GET", "POST"]) +def feedback_delete(request, pk): + """Soft delete feedback""" + feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False) + + # Check permission + user = request.user + if not (user.is_px_admin() or user.is_hospital_admin()): + messages.error(request, "You don't have permission to delete feedback.") + return redirect('feedback:feedback_detail', pk=pk) + + if request.method == 'POST': + try: + feedback.soft_delete(user=request.user) + + # Log audit + AuditService.log_event( + event_type='feedback_deleted', + description=f"Feedback deleted: {feedback.title}", + user=request.user, + content_object=feedback + ) + + messages.success(request, "Feedback deleted successfully.") + return redirect('feedback:feedback_list') + + except Exception as e: + messages.error(request, f"Error deleting feedback: {str(e)}") + return redirect('feedback:feedback_detail', pk=pk) + + context = { + 'feedback': feedback, + } + + return render(request, 'feedback/feedback_delete_confirm.html', context) + + +@login_required +@require_http_methods(["POST"]) +def feedback_assign(request, pk): + """Assign feedback to user""" + feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False) + + # Check permission + user = request.user + if not (user.is_px_admin() or user.is_hospital_admin()): + messages.error(request, "You don't have permission to assign feedback.") + return redirect('feedback:feedback_detail', pk=pk) + + user_id = request.POST.get('user_id') + note = request.POST.get('note', '') + + if not user_id: + messages.error(request, "Please select a user to assign.") + return redirect('feedback:feedback_detail', pk=pk) + + try: + assignee = User.objects.get(id=user_id) + feedback.assigned_to = assignee + feedback.assigned_at = timezone.now() + feedback.save(update_fields=['assigned_to', 'assigned_at']) + + # Create response + message = f"Assigned to {assignee.get_full_name()}" + if note: + message += f"\nNote: {note}" + + FeedbackResponse.objects.create( + feedback=feedback, + response_type='assignment', + message=message, + created_by=request.user, + is_internal=True + ) + + # Log audit + AuditService.log_event( + event_type='assignment', + description=f"Feedback assigned to {assignee.get_full_name()}", + user=request.user, + content_object=feedback + ) + + messages.success(request, f"Feedback assigned to {assignee.get_full_name()}.") + + except User.DoesNotExist: + messages.error(request, "User not found.") + + return redirect('feedback:feedback_detail', pk=pk) + + +@login_required +@require_http_methods(["POST"]) +def feedback_change_status(request, pk): + """Change feedback status""" + feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False) + + # Check permission + user = request.user + if not (user.is_px_admin() or user.is_hospital_admin()): + messages.error(request, "You don't have permission to change feedback status.") + return redirect('feedback:feedback_detail', pk=pk) + + new_status = request.POST.get('status') + note = request.POST.get('note', '') + + if not new_status: + messages.error(request, "Please select a status.") + return redirect('feedback:feedback_detail', pk=pk) + + old_status = feedback.status + feedback.status = new_status + + # Handle status-specific logic + if new_status == FeedbackStatus.REVIEWED: + feedback.reviewed_at = timezone.now() + feedback.reviewed_by = request.user + elif new_status == FeedbackStatus.ACKNOWLEDGED: + feedback.acknowledged_at = timezone.now() + feedback.acknowledged_by = request.user + elif new_status == FeedbackStatus.CLOSED: + feedback.closed_at = timezone.now() + feedback.closed_by = request.user + + feedback.save() + + # Create response + message = note or f"Status changed from {old_status} to {new_status}" + + FeedbackResponse.objects.create( + feedback=feedback, + response_type='status_change', + message=message, + created_by=request.user, + old_status=old_status, + new_status=new_status, + is_internal=True + ) + + # Log audit + AuditService.log_event( + event_type='status_change', + description=f"Feedback status changed from {old_status} to {new_status}", + user=request.user, + content_object=feedback, + metadata={'old_status': old_status, 'new_status': new_status} + ) + + messages.success(request, f"Feedback status changed to {new_status}.") + return redirect('feedback:feedback_detail', pk=pk) + + +@login_required +@require_http_methods(["POST"]) +def feedback_add_response(request, pk): + """Add response to feedback""" + feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False) + + response_type = request.POST.get('response_type', 'response') + message = request.POST.get('message') + is_internal = request.POST.get('is_internal') == 'on' + + if not message: + messages.error(request, "Please enter a response message.") + return redirect('feedback:feedback_detail', pk=pk) + + # Create response + FeedbackResponse.objects.create( + feedback=feedback, + response_type=response_type, + message=message, + created_by=request.user, + is_internal=is_internal + ) + + messages.success(request, "Response added successfully.") + return redirect('feedback:feedback_detail', pk=pk) + + +@login_required +@require_http_methods(["POST"]) +def feedback_toggle_featured(request, pk): + """Toggle featured status""" + feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False) + + # Check permission + user = request.user + if not (user.is_px_admin() or user.is_hospital_admin()): + messages.error(request, "You don't have permission to feature feedback.") + return redirect('feedback:feedback_detail', pk=pk) + + feedback.is_featured = not feedback.is_featured + feedback.save(update_fields=['is_featured']) + + status = "featured" if feedback.is_featured else "unfeatured" + messages.success(request, f"Feedback {status} successfully.") + + return redirect('feedback:feedback_detail', pk=pk) + + +@login_required +@require_http_methods(["POST"]) +def feedback_toggle_follow_up(request, pk): + """Toggle follow-up required status""" + feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False) + + # Check permission + user = request.user + if not (user.is_px_admin() or user.is_hospital_admin()): + messages.error(request, "You don't have permission to modify feedback.") + return redirect('feedback:feedback_detail', pk=pk) + + feedback.requires_follow_up = not feedback.requires_follow_up + feedback.save(update_fields=['requires_follow_up']) + + status = "marked for follow-up" if feedback.requires_follow_up else "unmarked for follow-up" + messages.success(request, f"Feedback {status} successfully.") + + return redirect('feedback:feedback_detail', pk=pk) diff --git a/config/urls.py b/config/urls.py index 0bfbcf4..58509bf 100644 --- a/config/urls.py +++ b/config/urls.py @@ -23,6 +23,7 @@ urlpatterns = [ # UI Pages path('complaints/', include('apps.complaints.urls')), + path('feedback/', include('apps.feedback.urls')), path('actions/', include('apps.px_action_center.urls')), path('journeys/', include('apps.journeys.urls')), path('surveys/', include('apps.surveys.urls')), @@ -35,7 +36,6 @@ urlpatterns = [ # API endpoints path('api/auth/', include('apps.accounts.urls')), - path('api/feedback/', include('apps.feedback.urls')), path('api/physicians/', include('apps.physicians.urls')), path('api/integrations/', include('apps.integrations.urls')), path('api/notifications/', include('apps.notifications.urls')), diff --git a/generate_saudi_data.py b/generate_saudi_data.py index 158d268..bca2217 100644 --- a/generate_saudi_data.py +++ b/generate_saudi_data.py @@ -99,6 +99,72 @@ COMPLAINT_TITLES_AR = [ ] +def clear_existing_data(): + """Clear all existing data from the database""" + print("\n" + "="*60) + print("Clearing Existing Data...") + print("="*60 + "\n") + + from apps.feedback.models import Feedback, FeedbackAttachment, FeedbackResponse + from apps.social.models import SocialMention + from apps.callcenter.models import CallCenterInteraction + + # Delete in reverse order of dependencies + print("Deleting survey instances...") + SurveyResponse.objects.all().delete() + SurveyInstance.objects.all().delete() + + print("Deleting journey instances...") + PatientJourneyStageInstance.objects.all().delete() + PatientJourneyInstance.objects.all().delete() + + print("Deleting PX actions...") + PXAction.objects.all().delete() + + print("Deleting QI projects...") + QIProject.objects.all().delete() + + print("Deleting social mentions...") + SocialMention.objects.all().delete() + + print("Deleting call center interactions...") + CallCenterInteraction.objects.all().delete() + + print("Deleting feedback...") + FeedbackResponse.objects.all().delete() + FeedbackAttachment.objects.all().delete() + Feedback.objects.all().delete() + + print("Deleting complaints...") + ComplaintUpdate.objects.all().delete() + Complaint.objects.all().delete() + + print("Deleting survey templates...") + SurveyQuestion.objects.all().delete() + SurveyTemplate.objects.all().delete() + + print("Deleting journey templates...") + PatientJourneyStageTemplate.objects.all().delete() + PatientJourneyTemplate.objects.all().delete() + + print("Deleting patients...") + Patient.objects.all().delete() + + print("Deleting physicians...") + Physician.objects.all().delete() + + print("Deleting departments...") + Department.objects.all().delete() + + print("Deleting hospitals...") + Hospital.objects.all().delete() + + print("Deleting users (except superusers)...") + User.objects.filter(is_superuser=False).delete() + + print("\n✓ All existing data cleared successfully!\n") + + def generate_saudi_phone(): """Generate Saudi phone number""" return f"+966{random.choice(['50', '53', '54', '55', '56', '58'])}{random.randint(1000000, 9999999)}" @@ -300,6 +366,92 @@ def create_complaints(patients, hospitals, physicians, users): return complaints +def create_feedback(patients, hospitals, physicians, users): + """Create sample feedback""" + print("Creating feedback...") + from apps.feedback.models import Feedback, FeedbackResponse + + feedback_titles = [ + 'Excellent care from Dr. Ahmed', + 'Very satisfied with the service', + 'Suggestion to improve waiting area', + 'Great experience at the hospital', + 'Staff was very helpful and kind', + 'Clean and well-maintained facility', + 'Quick and efficient service', + 'Appreciate the professionalism', + 'Suggestion for better parking', + 'Outstanding nursing care', + ] + + feedback_messages = [ + 'I had a wonderful experience. The staff was very professional and caring.', + 'The doctor took time to explain everything clearly. Very satisfied.', + 'I suggest adding more seating in the waiting area for better comfort.', + 'Everything was excellent from registration to discharge.', + 'The nurses were extremely helpful and answered all my questions.', + 'The facility is very clean and well-organized.', + 'I was seen quickly and the process was very smooth.', + 'I appreciate the high level of professionalism shown by all staff.', + 'The parking area could be improved with better signage.', + 'The nursing staff provided outstanding care during my stay.', + ] + + feedbacks = [] + for i in range(40): + patient = random.choice(patients) + hospital = patient.primary_hospital or random.choice(hospitals) + is_anonymous = random.random() < 0.2 # 20% anonymous + + feedback = Feedback.objects.create( + patient=None if is_anonymous else patient, + is_anonymous=is_anonymous, + contact_name=f"{random.choice(ENGLISH_FIRST_NAMES_MALE)} {random.choice(ENGLISH_LAST_NAMES)}" if is_anonymous else '', + contact_email=f"anonymous{i}@example.com" if is_anonymous else '', + contact_phone=generate_saudi_phone() if is_anonymous else '', + hospital=hospital, + department=random.choice(hospital.departments.all()) if hospital.departments.exists() and random.random() > 0.5 else None, + physician=random.choice(physicians) if random.random() > 0.6 else None, + feedback_type=random.choice(['compliment', 'suggestion', 'general', 'inquiry']), + title=random.choice(feedback_titles), + message=random.choice(feedback_messages), + category=random.choice(['clinical_care', 'staff_service', 'facility', 'communication', 'appointment', 'cleanliness']), + rating=random.randint(3, 5) if random.random() > 0.3 else None, + priority=random.choice(['low', 'medium', 'high']), + sentiment=random.choice(['positive', 'neutral', 'negative']), + sentiment_score=random.uniform(0.3, 1.0) if random.random() > 0.7 else None, + status=random.choice(['submitted', 'reviewed', 'acknowledged', 'closed']), + encounter_id=f"ENC{random.randint(100000, 999999)}" if random.random() > 0.5 else '', + assigned_to=random.choice(users) if random.random() > 0.5 else None, + is_featured=random.random() < 0.15, # 15% featured + requires_follow_up=random.random() < 0.2, # 20% require follow-up + ) + + # Add initial response + FeedbackResponse.objects.create( + feedback=feedback, + response_type='note', + message=f"Feedback received and logged in the system.", + created_by=random.choice(users), + is_internal=True, + ) + + # Add additional responses for some feedback + if feedback.status in ['reviewed', 'acknowledged', 'closed'] and random.random() > 0.5: + FeedbackResponse.objects.create( + feedback=feedback, + response_type='response', + message="Thank you for your feedback. We appreciate your input and will work to improve our services.", + created_by=random.choice(users), + is_internal=False, + ) + + feedbacks.append(feedback) + + print(f" Created {len(feedbacks)} feedback items") + return feedbacks + + def create_survey_templates(hospitals): """Create survey templates""" print("Creating survey templates...") @@ -580,6 +732,9 @@ def main(): print("PX360 - Saudi-Influenced Data Generator") print("="*60 + "\n") + # Clear existing data first + clear_existing_data() + # Create base data hospitals = create_hospitals() departments = create_departments(hospitals) @@ -592,6 +747,7 @@ def main(): # Create operational data complaints = create_complaints(patients, hospitals, physicians, users) + feedbacks = create_feedback(patients, hospitals, physicians, users) create_survey_templates(hospitals) create_journey_templates(hospitals) projects = create_qi_projects(hospitals) @@ -611,6 +767,7 @@ def main(): print(f" - {len(patients)} Patients") print(f" - {len(users)} Users") print(f" - {len(complaints)} Complaints") + print(f" - {len(feedbacks)} Feedback Items") print(f" - {len(actions)} PX Actions") print(f" - {len(journey_instances)} Journey Instances") print(f" - {len(survey_instances)} Survey Instances") diff --git a/templates/feedback/feedback_delete_confirm.html b/templates/feedback/feedback_delete_confirm.html new file mode 100644 index 0000000..c169d0e --- /dev/null +++ b/templates/feedback/feedback_delete_confirm.html @@ -0,0 +1,191 @@ +{% extends "layouts/base.html" %} +{% load i18n %} +{% load static %} + +{% block title %}Delete Feedback - PX360{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ +
+ +
+ +
+
+
+
+

+ + Delete Feedback +

+
+
+
+ +

Are you sure you want to delete this feedback?

+

+ This action will soft delete the feedback. The feedback will be marked as deleted + but will remain in the database for audit purposes. +

+
+ + + + + + + + +
+ {% csrf_token %} + + Cancel + + +
+ + +
+ + + Deleted feedback can be restored by system administrators if needed. + +
+
+
+
+
+
+{% endblock %} diff --git a/templates/feedback/feedback_detail.html b/templates/feedback/feedback_detail.html new file mode 100644 index 0000000..7e69e69 --- /dev/null +++ b/templates/feedback/feedback_detail.html @@ -0,0 +1,605 @@ +{% extends "layouts/base.html" %} +{% load i18n %} +{% load static %} + +{% block title %}Feedback Detail - PX360{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ +
+
+ +

+ + Feedback Detail +

+
+
+ {% if can_edit %} + + Edit + + + Delete + + {% endif %} + + Back to List + +
+
+ +
+ +
+ +
+
+
+ Feedback Information +
+ + {{ feedback.get_feedback_type_display }} + + + {{ feedback.get_status_display }} + + {% if feedback.is_featured %} + ★ FEATURED + {% endif %} +
+
+
+
+
+
ID:
+
+ {{ feedback.id }} +
+
+
+
Title:
+
+ {{ feedback.title }} +
+
+
+
Message:
+
+

{{ feedback.message }}

+
+
+
+
Category:
+
+ {{ feedback.get_category_display }} + {% if feedback.subcategory %} + / {{ feedback.subcategory }} + {% endif %} +
+
+ {% if feedback.rating %} +
+
Rating:
+
+ + {% for i in "12345" %} + {% if forloop.counter <= feedback.rating %} + + {% else %} + + {% endif %} + {% endfor %} + + ({{ feedback.rating }}/5) +
+
+ {% endif %} +
+
Sentiment:
+
+ + {{ feedback.get_sentiment_display }} + + {% if feedback.sentiment_score %} + (Score: {{ feedback.sentiment_score|floatformat:2 }}) + {% endif %} +
+
+
+
Priority:
+
+ {{ feedback.get_priority_display }} +
+
+
+
Created:
+
+ {{ feedback.created_at|date:"F d, Y H:i" }} +
+
+
+
+ + +
+
Patient/Contact Information
+
+ {% if feedback.patient %} +
+
Patient:
+
+ {{ feedback.patient.get_full_name }} +
+
+
+
MRN:
+
{{ feedback.patient.mrn }}
+
+ {% if feedback.patient.phone %} +
+
Phone:
+
{{ feedback.patient.phone }}
+
+ {% endif %} + {% if feedback.patient.email %} +
+
Email:
+
{{ feedback.patient.email }}
+
+ {% endif %} + {% else %} +
+
Type:
+
+ Anonymous Feedback +
+
+ {% if feedback.contact_name %} +
+
Contact Name:
+
{{ feedback.contact_name }}
+
+ {% endif %} + {% if feedback.contact_email %} +
+
Email:
+
{{ feedback.contact_email }}
+
+ {% endif %} + {% if feedback.contact_phone %} +
+
Phone:
+
{{ feedback.contact_phone }}
+
+ {% endif %} + {% endif %} +
+
+ + +
+
Organization Information
+
+
+
Hospital:
+
{{ feedback.hospital.name }}
+
+ {% if feedback.department %} +
+
Department:
+
{{ feedback.department.name }}
+
+ {% endif %} + {% if feedback.physician %} +
+
Physician:
+
{{ feedback.physician.get_full_name }}
+
+ {% endif %} + {% if feedback.encounter_id %} +
+
Encounter ID:
+
{{ feedback.encounter_id }}
+
+ {% endif %} +
+
+ + +
+
Timeline & Responses
+
+ {% if timeline %} +
+ {% for response in timeline %} +
+
+
+
+ + {% if response.created_by %} + {{ response.created_by.get_full_name }} + {% else %} + System + {% endif %} + + {{ response.get_response_type_display }} + {% if response.is_internal %} + Internal + {% endif %} +
+ + {{ response.created_at|date:"M d, Y H:i" }} + +
+
{{ response.message }}
+
+
+ {% endfor %} +
+ {% else %} +

No responses yet

+ {% endif %} +
+
+
+ + +
+ + {% if can_edit %} +
+
Actions
+
+ +
+ {% csrf_token %} + + + + +
+ +
+ + +
+ {% csrf_token %} + + + +
+ +
+ + +
+ {% csrf_token %} + + + +
+ + +
+ +
+ +
+ + +
+
+ {% csrf_token %} + +
+
+ {% csrf_token %} + +
+
+
+
+ {% endif %} + + +
+
Assignment & Tracking
+
+
+
Assigned To:
+
+ {% if feedback.assigned_to %} + {{ feedback.assigned_to.get_full_name }} +
{{ feedback.assigned_at|date:"M d, Y H:i" }} + {% else %} + Unassigned + {% endif %} +
+
+ {% if feedback.reviewed_by %} +
+
Reviewed By:
+
+ {{ feedback.reviewed_by.get_full_name }} +
{{ feedback.reviewed_at|date:"M d, Y H:i" }} +
+
+ {% endif %} + {% if feedback.acknowledged_by %} +
+
Acknowledged By:
+
+ {{ feedback.acknowledged_by.get_full_name }} +
{{ feedback.acknowledged_at|date:"M d, Y H:i" }} +
+
+ {% endif %} + {% if feedback.closed_by %} +
+
Closed By:
+
+ {{ feedback.closed_by.get_full_name }} +
{{ feedback.closed_at|date:"M d, Y H:i" }} +
+
+ {% endif %} +
+
+ + +
+
Flags & Settings
+
+
+
Featured:
+
+ {% if feedback.is_featured %} + Yes + {% else %} + No + {% endif %} +
+
+
+
Public:
+
+ {% if feedback.is_public %} + Yes + {% else %} + No + {% endif %} +
+
+
+
Follow-up Required:
+
+ {% if feedback.requires_follow_up %} + Yes + {% else %} + No + {% endif %} +
+
+
+
Source:
+
+ {{ feedback.source }} +
+
+
+
+ + + {% if attachments %} +
+
Attachments ({{ attachments.count }})
+
+
+ {% for attachment in attachments %} +
+
+
+ + {{ attachment.filename }} +
+ + {{ attachment.file_size|filesizeformat }} • + {{ attachment.created_at|date:"M d, Y" }} + +
+ + + +
+
+ {% endfor %} +
+
+
+ {% endif %} +
+
+
+{% endblock %} diff --git a/templates/feedback/feedback_form.html b/templates/feedback/feedback_form.html new file mode 100644 index 0000000..02638c3 --- /dev/null +++ b/templates/feedback/feedback_form.html @@ -0,0 +1,380 @@ +{% extends "layouts/base.html" %} +{% load i18n %} +{% load static %} + +{% block title %}{% if is_create %}Create{% else %}Edit{% endif %} Feedback - PX360{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ +
+
+ +

+ + {% if is_create %}Create New Feedback{% else %}Edit Feedback{% endif %} +

+
+ +
+ +
+
+
+ {% csrf_token %} + + + {% if form.non_field_errors %} + + {% endif %} + + +
+
+ Patient/Contact Information +
+
+ +
+
+ {{ form.is_anonymous }} + +
+ {% if form.is_anonymous.errors %} +
{{ form.is_anonymous.errors }}
+ {% endif %} +
+ + +
+ + {{ form.patient }} + {% if form.patient.help_text %} +
{{ form.patient.help_text }}
+ {% endif %} + {% if form.patient.errors %} +
{{ form.patient.errors }}
+ {% endif %} +
+ + + +
+
+ + +
+
+ Feedback Details +
+
+ +
+ + {{ form.feedback_type }} + {% if form.feedback_type.errors %} +
{{ form.feedback_type.errors }}
+ {% endif %} +
+ + +
+ + {{ form.title }} + {% if form.title.errors %} +
{{ form.title.errors }}
+ {% endif %} +
+ + +
+ + {{ form.message }} +
Please provide detailed feedback
+ {% if form.message.errors %} +
{{ form.message.errors }}
+ {% endif %} +
+ + +
+
+ + {{ form.category }} + {% if form.category.errors %} +
{{ form.category.errors }}
+ {% endif %} +
+
+ + {{ form.subcategory }} + {% if form.subcategory.errors %} +
{{ form.subcategory.errors }}
+ {% endif %} +
+
+ + +
+ +
+ + + + + + + + + + +
+
Rate your experience from 1 to 5 stars
+ {% if form.rating.errors %} +
{{ form.rating.errors }}
+ {% endif %} +
+ + +
+ + {{ form.priority }} + {% if form.priority.errors %} +
{{ form.priority.errors }}
+ {% endif %} +
+
+
+ + +
+
+ Organization Information +
+
+ +
+ + {{ form.hospital }} + {% if form.hospital.errors %} +
{{ form.hospital.errors }}
+ {% endif %} +
+ + +
+ + {{ form.department }} +
Select the department related to this feedback (optional)
+ {% if form.department.errors %} +
{{ form.department.errors }}
+ {% endif %} +
+ + +
+ + {{ form.physician }} +
Select the physician mentioned in this feedback (optional)
+ {% if form.physician.errors %} +
{{ form.physician.errors }}
+ {% endif %} +
+ + +
+ + {{ form.encounter_id }} +
Related encounter ID if applicable (optional)
+ {% if form.encounter_id.errors %} +
{{ form.encounter_id.errors }}
+ {% endif %} +
+
+
+ + +
+ + Cancel + + +
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/feedback/feedback_list.html b/templates/feedback/feedback_list.html new file mode 100644 index 0000000..ab0aa2b --- /dev/null +++ b/templates/feedback/feedback_list.html @@ -0,0 +1,483 @@ +{% extends "layouts/base.html" %} +{% load i18n %} +{% load static %} + +{% block title %}Feedback Console - PX360{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ +
+
+

+ + Feedback Console +

+

Manage patient feedback, compliments, and suggestions

+
+ +
+ + +
+
+
+
+
+
+
Total Feedback
+

{{ stats.total }}

+
+
+ +
+
+
+
+
+
+
+
+
+
+
Compliments
+

{{ stats.compliments }}

+
+
+ +
+
+
+
+
+
+
+
+
+
+
Avg Rating
+

{{ stats.avg_rating|floatformat:1 }} /5

+
+
+ +
+
+
+
+
+
+
+
+
+
+
Pending Review
+

{{ stats.submitted }}

+
+
+ +
+
+
+
+
+
+ + +
+
+
+ Filters +
+ +
+ +
+
+
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+ + +
+ + +
+
+ + +
+
+ +
+ + + Clear + +
+
+
+
+ + +
+
+ + Showing {{ page_obj.start_index }} to {{ page_obj.end_index }} of {{ page_obj.paginator.count }} feedback items + +
+
+ +
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + {% for feedback in feedbacks %} + + + + + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
+ + IDTypePatient/ContactTitleCategoryRatingSentimentStatusHospitalCreatedActions
+ +

No feedback found

+
+
+
+
+ + + {% if page_obj.has_other_pages %} + + {% endif %} +
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/layouts/partials/sidebar.html b/templates/layouts/partials/sidebar.html index 9f0bdfe..cb1e7e7 100644 --- a/templates/layouts/partials/sidebar.html +++ b/templates/layouts/partials/sidebar.html @@ -29,6 +29,16 @@ + + +