From 3a1910a617be7829389f58f51b2ed5765626114c Mon Sep 17 00:00:00 2001 From: ismail Date: Sun, 11 Jan 2026 01:17:24 +0300 Subject: [PATCH] temp commit --- ...lanation_explanationattachment_and_more.py | 68 +++ apps/complaints/models.py | 149 +++++- apps/complaints/urls.py | 14 +- apps/complaints/views.py | 441 +++++++++++++++++- apps/organizations/admin.py | 2 +- .../migrations/0006_staff_email.py | 18 + apps/organizations/models.py | 1 + docs/PDF_GENERATION_IMPLEMENTATION.md | 364 +++++++++++++++ pyproject.toml | 1 + templates/complaints/complaint_detail.html | 374 ++++++++++++++- templates/complaints/complaint_pdf.html | 438 +++++++++++++++++ .../explanation_already_submitted.html | 111 +++++ templates/complaints/explanation_form.html | 156 +++++++ templates/complaints/explanation_success.html | 111 +++++ templates/emails/explanation_request.html | 193 ++++++++ 15 files changed, 2387 insertions(+), 54 deletions(-) create mode 100644 apps/complaints/migrations/0005_complaintexplanation_explanationattachment_and_more.py create mode 100644 apps/organizations/migrations/0006_staff_email.py create mode 100644 docs/PDF_GENERATION_IMPLEMENTATION.md create mode 100644 templates/complaints/complaint_pdf.html create mode 100644 templates/complaints/explanation_already_submitted.html create mode 100644 templates/complaints/explanation_form.html create mode 100644 templates/complaints/explanation_success.html create mode 100644 templates/emails/explanation_request.html diff --git a/apps/complaints/migrations/0005_complaintexplanation_explanationattachment_and_more.py b/apps/complaints/migrations/0005_complaintexplanation_explanationattachment_and_more.py new file mode 100644 index 0000000..1ab5949 --- /dev/null +++ b/apps/complaints/migrations/0005_complaintexplanation_explanationattachment_and_more.py @@ -0,0 +1,68 @@ +# Generated by Django 5.0.14 on 2026-01-10 20:27 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('complaints', '0004_inquiryattachment_inquiryupdate'), + ('organizations', '0006_staff_email'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ComplaintExplanation', + 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)), + ('explanation', models.TextField(help_text="Staff's explanation about the complaint")), + ('token', models.CharField(db_index=True, help_text='Unique access token for explanation submission', max_length=64, unique=True)), + ('is_used', models.BooleanField(db_index=True, default=False, help_text='Token expiry tracking - becomes True after submission')), + ('submitted_via', models.CharField(choices=[('email_link', 'Email Link'), ('direct', 'Direct Entry')], default='email_link', help_text='How the explanation was submitted', max_length=20)), + ('email_sent_at', models.DateTimeField(blank=True, help_text='When the explanation request email was sent', null=True)), + ('responded_at', models.DateTimeField(blank=True, help_text='When the explanation was submitted', null=True)), + ('request_message', models.TextField(blank=True, help_text='Optional message sent with the explanation request')), + ('complaint', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='explanations', to='complaints.complaint')), + ('requested_by', models.ForeignKey(blank=True, help_text='User who requested the explanation', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requested_complaint_explanations', to=settings.AUTH_USER_MODEL)), + ('staff', models.ForeignKey(blank=True, help_text='Staff member who submitted the explanation', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaint_explanations', to='organizations.staff')), + ], + options={ + 'verbose_name': 'Complaint Explanation', + 'verbose_name_plural': 'Complaint Explanations', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ExplanationAttachment', + 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='explanation_attachments/%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)), + ('explanation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='complaints.complaintexplanation')), + ], + options={ + 'verbose_name': 'Explanation Attachment', + 'verbose_name_plural': 'Explanation Attachments', + 'ordering': ['-created_at'], + }, + ), + migrations.AddIndex( + model_name='complaintexplanation', + index=models.Index(fields=['complaint', '-created_at'], name='complaints__complai_b20e58_idx'), + ), + migrations.AddIndex( + model_name='complaintexplanation', + index=models.Index(fields=['token', 'is_used'], name='complaints__token_f8f9b7_idx'), + ), + ] diff --git a/apps/complaints/models.py b/apps/complaints/models.py index a11e7e7..e8141f2 100644 --- a/apps/complaints/models.py +++ b/apps/complaints/models.py @@ -811,7 +811,7 @@ class Inquiry(UUIDModel, TimeStampedModel): class InquiryUpdate(UUIDModel, TimeStampedModel): """ Inquiry update/timeline entry. - + Tracks all updates, status changes, and communications for inquiries. """ inquiry = models.ForeignKey( @@ -819,7 +819,7 @@ class InquiryUpdate(UUIDModel, TimeStampedModel): on_delete=models.CASCADE, related_name='updates' ) - + # Update details update_type = models.CharField( max_length=50, @@ -832,9 +832,9 @@ class InquiryUpdate(UUIDModel, TimeStampedModel): ], db_index=True ) - + message = models.TextField() - + # User who made the update created_by = models.ForeignKey( 'accounts.User', @@ -842,20 +842,20 @@ class InquiryUpdate(UUIDModel, TimeStampedModel): null=True, related_name='inquiry_updates' ) - + # Status change tracking old_status = models.CharField(max_length=20, blank=True) new_status = models.CharField(max_length=20, blank=True) - + # Metadata metadata = models.JSONField(default=dict, blank=True) - + class Meta: ordering = ['-created_at'] indexes = [ models.Index(fields=['inquiry', '-created_at']), ] - + def __str__(self): return f"{self.inquiry} - {self.update_type} - {self.created_at.strftime('%Y-%m-%d %H:%M')}" @@ -867,23 +867,146 @@ class InquiryAttachment(UUIDModel, TimeStampedModel): on_delete=models.CASCADE, related_name='attachments' ) - + file = models.FileField(upload_to='inquiries/%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, related_name='inquiry_attachments' ) - + description = models.TextField(blank=True) - + class Meta: ordering = ['-created_at'] - + def __str__(self): return f"{self.inquiry} - {self.filename}" + + +class ComplaintExplanation(UUIDModel, TimeStampedModel): + """ + Staff/recipient explanation about a complaint. + + Allows staff members to submit their perspective via token-based link. + Each staff member can submit one explanation per complaint. + """ + complaint = models.ForeignKey( + Complaint, + on_delete=models.CASCADE, + related_name='explanations' + ) + + staff = models.ForeignKey( + 'organizations.Staff', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='complaint_explanations', + help_text="Staff member who submitted the explanation" + ) + + explanation = models.TextField( + help_text="Staff's explanation about the complaint" + ) + + token = models.CharField( + max_length=64, + unique=True, + db_index=True, + help_text="Unique access token for explanation submission" + ) + + is_used = models.BooleanField( + default=False, + db_index=True, + help_text="Token expiry tracking - becomes True after submission" + ) + + submitted_via = models.CharField( + max_length=20, + choices=[ + ('email_link', 'Email Link'), + ('direct', 'Direct Entry'), + ], + default='email_link', + help_text="How the explanation was submitted" + ) + + email_sent_at = models.DateTimeField( + null=True, + blank=True, + help_text="When the explanation request email was sent" + ) + + responded_at = models.DateTimeField( + null=True, + blank=True, + help_text="When the explanation was submitted" + ) + + # Request details + requested_by = models.ForeignKey( + 'accounts.User', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='requested_complaint_explanations', + help_text="User who requested the explanation" + ) + + request_message = models.TextField( + blank=True, + help_text="Optional message sent with the explanation request" + ) + + class Meta: + ordering = ['-created_at'] + verbose_name = 'Complaint Explanation' + verbose_name_plural = 'Complaint Explanations' + indexes = [ + models.Index(fields=['complaint', '-created_at']), + models.Index(fields=['token', 'is_used']), + ] + + def __str__(self): + staff_name = self.staff if self.staff else 'Unknown' + return f"{self.complaint} - {staff_name} - {'Submitted' if self.is_used else 'Pending'}" + + @property + def is_expired(self): + """Check if token is expired (already used)""" + return self.is_used + + def can_submit(self): + """Check if explanation can still be submitted""" + return not self.is_used + + +class ExplanationAttachment(UUIDModel, TimeStampedModel): + """Attachment for complaint explanation""" + explanation = models.ForeignKey( + ComplaintExplanation, + on_delete=models.CASCADE, + related_name='attachments' + ) + + file = models.FileField(upload_to='explanation_attachments/%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") + + description = models.TextField(blank=True) + + class Meta: + ordering = ['-created_at'] + verbose_name = 'Explanation Attachment' + verbose_name_plural = 'Explanation Attachments' + + def __str__(self): + return f"{self.explanation} - {self.filename}" diff --git a/apps/complaints/urls.py b/apps/complaints/urls.py index 71ca09c..e15b801 100644 --- a/apps/complaints/urls.py +++ b/apps/complaints/urls.py @@ -1,7 +1,13 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from .views import ComplaintAttachmentViewSet, ComplaintViewSet, InquiryViewSet +from .views import ( + ComplaintAttachmentViewSet, + ComplaintViewSet, + InquiryViewSet, + complaint_explanation_form, + generate_complaint_pdf, +) from . import ui_views app_name = 'complaints' @@ -55,6 +61,12 @@ urlpatterns = [ path('public/api/load-departments/', ui_views.api_load_departments, name='api_load_departments'), path('public/api/load-categories/', ui_views.api_load_categories, name='api_load_categories'), + # Public Explanation Form (No Authentication Required) + path('/explain//', complaint_explanation_form, name='complaint_explanation_form'), + + # PDF Export + path('/pdf/', generate_complaint_pdf, name='complaint_pdf'), + # API Routes path('', include(router.urls)), ] diff --git a/apps/complaints/views.py b/apps/complaints/views.py index 58b6e7b..2373024 100644 --- a/apps/complaints/views.py +++ b/apps/complaints/views.py @@ -656,6 +656,11 @@ class ComplaintViewSet(viewsets.ModelViewSet): Sends complaint notification with AI-generated summary (editable by user). Logs the operation to NotificationLog and ComplaintUpdate. + + Recipient Priority: + 1. Staff with user account + 2. Staff with email field + 3. Department manager """ complaint = self.get_object() @@ -670,26 +675,36 @@ class ComplaintViewSet(viewsets.ModelViewSet): # Get additional message (optional) additional_message = request.data.get('additional_message', '').strip() - # Determine recipient + # Determine recipient with priority logic recipient = None recipient_display = None recipient_type = None + recipient_email = None - # Priority 1: Staff member mentioned in complaint + # Priority 1: Staff member with user account if complaint.staff and complaint.staff.user: recipient = complaint.staff.user - recipient_display = complaint.staff.get_full_name() - recipient_type = 'Staff Member' - # Priority 2: Department head + recipient_display = str(complaint.staff) + recipient_type = 'Staff Member (User Account)' + recipient_email = recipient.email + + # Priority 2: Staff member with email field (no user account) + elif complaint.staff and complaint.staff.email: + recipient_display = str(complaint.staff) + recipient_type = 'Staff Member (Email)' + recipient_email = complaint.staff.email + + # Priority 3: Department head elif complaint.department and complaint.department.manager: recipient = complaint.department.manager recipient_display = recipient.get_full_name() recipient_type = 'Department Head' + recipient_email = recipient.email - # Check if we found a recipient - if not recipient or not recipient.email: + # Check if we found a recipient with email + if not recipient_email: return Response( - {'error': 'No valid recipient found. Complaint must have either a staff member with user account, or a department manager with email.'}, + {'error': 'No valid recipient found. Complaint must have staff with email, or a department manager with email.'}, status=status.HTTP_400_BAD_REQUEST ) @@ -698,7 +713,7 @@ class ComplaintViewSet(viewsets.ModelViewSet): # Build email body email_body = f""" -Dear {recipient.get_full_name()}, +Dear {recipient_display}, You have been assigned to review the following complaint: @@ -755,14 +770,14 @@ This is an automated message from PX360 Complaint Management System. try: notification_log = NotificationService.send_email( - email=recipient.email, + email=recipient_email, subject=subject, message=email_body, related_object=complaint, metadata={ 'notification_type': 'complaint_notification', 'recipient_type': recipient_type, - 'recipient_id': str(recipient.id), + 'recipient_id': str(recipient.id) if recipient else None, 'sender_id': str(request.user.id), 'has_additional_message': bool(additional_message) } @@ -781,7 +796,7 @@ This is an automated message from PX360 Complaint Management System. created_by=request.user, metadata={ 'recipient_type': recipient_type, - 'recipient_id': str(recipient.id), + 'recipient_id': str(recipient.id) if recipient else None, 'notification_log_id': str(notification_log.id) if notification_log else None } ) @@ -794,8 +809,8 @@ This is an automated message from PX360 Complaint Management System. content_object=complaint, metadata={ 'recipient_type': recipient_type, - 'recipient_id': str(recipient.id), - 'recipient_email': recipient.email + 'recipient_id': str(recipient.id) if recipient else None, + 'recipient_email': recipient_email } ) @@ -804,7 +819,193 @@ This is an automated message from PX360 Complaint Management System. 'message': 'Email notification sent successfully', 'recipient': recipient_display, 'recipient_type': recipient_type, - 'recipient_email': recipient.email + 'recipient_email': recipient_email + }) + + @action(detail=True, methods=['post']) + def request_explanation(self, request, pk=None): + """ + Request explanation from staff/recipient. + + Generates a unique token and sends email with secure link. + Token can only be used once. + """ + complaint = self.get_object() + + # Check if complaint has staff to request explanation from + if not complaint.staff: + return Response( + {'error': 'Complaint has no staff assigned to request explanation from'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Check if explanation already exists for this staff + from .models import ComplaintExplanation + existing_explanation = ComplaintExplanation.objects.filter( + complaint=complaint, + staff=complaint.staff + ).first() + + if existing_explanation and existing_explanation.is_used: + return Response( + {'error': 'This staff member has already submitted an explanation'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Get optional message + request_message = request.data.get('request_message', '').strip() + + # Generate unique token + import secrets + token = secrets.token_urlsafe(32) + + # Create or update explanation record + if existing_explanation: + explanation = existing_explanation + explanation.token = token + explanation.is_used = False + explanation.requested_by = request.user + explanation.request_message = request_message + explanation.email_sent_at = timezone.now() + explanation.save() + else: + explanation = ComplaintExplanation.objects.create( + complaint=complaint, + staff=complaint.staff, + token=token, + is_used=False, + submitted_via='email_link', + requested_by=request.user, + request_message=request_message, + email_sent_at=timezone.now() + ) + + # Send email with explanation link + from django.contrib.sites.shortcuts import get_current_site + from apps.notifications.services import NotificationService + + site = get_current_site(request) + explanation_link = f"https://{site.domain}/complaints/{complaint.id}/explain/{token}/" + + # Determine recipient email + if complaint.staff.user and complaint.staff.user.email: + recipient_email = complaint.staff.user.email + recipient_display = str(complaint.staff) + elif complaint.staff.email: + recipient_email = complaint.staff.email + recipient_display = str(complaint.staff) + else: + return Response( + {'error': 'Staff member has no email address'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Build email subject + subject = f"Explanation Request - Complaint #{complaint.id}" + + # Build email body + email_body = f""" +Dear {recipient_display}, + +We have received a complaint that requires your explanation. + +COMPLAINT DETAILS: +---------------- +Reference: #{complaint.id} +Title: {complaint.title} +Severity: {complaint.get_severity_display()} +Priority: {complaint.get_priority_display()} + +{complaint.description} + +""" + + # Add patient info if available + if complaint.patient: + email_body += f""" +PATIENT INFORMATION: +------------------ +Name: {complaint.patient.get_full_name()} +MRN: {complaint.patient.mrn} +""" + + # Add request message if provided + if request_message: + email_body += f""" + +ADDITIONAL MESSAGE: +------------------ +{request_message} +""" + + email_body += f""" + +SUBMIT YOUR EXPLANATION: +------------------------ +Your perspective is important. Please submit your explanation about this complaint: +{explanation_link} + +Note: This link can only be used once. After submission, it will expire. + +If you have any questions, please contact the PX team. + +--- +This is an automated message from PX360 Complaint Management System. +""" + + # Send email + try: + notification_log = NotificationService.send_email( + email=recipient_email, + subject=subject, + message=email_body, + related_object=complaint, + metadata={ + 'notification_type': 'explanation_request', + 'staff_id': str(complaint.staff.id), + 'explanation_id': str(explanation.id), + 'requested_by_id': str(request.user.id), + 'has_request_message': bool(request_message) + } + ) + except Exception as e: + return Response( + {'error': f'Failed to send email: {str(e)}'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + # Create ComplaintUpdate entry + ComplaintUpdate.objects.create( + complaint=complaint, + update_type='communication', + message=f"Explanation request sent to {recipient_display}", + created_by=request.user, + metadata={ + 'explanation_id': str(explanation.id), + 'staff_id': str(complaint.staff.id), + 'notification_log_id': str(notification_log.id) if notification_log else None + } + ) + + # Log audit + AuditService.log_from_request( + event_type='explanation_requested', + description=f"Explanation request sent to {recipient_display}", + request=request, + content_object=complaint, + metadata={ + 'explanation_id': str(explanation.id), + 'staff_id': str(complaint.staff.id), + 'request_message': request_message + } + ) + + return Response({ + 'success': True, + 'message': 'Explanation request sent successfully', + 'explanation_id': str(explanation.id), + 'recipient': recipient_display, + 'explanation_link': explanation_link }) @@ -888,3 +1089,213 @@ class InquiryViewSet(viewsets.ModelViewSet): inquiry.save() return Response({'message': 'Response submitted successfully'}) + + +# Public views (no authentication required) +from django.shortcuts import render, redirect, get_object_or_404 +from django.http import JsonResponse + + +def complaint_explanation_form(request, complaint_id, token): + """ + Public-facing form for staff to submit explanation. + + This view does NOT require authentication. + Validates token and checks if it's still valid (not used). + """ + from .models import ComplaintExplanation, ExplanationAttachment + from apps.notifications.services import NotificationService + from django.contrib.sites.shortcuts import get_current_site + + # Get complaint + complaint = get_object_or_404(Complaint, id=complaint_id) + + # Validate token + explanation = get_object_or_404(ComplaintExplanation, complaint=complaint, token=token) + + # Check if token is already used + if explanation.is_used: + return render(request, 'complaints/explanation_already_submitted.html', { + 'complaint': complaint, + 'explanation': explanation + }) + + if request.method == 'POST': + # Handle form submission + explanation_text = request.POST.get('explanation', '').strip() + + if not explanation_text: + return render(request, 'complaints/explanation_form.html', { + 'complaint': complaint, + 'explanation': explanation, + 'error': 'Please provide your explanation.' + }) + + # Save explanation + explanation.explanation = explanation_text + explanation.is_used = True + explanation.responded_at = timezone.now() + explanation.save() + + # Handle file attachments + files = request.FILES.getlist('attachments') + for uploaded_file in files: + ExplanationAttachment.objects.create( + explanation=explanation, + file=uploaded_file, + filename=uploaded_file.name, + file_type=uploaded_file.content_type, + file_size=uploaded_file.size + ) + + # Notify complaint assignee + if complaint.assigned_to and complaint.assigned_to.email: + site = get_current_site(request) + complaint_url = f"https://{site.domain}/complaints/{complaint.id}/" + + subject = f"New Explanation Received - Complaint #{complaint.id}" + + email_body = f""" +Dear {complaint.assigned_to.get_full_name()}, + +A new explanation has been submitted for the following complaint: + +COMPLAINT DETAILS: +---------------- +Reference: #{complaint.id} +Title: {complaint.title} +Severity: {complaint.get_severity_display()} + +EXPLANATION SUBMITTED BY: +------------------------ +{explanation.staff} + +EXPLANATION: +----------- +{explanation.explanation} + +""" + if files: + email_body += f""" +ATTACHMENTS: +------------ +{len(files)} file(s) attached +""" + + email_body += f""" + +To view the complaint and explanation, please visit: +{complaint_url} + +--- +This is an automated message from PX360 Complaint Management System. +""" + + try: + NotificationService.send_email( + email=complaint.assigned_to.email, + subject=subject, + message=email_body, + related_object=complaint, + metadata={ + 'notification_type': 'explanation_submitted', + 'explanation_id': str(explanation.id), + 'staff_id': str(explanation.staff.id) if explanation.staff else None + } + ) + except Exception as e: + # Log error but don't fail the submission + import logging + logger = logging.getLogger(__name__) + logger.error(f"Failed to send notification email: {e}") + + # Create complaint update + ComplaintUpdate.objects.create( + complaint=complaint, + update_type='communication', + message=f"Explanation submitted by {explanation.staff}", + metadata={ + 'explanation_id': str(explanation.id), + 'staff_id': str(explanation.staff.id) if explanation.staff else None + } + ) + + # Redirect to success page + return render(request, 'complaints/explanation_success.html', { + 'complaint': complaint, + 'explanation': explanation, + 'attachment_count': len(files) + }) + + # GET request - display form + return render(request, 'complaints/explanation_form.html', { + 'complaint': complaint, + 'explanation': explanation + }) + + +from django.http import HttpResponse + + +def generate_complaint_pdf(request, complaint_id): + """ + Generate PDF for a complaint using WeasyPrint. + + Creates a professionally styled PDF document with all complaint details + including AI analysis, staff assignment, and resolution information. + """ + # Get complaint + complaint = get_object_or_404(Complaint, id=complaint_id) + + # Check permissions + user = request.user + if not user.is_authenticated: + return HttpResponse('Unauthorized', status=401) + + # Check if user can view this complaint + can_view = False + if user.is_px_admin(): + can_view = True + elif user.is_hospital_admin() and user.hospital == complaint.hospital: + can_view = True + elif user.is_department_manager() and user.department == complaint.department: + can_view = True + elif user.hospital == complaint.hospital: + can_view = True + + if not can_view: + return HttpResponse('Forbidden', status=403) + + # Render HTML template + from django.template.loader import render_to_string + html_string = render_to_string('complaints/complaint_pdf.html', { + 'complaint': complaint, + }) + + # Generate PDF using WeasyPrint + try: + from weasyprint import HTML + pdf_file = HTML(string=html_string).write_pdf() + + # Create response + response = HttpResponse(pdf_file, content_type='application/pdf') + filename = f"complaint_{complaint.id.strftime('%Y%m%d_%H%M%S')}.pdf" + response['Content-Disposition'] = f'attachment; filename="{filename}"' + + # Log audit + AuditService.log_from_request( + event_type='pdf_generated', + description=f"PDF generated for complaint: {complaint.title}", + request=request, + content_object=complaint, + metadata={'complaint_id': str(complaint.id)} + ) + + return response + except ImportError: + return HttpResponse('WeasyPrint is not installed. Please install it to generate PDFs.', status=500) + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.error(f"Error generating PDF for complaint {complaint.id}: {e}") + return HttpResponse(f'Error generating PDF: {str(e)}', status=500) diff --git a/apps/organizations/admin.py b/apps/organizations/admin.py index d6c5ddc..68cb90c 100644 --- a/apps/organizations/admin.py +++ b/apps/organizations/admin.py @@ -79,7 +79,7 @@ class StaffAdmin(admin.ModelAdmin): fieldsets = ( (None, {'fields': ('first_name', 'last_name', 'first_name_ar', 'last_name_ar')}), ('Role', {'fields': ('staff_type', 'job_title')}), - ('Professional', {'fields': ('license_number', 'specialization', 'employee_id')}), + ('Professional', {'fields': ('license_number', 'specialization', 'employee_id', 'email')}), ('Organization', {'fields': ('hospital', 'department')}), ('Account', {'fields': ('user',)}), ('Status', {'fields': ('status',)}), diff --git a/apps/organizations/migrations/0006_staff_email.py b/apps/organizations/migrations/0006_staff_email.py new file mode 100644 index 0000000..0a5e12c --- /dev/null +++ b/apps/organizations/migrations/0006_staff_email.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.14 on 2026-01-10 14:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('organizations', '0005_alter_staff_department'), + ] + + operations = [ + migrations.AddField( + model_name='staff', + name='email', + field=models.EmailField(blank=True, max_length=254), + ), + ] diff --git a/apps/organizations/models.py b/apps/organizations/models.py index 977a2a4..ba48be3 100644 --- a/apps/organizations/models.py +++ b/apps/organizations/models.py @@ -157,6 +157,7 @@ class Staff(UUIDModel, TimeStampedModel): # Professional Data (Nullable for non-physicians) license_number = models.CharField(max_length=100, unique=True, null=True, blank=True) specialization = models.CharField(max_length=200, blank=True) + email = models.EmailField(blank=True) employee_id = models.CharField(max_length=50, unique=True, db_index=True) # Organization diff --git a/docs/PDF_GENERATION_IMPLEMENTATION.md b/docs/PDF_GENERATION_IMPLEMENTATION.md new file mode 100644 index 0000000..7529d95 --- /dev/null +++ b/docs/PDF_GENERATION_IMPLEMENTATION.md @@ -0,0 +1,364 @@ +# PDF Generation Implementation for Complaints + +## Overview +Implemented professional PDF generation for complaints using WeasyPrint. This feature allows users to download a beautifully formatted PDF document containing all complaint details, including AI analysis, staff assignment, and resolution information. + +## Implementation Details + +### 1. Dependencies +- Added `weasyprint>=60.0` to `pyproject.toml` +- WeasyPrint provides CSS-based PDF generation with full support for modern CSS features + +### 2. Files Created + +#### `templates/complaints/complaint_pdf.html` +Professional PDF template with: +- **Header**: Complaint title, ID, status, severity, patient info +- **Basic Information**: Category, source, priority, encounter ID, dates +- **Description**: Full complaint details +- **Staff Assignment**: Assigned staff member (if any) +- **AI Analysis**: + - Emotion analysis with confidence score and intensity bar + - AI summary + - Suggested action +- **Resolution**: Resolution details (if resolved) +- **Footer**: Generation timestamp, system branding + +**Features:** +- A4 page size with proper margins +- Professional purple gradient header +- Color-coded badges for status and severity +- Page numbers in footer +- Proper page breaks for multi-page documents +- Print-optimized CSS + +### 3. Files Modified + +#### `apps/complaints/views.py` +Added `generate_complaint_pdf()` function: +- Validates user permissions (PX Admin, Hospital Admin, Department Manager, or hospital staff) +- Renders HTML template with complaint context +- Converts HTML to PDF using WeasyPrint +- Returns PDF as downloadable attachment +- Logs PDF generation to audit trail +- Handles errors gracefully with user-friendly messages + +#### `apps/complaints/urls.py` +- Added URL route: `/pdf/` mapped to `generate_complaint_pdf` +- Named URL: `complaints:complaint_pdf` + +#### `templates/complaints/complaint_detail.html` +Added "PDF View" tab: +- New tab in complaints detail page +- Download PDF button with icon +- Description of PDF contents +- Informational alerts about what's included +- Note about WeasyPrint requirement + +## Usage + +### For Users + +1. Navigate to a complaint detail page +2. Click the "PDF View" tab +3. Click the "Download PDF" button +4. The PDF will be generated and downloaded automatically + +### For Developers + +#### Generating a PDF Programmatically + +```python +from django.template.loader import render_to_string +from weasyprint import HTML + +# Render template +html_string = render_to_string('complaints/complaint_pdf.html', { + 'complaint': complaint, +}) + +# Generate PDF +pdf_file = HTML(string=html_string).write_pdf() + +# Return as HTTP response +response = HttpResponse(pdf_file, content_type='application/pdf') +response['Content-Disposition'] = 'attachment; filename="complaint.pdf"' +``` + +## Installation Requirements + +### System Dependencies (Linux/Ubuntu) +```bash +sudo apt-get install python3-dev python3-pip python3-cffi libcairo2 libpango-1.0-0 libgdk-pixbuf2.0-0 shared-mime-info + +# For additional font support +sudo apt-get install fonts-liberation +``` + +### Python Dependencies +```bash +pip install weasyprint>=60.0 +``` + +Or using uv/pip with the project's pyproject.toml: +```bash +uv pip install weasyprint +# or +pip install -e . +``` + +### macOS Dependencies +```bash +brew install cairo pango gdk-pixbuf libffi shared-mime-info +``` + +### Windows Dependencies +Download and install GTK+ from: +https://github.com/tschoonj/GTK-for-Windows-Runtime-Environment-Installer + +## PDF Template Customization + +The PDF template uses standard CSS. You can customize: + +### Colors +Edit the gradient in the header: +```css +.header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} +``` + +### Page Margins +Edit the `@page` rule: +```css +@page { + size: A4; + margin: 2cm; +} +``` + +### Badges +Modify badge colors by editing the CSS classes: +```css +.status-open { background: #e3f2fd; color: #1976d2; } +.severity-high { background: #ffebee; color: #d32f2f; } +``` + +### Fonts +Add custom fonts using `@font-face`: +```css +@page { + @font-face { + src: url('path/to/font.ttf'); + } +} +``` + +## Features + +### Permission Control +Only authorized users can generate PDFs: +- PX Admins: All complaints +- Hospital Admins: Their hospital's complaints +- Department Managers: Their department's complaints +- Hospital Staff: Their hospital's complaints + +### Audit Logging +All PDF generations are logged: +- Event type: `pdf_generated` +- Description includes complaint title +- Metadata includes complaint ID +- Tracked by user and timestamp + +### Error Handling +- Graceful error messages if WeasyPrint is not installed +- Logs errors for debugging +- Returns user-friendly error responses + +## PDF Contents + +### Header Section +- Complaint title (large, prominent) +- Complaint ID (truncated to 8 characters) +- Status badge (color-coded) +- Severity badge (color-coded) +- Patient name and MRN +- Hospital name +- Department name (if assigned) + +### Basic Information +Grid layout with: +- Category (with subcategory if present) +- Source +- Priority +- Encounter ID +- Created date +- SLA deadline + +### Description +Full complaint text with line breaks preserved. + +### Staff Assignment (if assigned) +- Staff member name (English and Arabic) +- Job title +- Department +- AI-extracted staff name (if available) +- Staff match confidence score + +### AI Analysis (if available) +- **Emotion Analysis**: + - Emotion type with badge + - Confidence percentage + - Intensity bar visualization +- **AI Summary**: Brief summary of the complaint +- **Suggested Action**: AI-recommended action to take + +### Resolution (if resolved) +- Resolution text +- Resolved by (user name) +- Resolution date/time + +### Footer +- Generation timestamp +- PX360 branding +- AlHammadi Group branding +- Page numbers (for multi-page documents) + +## Styling Highlights + +### Color Scheme +- Primary purple gradient: `#667eea` → `#764ba2` +- Status colors: Blue, Orange, Green, Gray, Red +- Severity colors: Green, Orange, Red, Dark Red +- AI section: Purple gradient background +- Resolution section: Green background + +### Typography +- Font family: Segoe UI, Tahoma, Geneva, Verdana, sans-serif +- Base font size: 11pt +- Labels: 9pt, uppercase, letter-spacing: 0.5px +- Values: 11pt + +### Layout +- Max-width: 210mm (A4 width) +- 2cm page margins +- Grid-based information layout +- Card-based sections with borders and shadows +- Proper spacing and padding + +## Browser Compatibility + +WeasyPrint generates PDFs server-side, so the PDF will look the same regardless of the user's browser. The generated PDF can be viewed in: +- Adobe Acrobat Reader +- Chrome/Edge PDF viewer +- Firefox PDF viewer +- Safari PDF viewer +- Any modern PDF viewer + +## Performance Considerations + +- PDF generation is synchronous (happens in the request/response cycle) +- For complaints with extensive updates or attachments, generation may take 1-3 seconds +- Consider adding caching for frequently accessed complaints +- For high-traffic scenarios, consider asynchronous generation + +## Future Enhancements + +Potential improvements: +1. **Batch PDF generation**: Generate PDFs for multiple complaints +2. **Email PDF**: Option to email PDF directly +3. **Custom branding**: Allow hospital-specific logos/colors +4. **PDF templates**: Multiple template options (minimal, detailed, etc.) +5. **Digital signatures**: Add digital signature capability +6. **Watermarks**: Add watermarks for draft/official versions +7. **Barcodes/QR codes**: Include complaint barcode for scanning +8. **Attachments**: Include complaint attachments in PDF +9. **Bilingual PDF**: Generate PDF in Arabic/English side-by-side +10. **Charts**: Include complaint trend charts + +## Troubleshooting + +### WeasyPrint Import Error +**Error**: `ImportError: No module named 'weasyprint'` + +**Solution**: +```bash +pip install weasyprint +``` + +### Cairo Library Error +**Error**: `ImportError: No module named 'cairo'` + +**Solution**: +Install system dependencies (see Installation Requirements section) + +### Font Issues +**Error**: Text appears as squares or missing characters + +**Solution**: +Install additional fonts on the system: +```bash +sudo apt-get install fonts-liberation fonts-noto-cjk +``` + +### Memory Issues +**Error**: PDF generation fails for large complaints + +**Solution**: +1. Reduce number of updates included +2. Implement pagination for large content +3. Increase server memory allocation + +## Testing + +### Manual Testing +1. Create a test complaint with AI analysis +2. Navigate to complaint detail page +3. Click PDF View tab +4. Download PDF +5. Verify all sections are present +6. Check styling and formatting +7. Test with different complaint states (open, resolved, etc.) + +### Automated Testing +```python +from django.test import TestCase +from django.urls import reverse +from apps.complaints.models import Complaint + +class PDFGenerationTest(TestCase): + def test_pdf_generation(self): + complaint = Complaint.objects.first() + url = reverse('complaints:complaint_pdf', kwargs={'pk': complaint.pk}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response['Content-Type'], 'application/pdf') + self.assertGreater(len(response.content), 0) +``` + +## References + +- [WeasyPrint Documentation](https://weasyprint.readthedocs.io/) +- [CSS Paged Media Module](https://www.w3.org/TR/css-page-3/) +- [Django WeasyPrint Guide](https://weasyprint.readthedocs.io/en/latest/django.html) + +## Support + +For issues or questions: +1. Check WeasyPrint installation +2. Review system dependencies +3. Check Django logs for error details +4. Verify user permissions +5. Test with a simple PDF template first + +## Changelog + +### v1.0.0 (2025-01-10) +- Initial implementation +- Professional PDF template with complaint details +- AI analysis integration +- Staff assignment display +- Resolution information +- Permission-based access control +- Audit logging +- Download functionality diff --git a/pyproject.toml b/pyproject.toml index 42cbfda..535fdba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "djangorestframework-stubs>=3.16.6", "rich>=14.2.0", "reportlab>=4.4.7", + "weasyprint>=60.0", "openpyxl>=3.1.5", "litellm>=1.0.0", "watchdog>=6.0.0", diff --git a/templates/complaints/complaint_detail.html b/templates/complaints/complaint_detail.html index 92c8ac3..4b4499e 100644 --- a/templates/complaints/complaint_detail.html +++ b/templates/complaints/complaint_detail.html @@ -200,6 +200,18 @@ {{ _("PX Actions")}} ({{ px_actions.count }}) + + @@ -586,6 +598,136 @@ + +
+
+
+
{% trans "Staff Explanation" %}
+ + {% if complaint.explanation %} + +
+
+
+
+ + {% trans "Explanation Received" %} +
+

{{ complaint.explanation.explanation|linebreaks }}

+ {% if complaint.explanation.staff_name %} +

+ + {% trans "Staff:" %} {{ complaint.explanation.staff_name }} + +

+ {% endif %} +
+ + + {% trans "Submitted" %} + +
+
+ + + {{ complaint.explanation.responded_at|date:"M d, Y H:i" }} + {% if complaint.explanation.attachment_count > 0 %} + | + + {{ complaint.explanation.attachment_count }} {% trans "attachment(s)" %} + {% endif %} + + {% if complaint.explanation.attachments %} +
+
{% trans "Attachments:" %}
+ {% for attachment in complaint.explanation.attachments %} + + {{ attachment.filename }} + + {% endfor %} +
+ {% endif %} +
+ + {% if complaint.explanation and complaint.explanation.token %} +
+ + +
+ {% endif %} + +
+
+
+ + + {% trans "Explanation ID:" %} {{ complaint.explanation.id }} | + {% trans "Token:" %} {{ complaint.explanation.token|slice:":8" }}... + +
+
+
+ + {% else %} + +
+ +

{% trans "No explanation has been submitted yet." %}

+ + {% if can_edit %} +
+
+
+ + {% trans "Request Explanation" %} +
+

+ {% trans "Send a link to the assigned staff member requesting their explanation about this complaint." %} +

+ + {% if complaint.staff %} +
+ + {% trans "Will be sent to:" %} + {{ complaint.staff.get_full_name }} + {% if complaint.staff.user %} +
{{ complaint.staff.user.email }} + {% elif complaint.staff.email %} +
{{ complaint.staff.email }} + {% else %} +
{% trans "No email configured" %} + {% endif %} +
+ +
+ + +
+ + + + {% else %} +
+ + {% trans "Please assign a staff member to this complaint before requesting an explanation." %} +
+ {% endif %} +
+
+ {% endif %} +
+ {% endif %} +
+
+
+
@@ -622,6 +764,56 @@
+ + +
+
+
+
+ {% trans "PDF View" %} +
+ +
+ + {% trans "Download PDF" %} + +
+ + + {% trans "This will generate a professionally formatted PDF with all complaint details, including AI analysis, staff assignment, and resolution information." %} + +
+
+ +
+ +
+
+ {% trans "PDF Contents" %} +
+
    +
  • {% trans "Header:" %} Complaint title, ID, status, severity, patient info
  • +
  • {% trans "Basic Information:" %} Category, source, priority, encounter ID, dates
  • +
  • {% trans "Description:" %} Full complaint details
  • +
  • {% trans "Staff Assignment:" %} Assigned staff member (if any)
  • +
  • {% trans "AI Analysis:" %} Emotion analysis, summary, suggested action (if available)
  • +
  • {% trans "Resolution:" %} Resolution details (if resolved)
  • +
+
+ +
+
+ {% trans "Note" %} +
+

+ {% trans "PDF generation requires WeasyPrint to be installed. If you see an error message, please contact your system administrator." %} +

+
+
+
+
@@ -662,8 +854,11 @@ {% for dept in hospital_departments %} {% endfor %} @@ -712,8 +907,13 @@ + + + - @@ -986,53 +1186,84 @@ {{ _("Recipient") }} - {% if complaint.staff and complaint.staff.user %} - + {% if complaint.staff %} + + {% if complaint.staff.user %} +
- Primary Recipient: {{ complaint.staff.get_full_name }} + Primary Recipient (Assigned Staff with User Account): {{ complaint.staff.get_full_name }} {% if complaint.staff.job_title %}
{{ complaint.staff.job_title }} {% endif %} -
- - {% elif complaint.staff %} - -
- - {{ _("Staff Member Assigned")}}: {{ complaint.staff.get_full_name }} - {% if complaint.staff.job_title %} -
{{ complaint.staff.job_title }} + {% if complaint.staff.department %} +
{{ complaint.staff.department.name_en }} {% endif %}
- - - {{ _("This staff member has no user account in the system")}}. - + Email will be sent to: {{ complaint.staff.user.email }}
+ {% elif complaint.staff.email %} + +
+ + Primary Recipient (Assigned Staff - Email): {{ complaint.staff.get_full_name }} + {% if complaint.staff.job_title %} +
{{ complaint.staff.job_title }} + {% endif %} + {% if complaint.staff.department %} +
{{ complaint.staff.department.name_en }} + {% endif %} +
+ Email will be sent to: {{ complaint.staff.email }} +
+ {% else %} + +
+ + Assigned Staff: {{ complaint.staff.get_full_name }} + {% if complaint.staff.job_title %} +
{{ complaint.staff.job_title }} + {% endif %} + {% if complaint.staff.department %} +
{{ complaint.staff.department.name_en }} + {% endif %} +
+ No email configured for this staff member +
+ {% endif %} + {% if not complaint.staff.user and not complaint.staff.email %} + {% if complaint.department and complaint.department.manager %} -
Actual Recipient: {{ complaint.department.manager.get_full_name }}
{{ _("Department Head of")}} {{ complaint.department.name_en }} + + Fallback Recipient (Department Head): {{ complaint.department.manager.get_full_name }} + {% if complaint.department.manager.email %} +
Email will be sent to: {{ complaint.department.manager.email }} + {% else %} +
Department head has no email address + {% endif %}
{% else %} -
{{ _("No recipient available")}}
{{ _("The assigned staff has no user account and no department manager is set")}}.
{% endif %} + {% endif %} {% elif complaint.department and complaint.department.manager %} - +
- Department Head: {{ complaint.department.manager.get_full_name }} -
{{ _("Manager of")}} {{ complaint.department.name_en }} + Recipient (Department Head): {{ complaint.department.manager.get_full_name }} + {% if complaint.department %} +
Manager of {{ complaint.department.name_en }} + {% endif %}
{% else %} @@ -1376,6 +1607,101 @@ function getCookie(name) { return cookieValue; } +function switchToExplanationTab() { + const explanationTab = document.getElementById('explanation-tab'); + if (explanationTab) { + explanationTab.click(); + } +} + +function requestExplanation() { + const message = document.getElementById('explanationMessage')?.value || ''; + + if (!confirm('{% trans "Are you sure you want to request an explanation?" %}')) { + return; + } + + const btn = event.target; + const originalText = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = 'Sending...'; + + fetch(`/complaints/api/complaints/{{ complaint.id }}/request_explanation/`, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken') + }, + body: JSON.stringify({ + message: message + }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + alert('{% trans "Explanation request sent successfully!" %}'); + location.reload(); + } else { + alert('{% trans "Error:" %} ' + (data.error || '{% trans "Unknown error" %}')); + btn.disabled = false; + btn.innerHTML = originalText; + } + }) + .catch(error => { + console.error('Error:', error); + alert('{% trans "Failed to send explanation request. Please try again." %}'); + btn.disabled = false; + btn.innerHTML = originalText; + }); +} + +{% if complaint.explanation and complaint.explanation.token %} +function copyExplanationLink() { + const link = `{% if request.is_secure %}https{% else %}http{% endif %}://{{ request.get_host }}{% url 'complaints:complaint_explanation_form' complaint.id complaint.explanation.token %}`; + + navigator.clipboard.writeText(link).then(() => { + alert('{% trans "Link copied to clipboard!" %}'); + }).catch(() => { + // Fallback + const textarea = document.createElement('textarea'); + textarea.value = link; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + alert('{% trans "Link copied to clipboard!" %}'); + }); +} + +function resendExplanation() { + if (!confirm('{% trans "Resend explanation request email?" %}')) { + return; + } + + fetch(`/complaints/api/complaints/{{ complaint.id }}/resend_explanation_email/`, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken') + } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + alert('{% trans "Email resent successfully!" %}'); + } else { + alert('{% trans "Error:" %} ' + (data.error || '{% trans "Unknown error" %}')); + } + }) + .catch(error => { + console.error('Error:', error); + alert('{% trans "Failed to resend email. Please try again." %}'); + }); +} +{% endif %} + function sendNotification() { const btn = document.getElementById('sendNotificationBtn'); const emailMessage = document.getElementById('emailMessage').value; diff --git a/templates/complaints/complaint_pdf.html b/templates/complaints/complaint_pdf.html new file mode 100644 index 0000000..c1aaf82 --- /dev/null +++ b/templates/complaints/complaint_pdf.html @@ -0,0 +1,438 @@ + + + + + + Complaint #{{ complaint.id|slice:":8" }} + + + +
+ +
+

{{ complaint.title }}

+
+
+ ID: {{ complaint.id|slice:":8" }} + • Status: + {{ complaint.get_status_display }} + • Severity: + {{ complaint.get_severity_display }} +
+
+ Patient: {{ complaint.patient.get_full_name }} (MRN: {{ complaint.patient.mrn }}) +
+
+ Hospital: {{ complaint.hospital.name_en }} + {% if complaint.department %} + • Department: {{ complaint.department.name_en }} + {% endif %} +
+
+
+ + +
+

Complaint Information

+
+
+
Category
+
+ {{ complaint.get_category_display }} + {% if complaint.subcategory %} + / {{ complaint.subcategory }} + {% endif %} +
+
+
+
Source
+
{{ complaint.get_source_display }}
+
+
+
Priority
+
+ {{ complaint.get_priority_display }} +
+
+
+
Encounter ID
+
+ {% if complaint.encounter_id %} + {{ complaint.encounter_id }} + {% else %} + N/A + {% endif %} +
+
+
+
Created Date
+
{{ complaint.created_at|date:"F d, Y H:i" }}
+
+
+
SLA Deadline
+
{{ complaint.due_at|date:"F d, Y H:i" }}
+
+
+
+ + +
+

Complaint Description

+
+ {{ complaint.description|linebreaks }} +
+
+ + + {% if complaint.staff %} +
+

Staff Assignment

+
+
Assigned Staff Member
+
+ {{ complaint.staff.get_full_name }} + {% if complaint.staff.first_name_ar or complaint.staff.last_name_ar %} +
({{ complaint.staff.first_name_ar }} {{ complaint.staff.last_name_ar }}) + {% endif %} + {% if complaint.staff.job_title %} +
{{ complaint.staff.job_title }} + {% endif %} +
+ {% if complaint.metadata.ai_analysis.extracted_staff_name %} +
+ AI Extracted: "{{ complaint.metadata.ai_analysis.extracted_staff_name }}" + {% if complaint.metadata.ai_analysis.staff_confidence %} + (Confidence: {{ complaint.metadata.ai_analysis.staff_confidence|mul:100|floatformat:0 }}%) + {% endif %} +
+ {% endif %} +
+
+ {% endif %} + + + {% if complaint.short_description or complaint.suggested_action or complaint.emotion %} +
+

+ 🤖AI Analysis +

+ + + {% if complaint.emotion %} +
+
Emotion Analysis
+
+
+ + {{ complaint.get_emotion_display }} + + + Confidence: {{ complaint.emotion_confidence|mul:100|floatformat:0 }}% + +
+
+
+
+ Intensity + {{ complaint.emotion_intensity|floatformat:2 }} / 1.0 +
+
+
+
+
+
+ {% endif %} + + + {% if complaint.short_description %} +
+
AI Summary
+
+ {{ complaint.short_description }} +
+
+ {% endif %} + + + {% if complaint.suggested_action %} +
+
+ Suggested Action +
+
+ {{ complaint.suggested_action }} +
+
+ {% endif %} +
+ {% endif %} + + + {% if complaint.resolution %} +
+

Resolution

+
+
+ Complaint Resolved +
+
+ {{ complaint.resolution|linebreaks }} +
+
+ Resolved by: {{ complaint.resolved_by.get_full_name }} +
+ Resolved on: {{ complaint.resolved_at|date:"F d, Y H:i" }} +
+
+
+ {% endif %} + + + +
+ + diff --git a/templates/complaints/explanation_already_submitted.html b/templates/complaints/explanation_already_submitted.html new file mode 100644 index 0000000..0c06fb9 --- /dev/null +++ b/templates/complaints/explanation_already_submitted.html @@ -0,0 +1,111 @@ +{% load i18n %} + + + + + + {% trans "Already Submitted" %} - PX360 + + + + +
+
+
+
+
+ + + + i + + +

{% trans "Already Submitted" %}

+ +

+ {% trans "This explanation link has already been used. Each explanation link can only be used once." %} +

+ +
+
{% trans "Complaint Information" %}
+
+
+ {% trans "Reference:" %} #{{ complaint.id }} +
+
+ {% trans "Title:" %} {{ complaint.title }} +
+ {% if explanation.responded_at %} +
+ {% trans "Submitted On:" %} {{ explanation.responded_at|date:"Y-m-d H:i" }} +
+ {% endif %} + {% if explanation.staff %} +
+ {% trans "Submitted By:" %} {{ explanation.staff }} +
+ {% endif %} +
+
+ +
+ + {% trans "What If You Need To Update?" %} +

+ {% trans "If you need to provide additional information or make changes to your explanation, please contact the PX team directly." %} +

+
+ +
+ +
+

{% trans "Explanation ID:" %} {{ explanation.id }}

+

{% trans "Status:" %} {% trans "Already Submitted" %}

+

{% trans "This link cannot be used again." %}

+
+
+ +
+
+
+
+ + + + + diff --git a/templates/complaints/explanation_form.html b/templates/complaints/explanation_form.html new file mode 100644 index 0000000..9f41d7a --- /dev/null +++ b/templates/complaints/explanation_form.html @@ -0,0 +1,156 @@ +{% load i18n %} + + + + + + {% trans "Submit Explanation" %} - PX360 + + + + +
+
+
+
+
+

+ + {% trans "Submit Your Explanation" %} +

+
+
+ {% if error %} + + {% endif %} + +
+
{% trans "Complaint Details" %}
+
+
+ {% trans "Reference:" %} #{{ complaint.id }} +
+
+ {% trans "Title:" %} {{ complaint.title }} +
+
+ {% trans "Severity:" %} + + {{ complaint.get_severity_display }} + +
+
+ {% trans "Priority:" %} + + {{ complaint.get_priority_display }} + +
+ {% if complaint.patient %} +
+ {% trans "Patient:" %} {{ complaint.patient.get_full_name }} (MRN: {{ complaint.patient.mrn }}) +
+ {% endif %} +
+ {% trans "Description:" %} +

{{ complaint.description|linebreaks }}

+
+
+
+ +
+ {% csrf_token %} + +
+ +

+ {% trans "Please provide your perspective about the complaint mentioned above. Your explanation will help us understand the situation better." %} +

+ +
+ +
+ +

+ {% trans "You can attach relevant documents, images, or other files to support your explanation." %} +

+ +
+ {% trans "Accepted file types: PDF, DOC, DOCX, JPG, PNG, etc. Maximum file size: 10MB." %} +
+
+ +
+ + {% trans "Important Note:" %} + {% trans "This link can only be used once. After submitting your explanation, it will expire and cannot be used again." %} +
+ +
+ +
+
+
+ +
+
+
+
+ + + + + diff --git a/templates/complaints/explanation_success.html b/templates/complaints/explanation_success.html new file mode 100644 index 0000000..27359d4 --- /dev/null +++ b/templates/complaints/explanation_success.html @@ -0,0 +1,111 @@ +{% load i18n %} + + + + + + {% trans "Explanation Submitted" %} - PX360 + + + + +
+
+
+
+
+ + + + + + +

{% trans "Explanation Submitted Successfully!" %}

+ +

+ {% trans "Thank you for providing your explanation. It has been received and will be reviewed by the PX team." %} +

+ +
+
{% trans "Complaint Summary" %}
+
+
+ {% trans "Reference:" %} #{{ complaint.id }} +
+
+ {% trans "Title:" %} {{ complaint.title }} +
+
+ {% trans "Submitted On:" %} {{ explanation.responded_at|date:"Y-m-d H:i" }} +
+ {% if attachment_count > 0 %} +
+ {% trans "Attachments:" %} {{ attachment_count }} +
+ {% endif %} +
+
+ +
+ + {% trans "What Happens Next?" %} +
    +
  • {% trans "Your explanation will be reviewed by the complaint assignee" %}
  • +
  • {% trans "The PX team may contact you if additional information is needed" %}
  • +
  • {% trans "Your explanation will be considered during the complaint investigation" %}
  • +
+
+ +
+ +
+

{% trans "Explanation ID:" %} {{ explanation.id }}

+

{% trans "Submission Time:" %} {{ explanation.responded_at|date:"Y-m-d H:i:s" }}

+

{% trans "A confirmation email has been sent to the complaint assignee." %}

+
+
+ +
+
+
+
+ + + + + diff --git a/templates/emails/explanation_request.html b/templates/emails/explanation_request.html new file mode 100644 index 0000000..ac3b469 --- /dev/null +++ b/templates/emails/explanation_request.html @@ -0,0 +1,193 @@ +{% load i18n %} + + + + + + {% trans "Explanation Request" %} + + + +
+
+

{% trans "Explanation Request" %}

+
+ +
+

{% trans "Dear" %} {{ staff_name }},

+ +

{% trans "You have been assigned to provide an explanation for the following patient complaint. Please review the details and submit your response using the link below." %}

+ + {% if custom_message %} +
+ {% trans "Note from PX Team:" %} +

{{ custom_message }}

+
+ {% endif %} + +
+

{% trans "Complaint Details" %}

+
+
{% trans "Reference:" %}
+
#{{ complaint_id }}
+
+
+
{% trans "Title:" %}
+
{{ complaint_title }}
+
+
+
{% trans "Patient:" %}
+
{{ patient_name }}
+
+
+
{% trans "Hospital:" %}
+
{{ hospital_name }}
+
+
+
{% trans "Department:" %}
+
{{ department_name }}
+
+
+
{% trans "Category:" %}
+
{{ category }}
+
+
+
{% trans "Status:" %}
+
{{ status }}
+
+
+
{% trans "Date:" %}
+
{{ created_date }}
+
+ + {% if description %} +
+
{% trans "Description:" %}
+
{{ description }}
+
+ {% endif %} +
+ + + +
+ {% trans "Important Information:" %} +
    +
  • {% trans "This link is unique and can only be used once" %}
  • +
  • {% trans "You can attach supporting documents to your explanation" %}
  • +
  • {% trans "Your response will be reviewed by the PX team" %}
  • +
  • {% trans "Please submit your explanation at your earliest convenience" %}
  • +
+
+ +

{% trans "If you have any questions or concerns, please contact the PX team directly." %}

+ +

{% trans "Thank you for your cooperation." %}

+
+ + +
+ +