""" Complaints utility functions Export and bulk operation utilities. """ import csv import io from datetime import datetime, timedelta from typing import List from django.db.models import Count, Q from django.http import HttpResponse from django.utils import timezone from openpyxl import Workbook from openpyxl.styles import Font, PatternFill, Alignment, Border, Side from openpyxl.utils import get_column_letter def export_complaints_csv(queryset, filters=None): """ Export complaints to CSV format. Args: queryset: Complaint queryset to export filters: Optional dict of applied filters Returns: HttpResponse with CSV file """ response = HttpResponse(content_type="text/csv") response["Content-Disposition"] = ( f'attachment; filename="complaints_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv"' ) writer = csv.writer(response) # Write header writer.writerow( [ "ID", "Title", "Patient Name", "Patient MRN", "Hospital", "Department", "Category", "Severity", "Priority", "Status", "Source", "Assigned To", "Created At", "Due At", "Is Overdue", "Resolved At", "Closed At", "Description", ] ) # Write data for complaint in queryset: writer.writerow( [ str(complaint.id)[:8], complaint.title, complaint.patient.get_full_name(), complaint.patient.mrn, complaint.hospital.name_en, complaint.department.name_en if complaint.department else "", complaint.get_category_display(), complaint.get_severity_display(), complaint.get_priority_display(), complaint.get_status_display(), complaint.get_source_display(), complaint.assigned_to.get_full_name() if complaint.assigned_to else "", complaint.created_at.strftime("%Y-%m-%d %H:%M:%S"), complaint.due_at.strftime("%Y-%m-%d %H:%M:%S"), "Yes" if complaint.is_overdue else "No", complaint.resolved_at.strftime("%Y-%m-%d %H:%M:%S") if complaint.resolved_at else "", complaint.closed_at.strftime("%Y-%m-%d %H:%M:%S") if complaint.closed_at else "", complaint.description[:500], ] ) return response def export_complaints_excel(queryset, filters=None): """ Export complaints to Excel format with formatting. Args: queryset: Complaint queryset to export filters: Optional dict of applied filters Returns: HttpResponse with Excel file """ wb = Workbook() ws = wb.active ws.title = "Complaints" # Define styles header_font = Font(bold=True, color="FFFFFF") header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") header_alignment = Alignment(horizontal="center", vertical="center") # Write header headers = [ "ID", "Title", "Patient Name", "Patient MRN", "Hospital", "Department", "Category", "Severity", "Priority", "Status", "Source", "Assigned To", "Created At", "Due At", "Is Overdue", "Resolved At", "Closed At", "Description", ] for col_num, header in enumerate(headers, 1): cell = ws.cell(row=1, column=col_num, value=header) cell.font = header_font cell.fill = header_fill cell.alignment = header_alignment # Write data for row_num, complaint in enumerate(queryset, 2): ws.cell(row=row_num, column=1, value=str(complaint.id)[:8]) ws.cell(row=row_num, column=2, value=complaint.title) ws.cell(row=row_num, column=3, value=complaint.patient.get_full_name()) ws.cell(row=row_num, column=4, value=complaint.patient.mrn) ws.cell(row=row_num, column=5, value=complaint.hospital.name_en) ws.cell(row=row_num, column=6, value=complaint.department.name_en if complaint.department else "") ws.cell(row=row_num, column=7, value=complaint.get_category_display()) ws.cell(row=row_num, column=8, value=complaint.get_severity_display()) ws.cell(row=row_num, column=9, value=complaint.get_priority_display()) ws.cell(row=row_num, column=10, value=complaint.get_status_display()) ws.cell(row=row_num, column=11, value=complaint.get_source_display()) ws.cell(row=row_num, column=12, value=complaint.assigned_to.get_full_name() if complaint.assigned_to else "") ws.cell(row=row_num, column=13, value=complaint.created_at.strftime("%Y-%m-%d %H:%M:%S")) ws.cell(row=row_num, column=14, value=complaint.due_at.strftime("%Y-%m-%d %H:%M:%S")) ws.cell(row=row_num, column=15, value="Yes" if complaint.is_overdue else "No") ws.cell( row=row_num, column=16, value=complaint.resolved_at.strftime("%Y-%m-%d %H:%M:%S") if complaint.resolved_at else "", ) ws.cell( row=row_num, column=17, value=complaint.closed_at.strftime("%Y-%m-%d %H:%M:%S") if complaint.closed_at else "", ) ws.cell(row=row_num, column=18, value=complaint.description[:500]) # Auto-adjust column widths for column in ws.columns: max_length = 0 column_letter = column[0].column_letter for cell in column: try: if len(str(cell.value)) > max_length: max_length = len(cell.value) except: pass adjusted_width = min(max_length + 2, 50) ws.column_dimensions[column_letter].width = adjusted_width # Save to response response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") response["Content-Disposition"] = ( f'attachment; filename="complaints_{datetime.now().strftime("%Y%m%d_%H%M%S")}.xlsx"' ) wb.save(response) return response def bulk_assign_complaints(complaint_ids: List[str], user_id: str, current_user): """ Bulk assign complaints to a user. Args: complaint_ids: List of complaint IDs user_id: ID of user to assign to current_user: User performing the action Returns: dict: Result with success count and errors """ from apps.complaints.models import Complaint, ComplaintUpdate from apps.accounts.models import User from django.utils import timezone try: assignee = User.objects.get(id=user_id) except User.DoesNotExist: return {"success": False, "error": "User not found"} success_count = 0 errors = [] for complaint_id in complaint_ids: try: complaint = Complaint.objects.get(id=complaint_id) complaint.assigned_to = assignee complaint.assigned_at = timezone.now() complaint.save(update_fields=["assigned_to", "assigned_at"]) # Create update ComplaintUpdate.objects.create( complaint=complaint, update_type="assignment", message=f"Bulk assigned to {assignee.get_full_name()}", created_by=current_user, ) success_count += 1 except Complaint.DoesNotExist: errors.append(f"Complaint {complaint_id} not found") except Exception as e: errors.append(f"Error assigning complaint {complaint_id}: {str(e)}") return {"success": True, "success_count": success_count, "total": len(complaint_ids), "errors": errors} def bulk_change_status(complaint_ids: List[str], new_status: str, current_user, note: str = ""): """ Bulk change status of complaints. Args: complaint_ids: List of complaint IDs new_status: New status to set current_user: User performing the action note: Optional note Returns: dict: Result with success count and errors """ from apps.complaints.models import Complaint, ComplaintUpdate from django.utils import timezone success_count = 0 errors = [] for complaint_id in complaint_ids: try: complaint = Complaint.objects.get(id=complaint_id) old_status = complaint.status complaint.status = new_status # Handle status-specific logic if new_status == "resolved": complaint.resolved_at = timezone.now() complaint.resolved_by = current_user elif new_status == "closed": complaint.closed_at = timezone.now() complaint.closed_by = current_user complaint.save() # Create update ComplaintUpdate.objects.create( complaint=complaint, update_type="status_change", message=note or f"Bulk status change from {old_status} to {new_status}", created_by=current_user, old_status=old_status, new_status=new_status, ) success_count += 1 except Complaint.DoesNotExist: errors.append(f"Complaint {complaint_id} not found") except Exception as e: errors.append(f"Error changing status for complaint {complaint_id}: {str(e)}") return {"success": True, "success_count": success_count, "total": len(complaint_ids), "errors": errors} def bulk_escalate_complaints(complaint_ids: List[str], current_user, reason: str = ""): """ Bulk escalate complaints. Args: complaint_ids: List of complaint IDs current_user: User performing the action reason: Escalation reason Returns: dict: Result with success count and errors """ from apps.complaints.models import Complaint, ComplaintUpdate from apps.complaints.services.complaint_service import ComplaintService from django.utils import timezone success_count = 0 errors = [] not_assigned = 0 for complaint_id in complaint_ids: try: complaint = Complaint.objects.select_related("staff", "department", "hospital").get(id=complaint_id) complaint.escalated_at = timezone.now() target_user, fallback_path = ComplaintService.get_escalation_target(complaint, staff=complaint.staff) escalation_message = f"Bulk escalation. Reason: {reason or 'No reason provided'}" if target_user: complaint.assigned_to = target_user escalation_message += f" Escalated to: {target_user.get_full_name()} [via {fallback_path}]" else: not_assigned += 1 escalation_message += " WARNING: No escalation target found." complaint.save(update_fields=["escalated_at", "assigned_to", "updated_at"]) ComplaintUpdate.objects.create( complaint=complaint, update_type="escalation", message=escalation_message, created_by=current_user, metadata={ "reason": reason, "escalated_to_user_id": str(target_user.id) if target_user else None, "escalation_fallback_path": fallback_path, "bulk": True, }, ) success_count += 1 except Complaint.DoesNotExist: errors.append(f"Complaint {complaint_id} not found") except Exception as e: errors.append(f"Error escalating complaint {complaint_id}: {str(e)}") return { "success": True, "success_count": success_count, "total": len(complaint_ids), "errors": errors, "not_assigned": not_assigned, } HEADER_FONT = Font(bold=True, color="FFFFFF", size=11) HEADER_FILL = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") HEADER_ALIGNMENT = Alignment(horizontal="center", vertical="center", wrap_text=True) SECTION_FONT = Font(bold=True, size=11) SECTION_FILL = PatternFill(start_color="D9E2F3", end_color="D9E2F3", fill_type="solid") THIN_BORDER = Border( left=Side(style="thin"), right=Side(style="thin"), top=Side(style="thin"), bottom=Side(style="thin"), ) def _write_header_row(ws, row, headers, font=HEADER_FONT, fill=HEADER_FILL, alignment=HEADER_ALIGNMENT): for col_num, header in enumerate(headers, 1): cell = ws.cell(row=row, column=col_num, value=header) cell.font = font cell.fill = fill cell.alignment = alignment cell.border = THIN_BORDER def _auto_width(ws, max_width=40): for column in ws.columns: max_length = 0 col_letter = get_column_letter(column[0].column) for cell in column: try: if cell.value: max_length = max(max_length, len(str(cell.value))) except Exception: pass ws.column_dimensions[col_letter].width = min(max_length + 3, max_width) def export_requests_report(queryset, year=None, month=None): """ Step 0 — Requests Report Excel export. Generates a monthly Excel report matching the Step 0 template: - Timeline template sheet with 15 columns - Monthly data sheet with all request rows - Summary section with stats and staff breakdown """ from apps.dashboard.models import ComplaintRequest wb = Workbook() ws_template = wb.active ws_template.title = "Time-Line Template" template_headers = [ "#", "Request Date", "Patient Name", "File Number", "Complained Department", "Incident Date", "Entry (Staff)", "Time", "Form Sent Date", "Time", "Complaint Filed Date", "Time", "Time Between Send & File", "Non-Activation Reason", "PR Observations", ] _write_header_row(ws_template, 1, template_headers) for col in range(1, 16): ws_template.column_dimensions[get_column_letter(col)].width = 18 qs = queryset.select_related("staff", "hospital", "complained_department", "complaint") month_label = f"{year}-{month:02d}" if year and month else datetime.now().strftime("%Y-%m") ws_data = wb.create_sheet(title=month_label) _write_header_row(ws_data, 1, template_headers) row_num = 2 for idx, req in enumerate(qs, 1): filled_date = req.filled_at.date() if req.filled_at else "" filled_time = req.filled_at.strftime("%H:%M") if req.filled_at else "" form_sent_date = req.form_sent_at.date() if req.form_sent_at else "" form_sent_time = req.form_sent_time.strftime("%H:%M") if req.form_sent_time else "" ws_data.cell(row=row_num, column=1, value=idx) ws_data.cell(row=row_num, column=2, value=req.request_date) ws_data.cell(row=row_num, column=3, value=req.patient_name) ws_data.cell(row=row_num, column=4, value=req.file_number) ws_data.cell(row=row_num, column=5, value=req.complained_department.name if req.complained_department else "") ws_data.cell(row=row_num, column=6, value=req.incident_date or "") ws_data.cell(row=row_num, column=7, value=req.staff.get_full_name() if req.staff else "") ws_data.cell(row=row_num, column=8, value=req.request_time.strftime("%H:%M") if req.request_time else "") ws_data.cell(row=row_num, column=9, value=form_sent_date) ws_data.cell(row=row_num, column=10, value=form_sent_time) ws_data.cell(row=row_num, column=11, value=filled_date) ws_data.cell(row=row_num, column=12, value=filled_time) if req.form_sent_at and req.filled_at: delta = req.filled_at - req.form_sent_at ws_data.cell(row=row_num, column=13, value=str(delta)) ws_data.cell(row=row_num, column=14, value=req.get_reason_non_activation_display() or "") ws_data.cell(row=row_num, column=15, value=req.pr_observations) for col in range(1, 16): ws_data.cell(row=row_num, column=col).border = THIN_BORDER row_num += 1 summary_row = row_num + 2 stats = qs.aggregate( on_hold_count=Count("pk", filter=Q(on_hold=True)), not_filled_count=Count("pk", filter=Q(not_filled=True)), filled_count=Count("pk", filter=Q(filled=True)), barcode_count=Count("pk", filter=Q(from_barcode=True)), same_time=Count("pk", filter=Q(filling_time_category="same_time")), within_6h=Count("pk", filter=Q(filling_time_category="within_6h")), six_to_24h=Count("pk", filter=Q(filling_time_category="6_to_24h")), after_1_day=Count("pk", filter=Q(filling_time_category="after_1_day")), not_mentioned=Count("pk", filter=Q(filling_time_category="not_mentioned")), ) total = qs.count() summary_items = [ ("Total Complaints on Hold", stats["on_hold_count"]), ("Total Not Filled", stats["not_filled_count"]), ("Total Filled", stats["filled_count"]), ("Total from Barcode (SELF)", stats["barcode_count"]), ("Filled at the same time", stats["same_time"]), ("Filled within 6 hours", stats["within_6h"]), ("Filled from 6 to 24 hours", stats["six_to_24h"]), ("Filled after 1 day", stats["after_1_day"]), ("Time not mentioned", stats["not_mentioned"]), ("TOTAL", total), ] ws_data.cell(row=summary_row, column=1, value="Summary").font = SECTION_FONT summary_row += 1 for label, val in summary_items: ws_data.cell(row=summary_row, column=1, value=label).font = Font(bold=(label == "TOTAL")) ws_data.cell(row=summary_row, column=2, value=val) summary_row += 1 summary_row += 1 ws_data.cell(row=summary_row, column=1, value="Staff Breakdown").font = SECTION_FONT summary_row += 1 _write_header_row(ws_data, summary_row, ["Staff", "Total", "Filled", "Not Filled"]) summary_row += 1 staff_stats = ( qs.values("staff__first_name", "staff__last_name", "staff__id") .annotate( total=Count("pk"), filled=Count("pk", filter=Q(filled=True)), not_filled=Count("pk", filter=Q(not_filled=True)), ) .order_by("-total") ) for s in staff_stats: name = f"{s['staff__first_name'] or ''} {s['staff__last_name'] or ''}".strip() or "Unknown" ws_data.cell(row=summary_row, column=1, value=name) ws_data.cell(row=summary_row, column=2, value=s["total"]) ws_data.cell(row=summary_row, column=3, value=s["filled"]) ws_data.cell(row=summary_row, column=4, value=s["not_filled"]) for col in range(1, 5): ws_data.cell(row=summary_row, column=col).border = THIN_BORDER summary_row += 1 summary_row += 1 ws_data.cell(row=summary_row, column=1, value="Non-Activation Reasons").font = SECTION_FONT summary_row += 1 _write_header_row(ws_data, summary_row, ["Reason", "Count", "Percentage"]) summary_row += 1 reason_stats = ( qs.exclude(reason_non_activation="") .values("reason_non_activation") .annotate(count=Count("pk")) .order_by("-count") ) for r in reason_stats: display = dict(ComplaintRequest.NON_ACTIVATION_REASON_CHOICES).get( r["reason_non_activation"], r["reason_non_activation"] ) pct = (r["count"] / total * 100) if total else 0 ws_data.cell(row=summary_row, column=1, value=display) ws_data.cell(row=summary_row, column=2, value=r["count"]) ws_data.cell(row=summary_row, column=3, value=f"{pct:.1f}%") for col in range(1, 4): ws_data.cell(row=summary_row, column=col).border = THIN_BORDER summary_row += 1 _auto_width(ws_data, 50) response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") response["Content-Disposition"] = f'attachment; filename="requests_report_{month_label}.xlsx"' wb.save(response) return response def export_monthly_calculations(queryset, year, month): """ Step 1 — Monthly Calculations Excel export. Generates the full 108-column monthly calculations report matching the Step 1 template structure, including: - Main data sheet with all columns A-BN - Summary sections: weekly, source, location, department, sub-department, employee performance, activation delay """ from apps.complaints.models import Complaint, ComplaintInvolvedDepartment wb = Workbook() ws = wb.active ws.title = month_label = f"{year}-{month:02d}" headers = [ "Week", "No.", "Complaint ID", "File Number", "Source", "Location", "Main Department", "Sub-Department", "Date Received", "Entered By", "MOH Ref", "MOH Ref Date", "MOH Ref Time", "Phone Number", "Timeline SLA", "Form Sent Date", "Form Sent Time", "Employee (Form Sent)", "Complaint Filed Date", "Complaint Filed Time", "Activated", "Activation Date", "Activation Time", "Employee (Activation)", "Sent to Dept Date", "Sent to Dept Time", "Employee (Dept Send)", "Time: Filed to Sent", "1st Reminder Date", "1st Reminder Time", "Employee (1st Rem)", "Time: 1st Rem to Sent", "2nd Reminder Date", "2nd Reminder Time", "Employee (2nd Rem)", "Time: 2nd Rem to 1st Rem", "Escalated", "Escalation Date", "Escalation Time", "Employee (Escalation)", "Time: Escalation to Sent", "Closed", "Close Date", "Close Time", "Employee (Close)", "Time: Close to Sent", "Resolved", "Resolve Date", "Resolve Time", "Employee (Resolve)", "Time: Resolve to Sent", "Response Date", "Response Time", "Time: Response to Sent", "Complained Person Name", "Main Complaint Subject", "Summary (Arabic)", "Summary (English)", "Reminder Documentation", "Reminder Date", "Delay Reason (Dept)", "Delay Reason (Closure 72h)", "Person Responsible for Delay", "Satisfaction", "Action Taken by Dept", "Investigation Result", "Solutions & Suggestions", "Recommendation/Action Plan", "Responsible Department", "Rightful Side", "PR Observations", ] _write_header_row(ws, 1, headers) ws.freeze_panes = "D2" qs = queryset.select_related( "hospital", "department", "main_section", "subsection", "assigned_to", "resolved_by", "closed_by", "created_by", "source", ).prefetch_related("involved_departments__department", "updates") dept_cat_keywords = { "medical": [ "doctor", "physician", "surgeon", "consultant", "specialist", "er", "emergency", "icu", "nicu", "pediatric", "ob/gyn", "obstetric", "gynecolog", "cardiology", "orthoped", "radiology", "dermatolog", "patholog", "lab", "laboratory", "pharmacy", "anesthesi", "nephrology", "urology", "dental", "dentist", "ophthalmol", "ent", "otorhinolaryng", "pulmonar", "respirator", "oncolog", "hematolog", "gastroenter", "endocrin", "neurolog", "psychiatry", "psychiatric", "internal medicine", "general surgery", "pediatrics", "neonat", "nutrition", "dietitian", "physiothera", "physical therapy", "rehab", "speech therap", "occupational", "medical report", "blood bank", "infection control", ], "admin": [ "reception", "registration", "appointment", "approval", "insurance", "finance", "billing", "account", "hr", "human resource", "it ", "information technology", "medical record", "health information", "management", "admin", "security", "parking", "facility", "maintenance", "housekeep", "clean", "food", "kitchen", "cafeteria", "transport", "patient relation", "pr ", "public relation", "complaint", "quality", "risk", "credential", "medical approval", "pre-approval", "preapproval", ], "nursing": [ "nurs", "nurse", "iv ", "injection", "medication admin", "wound care", "triage", ], "support": [ "kitchen", "food service", "clean", "housekeep", "laundry", "security", "transport", "maintenance", "facility", "steriliz", "central supply", ], } def classify_department(dept_name): if not dept_name: return "" name_lower = dept_name.lower() for cat, keywords in dept_cat_keywords.items(): for kw in keywords: if kw in name_lower: return cat.title() return "Other" def get_timeline_sla(complaint): if not complaint.resolved_at or not complaint.created_at: return "More than 72 hours" delta = complaint.resolved_at - complaint.created_at hours = delta.total_seconds() / 3600 if hours <= 24: return "24 Hours" elif hours <= 48: return "48 Hours" elif hours <= 72: return "72 Hours" return "More than 72 hours" def get_dept_primary(complaint): dept = complaint.involved_departments.filter(is_primary=True).first() if not dept: dept = complaint.involved_departments.first() return dept def fmt_date(dt): if not dt: return "" return dt.date() if hasattr(dt, "date") else dt def fmt_time(dt): if not dt: return "" return dt.strftime("%H:%M") if hasattr(dt, "strftime") else "" def time_diff(dt1, dt2): if dt1 and dt2: delta = dt1 - dt2 return str(delta) return "" row_num = 2 for idx, c in enumerate(qs, 1): cal = c.created_at week_of_month = (cal.day - 1) // 7 + 1 dept_name = "" sub_dept_name = "" dept_inv = get_dept_primary(c) if dept_inv: dept_name = dept_inv.department.name if dept_inv.department else "" sub_dept_name = dept_name main_dept = classify_department(dept_name or (c.department.name if c.department else "")) source_display = "" if c.source: source_display = c.source.name if hasattr(c.source, "name") else str(c.source) if c.complaint_source_type == "internal": if c.source and c.source.code in ("moh", "chi"): pass else: source_display = "Patient" location_display = "" if c.main_section: location_display = c.main_section.name if hasattr(c.main_section, "name") else str(c.main_section) if c.location: loc_name = c.location.name if hasattr(c.location, "name") else str(c.location) if loc_name: location_display = f"{loc_name} - {location_display}" if location_display else loc_name entry_by = c.created_by.get_full_name() if c.created_by else "" activation_date = c.activated_at or c.assigned_at activation_employee = "" if c.assigned_to: activation_employee = c.assigned_to.get_full_name() forwarded_date = c.forwarded_to_dept_at forwarded_employee = "" if dept_inv and dept_inv.assigned_to: forwarded_employee = dept_inv.assigned_to.get_full_name() reminder1_date = c.reminder_sent_at reminder2_date = c.second_reminder_sent_at if dept_inv: if dept_inv.first_reminder_sent_at: reminder1_date = dept_inv.first_reminder_sent_at if dept_inv.second_reminder_sent_at: reminder2_date = dept_inv.second_reminder_sent_at escalated_date = c.escalated_at filed_date = cal time_filed_to_sent = time_diff(forwarded_date, filed_date) time_rem1_to_sent = time_diff(forwarded_date, reminder1_date) if reminder1_date and forwarded_date else "" time_rem2_to_rem1 = time_diff(reminder2_date, reminder1_date) if reminder2_date and reminder1_date else "" time_escal_to_sent = time_diff(forwarded_date, escalated_date) if escalated_date and forwarded_date else "" close_date = c.closed_at resolve_date = c.resolved_at summary_ar = c.short_description_ar summary_en = c.short_description_en complained_person = c.staff_name or "" delay_reason_dept = "" delayed_person = "" if dept_inv: delay_reason_dept = dept_inv.delay_reason delayed_person = dept_inv.delayed_person rightful_side = c.get_resolution_outcome_display() if c.resolution_outcome else "" row_data = [ week_of_month, idx, c.reference_number or "", c.file_number or (c.patient.mrn if c.patient else ""), source_display, location_display, main_dept, sub_dept_name, fmt_date(cal), entry_by, c.moh_reference, fmt_date(c.moh_reference_date), "", c.contact_phone, get_timeline_sla(c), fmt_date(c.form_sent_at), fmt_time(c.form_sent_at), c.created_by.get_full_name() if c.created_by else "", fmt_date(filed_date), fmt_time(filed_date), "Yes" if activation_date else "No", fmt_date(activation_date), fmt_time(activation_date), activation_employee, fmt_date(forwarded_date), fmt_time(forwarded_date), forwarded_employee, time_filed_to_sent, fmt_date(reminder1_date), fmt_time(reminder1_date), "", time_rem1_to_sent, fmt_date(reminder2_date), fmt_time(reminder2_date), "", time_rem2_to_rem1, "Yes" if escalated_date else "No", fmt_date(escalated_date), fmt_time(escalated_date), "", time_escal_to_sent, "Yes" if close_date else "No", fmt_date(close_date), fmt_time(close_date), c.closed_by.get_full_name() if c.closed_by else "", time_diff(resolve_date, close_date) if resolve_date and close_date else "", "Yes" if resolve_date else "No", fmt_date(resolve_date), fmt_time(resolve_date), c.resolved_by.get_full_name() if c.resolved_by else "", time_diff(c.resolution_sent_at, resolve_date) if c.resolution_sent_at and resolve_date else "", fmt_date(c.response_date), "", time_diff(c.response_date, resolve_date) if c.response_date and resolve_date else "", complained_person, c.complaint_subject or c.title, summary_ar, summary_en, "", "", delay_reason_dept, c.delay_reason_closure, delayed_person, c.get_satisfaction_display() if c.satisfaction else "", c.action_taken_by_dept, c.action_result, c.recommendation_action_plan or "", c.recommendation_action_plan or "", dept_name or (c.department.name if c.department else ""), rightful_side, "", ] for col_num, val in enumerate(row_data, 1): cell = ws.cell(row=row_num, column=col_num, value=val) cell.border = THIN_BORDER cell.alignment = Alignment(vertical="center", wrap_text=True) row_num += 1 summary_row = row_num + 2 ws.cell(row=summary_row, column=1, value="Weekly Breakdown").font = SECTION_FONT summary_row += 1 _write_header_row(ws, summary_row, ["Metric", "Week 1", "Week 2", "Week 3", "Week 4", "Week 5", "Total"]) summary_row += 1 for week in range(1, 6): start_day = (week - 1) * 7 + 1 end_day = week * 7 week_count = sum( 1 for c in queryset if start_day <= c.created_at.day <= end_day and c.created_at.month == month ) ws.cell(row=summary_row, column=week + 1, value=week_count) ws.cell(row=summary_row, column=7, value=queryset.count()) summary_row += 2 ws.cell(row=summary_row, column=1, value="Source Distribution").font = SECTION_FONT summary_row += 1 _write_header_row(ws, summary_row, ["Source", "Count", "%"]) summary_row += 1 total = queryset.count() source_counts = {"MOH": 0, "CCHI": 0, "Patient": 0, "Patient's Relatives": 0, "Other": 0} for c in queryset: if c.source and c.source.code == "moh": source_counts["MOH"] += 1 elif c.source and c.source.code == "chi": source_counts["CCHI"] += 1 elif c.complaint_source_type == "internal": if c.source and c.source.code == "chi": source_counts["CCHI"] += 1 elif c.source and c.source.code == "moh": source_counts["MOH"] += 1 else: source_counts["Patient"] += 1 else: source_counts["Other"] += 1 for src, cnt in source_counts.items(): if cnt > 0: pct = (cnt / total * 100) if total else 0 ws.cell(row=summary_row, column=1, value=src) ws.cell(row=summary_row, column=2, value=cnt) ws.cell(row=summary_row, column=3, value=f"{pct:.1f}%") for col in range(1, 4): ws.cell(row=summary_row, column=col).border = THIN_BORDER summary_row += 1 ws.cell(row=summary_row, column=1, value="Total").font = Font(bold=True) ws.cell(row=summary_row, column=2, value=total) summary_row += 2 ws.cell(row=summary_row, column=1, value="Department Category Breakdown").font = SECTION_FONT summary_row += 1 _write_header_row(ws, summary_row, ["Category", "Count", "%"]) summary_row += 1 dept_categories = {} for c in queryset: dept_name_raw = "" dept_inv = c.involved_departments.filter(is_primary=True).first() or c.involved_departments.first() if dept_inv and dept_inv.department: dept_name_raw = dept_inv.department.name elif c.department: dept_name_raw = c.department.name cat = classify_department(dept_name_raw) or "Other" dept_categories[cat] = dept_categories.get(cat, 0) + 1 for cat, cnt in sorted(dept_categories.items(), key=lambda x: -x[1]): pct = (cnt / total * 100) if total else 0 ws.cell(row=summary_row, column=1, value=cat) ws.cell(row=summary_row, column=2, value=cnt) ws.cell(row=summary_row, column=3, value=f"{pct:.1f}%") for col in range(1, 4): ws.cell(row=summary_row, column=col).border = THIN_BORDER summary_row += 1 summary_row += 2 ws.cell(row=summary_row, column=1, value="Employee Performance").font = SECTION_FONT summary_row += 1 _write_header_row(ws, summary_row, ["Employee", "Count", "%", "24h", "48h", "72h", ">72h", "Total", "SELF"]) summary_row += 1 emp_stats = ( queryset.values("created_by__first_name", "created_by__last_name") .annotate( total=Count("pk"), ) .order_by("-total") ) for emp in emp_stats: name = f"{emp['created_by__first_name'] or ''} {emp['created_by__last_name'] or ''}".strip() or "Unknown" emp_qs = queryset.filter( created_by__first_name=emp["created_by__first_name"], created_by__last_name=emp["created_by__last_name"] ) h24 = sum(1 for c in emp_qs if c.resolved_at and (c.resolved_at - c.created_at).total_seconds() <= 86400) h48 = sum( 1 for c in emp_qs if c.resolved_at and 86400 < (c.resolved_at - c.created_at).total_seconds() <= 172800 ) h72 = sum( 1 for c in emp_qs if c.resolved_at and 172800 < (c.resolved_at - c.created_at).total_seconds() <= 259200 ) over72 = emp["total"] - h24 - h48 - h72 self_count = sum(1 for c in emp_qs if c.created_by == c.assigned_to) pct = (emp["total"] / total * 100) if total else 0 row_vals = [name, emp["total"], f"{pct:.1f}%", h24, h48, h72, max(0, over72), emp["total"], self_count] for col_num, val in enumerate(row_vals, 1): ws.cell(row=summary_row, column=col_num, value=val) ws.cell(row=summary_row, column=col_num).border = THIN_BORDER summary_row += 1 response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") response["Content-Disposition"] = f'attachment; filename="monthly_calculations_{month_label}.xlsx"' wb.save(response) return response def _build_quarterly_yearly_report(queryset, title, months_list, year=None): """ Shared logic for quarterly and yearly calculations reports. Args: queryset: Complaint queryset (already filtered by date range) title: Sheet/report title (e.g., "Q1 2025" or "Yearly 2025") months_list: list of (month_num,) tuples for each month in the period year: year number (optional, for sheet naming) """ from apps.complaints.models import Complaint, ComplaintInvolvedDepartment wb = Workbook() ws_kpi = wb.active ws_kpi.title = "1. KPI" dept_cat_keywords = { "medical": [ "doctor", "physician", "surgeon", "consultant", "specialist", "er", "emergency", "icu", "nicu", "pediatric", "ob/gyn", "cardiology", "orthoped", "radiology", "dermatolog", "lab", "pharmacy", "anesthesi", "nephrology", "urology", "dental", "oncolog", "hematolog", "gastroenter", "endocrin", "neurolog", "psychiatry", "internal medicine", "general surgery", "pediatrics", "nutrition", "physiothera", "physical therapy", "rehab", "medical report", "blood bank", "infection control", ], "admin": [ "reception", "appointment", "approval", "insurance", "finance", "billing", "hr", "medical record", "management", "admin", "security", "facility", "quality", "risk", "credential", "medical approval", "pre-approval", "preapproval", "it ", ], "nursing": ["nurs", "nurse", "iv ", "injection", "medication admin", "wound care", "triage"], "support": ["kitchen", "food service", "clean", "housekeep", "laundry", "steriliz", "central supply"], } def classify_department(dept_name): if not dept_name: return "Other" name_lower = dept_name.lower() for cat, keywords in dept_cat_keywords.items(): for kw in keywords: if kw in name_lower: return cat.title() return "Other" month_labels = [datetime.strptime(f"{year}-{m:02d}", "%Y-%m").strftime("%b") for _, m in months_list] complaints_list = list( queryset.select_related( "hospital", "department", "source", "created_by", "assigned_to", "resolved_by", "closed_by", ).prefetch_related("involved_departments__department") ) month_complaints = {} for c in complaints_list: m_key = c.created_at.month if m_key not in month_complaints: month_complaints[m_key] = [] month_complaints[m_key].append(c) def _month_qs(m): return month_complaints.get(m, []) row = 1 ws_kpi.cell(row=row, column=1, value=title).font = Font(bold=True, size=14) row += 2 def write_kpi_block(name, monthly_values, target=0.95, threshold=0.90): nonlocal row ws_kpi.cell(row=row, column=1, value=name).font = Font(bold=True, size=12) row += 1 numerator_label_row = row ws_kpi.cell(row=row, column=1, value="Numerator") for i, (m_num, _) in enumerate(months_list): ws_kpi.cell(row=row, column=2 + i, value=monthly_values[i][0]) total_num = sum(v[0] for v in monthly_values) ws_kpi.cell(row=row, column=2 + len(months_list), value=total_num) row += 1 ws_kpi.cell(row=row, column=1, value="Denominator") for i, (m_num, _) in enumerate(months_list): ws_kpi.cell(row=row, column=2 + i, value=monthly_values[i][1]) total_den = sum(v[1] for v in monthly_values) ws_kpi.cell(row=row, column=2 + len(months_list), value=total_den) row += 1 ws_kpi.cell(row=row, column=1, value="Result (%)") for i, (m_num, _) in enumerate(months_list): pct = (monthly_values[i][0] / monthly_values[i][1] * 100) if monthly_values[i][1] else 0 ws_kpi.cell(row=row, column=2 + i, value=f"{pct:.1f}%") total_pct = (total_num / total_den * 100) if total_den else 0 ws_kpi.cell(row=row, column=2 + len(months_list), value=f"{total_pct:.1f}%") row += 2 kpi1_data = [] kpi2_data = [] kpi4_data = [] for m_num, _ in months_list: mc = _month_qs(m_num) total_m = len(mc) closed_m = sum(1 for c in mc if c.status in ("resolved", "closed")) kpi1_data.append((closed_m, total_m)) resolved_72h = 0 for c in mc: if c.resolved_at and c.created_at: if (c.resolved_at - c.created_at).total_seconds() <= 259200: resolved_72h += 1 kpi2_data.append((resolved_72h, total_m)) resolved_48h = 0 for c in mc: if c.resolved_at and c.created_at: if (c.resolved_at - c.created_at).total_seconds() <= 172800: resolved_48h += 1 kpi4_data.append((resolved_48h, total_m)) write_kpi_block("KPI #1 — Closure Rate", kpi1_data) write_kpi_block("KPI #2 — Resolved Within 72 Hours", kpi2_data) write_kpi_block("KPI #4 — Responses Within 48 Hours", kpi4_data, target=0.50, threshold=0.40) kpi3_data = [] for m_num, _ in months_list: mc = _month_qs(m_num) satisfied_m = sum(1 for c in mc if c.satisfaction == "satisfied") total_surveyed_m = sum(1 for c in mc if c.satisfaction in ("satisfied", "neutral", "dissatisfied")) kpi3_data.append((satisfied_m, total_surveyed_m)) write_kpi_block("KPI #3 — Satisfaction Rate", kpi3_data, target=0.80, threshold=0.70) response_rate_call_data = [] response_rate_survey_data = [] for m_num, _ in months_list: mc = _month_qs(m_num) total_m = len(mc) responded_call = sum(1 for c in mc if c.satisfaction in ("satisfied", "neutral", "dissatisfied")) responded_survey = sum(1 for c in mc if c.resolution_survey_id is not None) response_rate_call_data.append((responded_call, total_m)) response_rate_survey_data.append((responded_survey, total_m)) write_kpi_block("Response Rate — Calls", response_rate_call_data, target=0.80, threshold=0.70) write_kpi_block("Response Rate — Survey", response_rate_survey_data, target=0.80, threshold=0.70) for col in range(1, 3 + len(months_list)): ws_kpi.column_dimensions[get_column_letter(col)].width = 16 ws_table = wb.create_sheet("2. First Table") row = 1 ws_table.cell(row=row, column=1, value=f"{title} — Distribution Analysis").font = Font(bold=True, size=14) row += 2 external = 0 internal = 0 source_breakdown = {"MOH": 0, "CCHI": 0, "Insurance": 0, "Internal": 0} location_breakdown = {"In-Patient": 0, "Out-Patient": 0, "ER": 0} category_breakdown = {} for c in complaints_list: is_external = False if c.source and c.source.code == "moh": source_breakdown["MOH"] += 1 external += 1 is_external = True elif c.source and c.source.code == "chi": source_breakdown["CCHI"] += 1 external += 1 is_external = True else: source_breakdown["Internal"] += 1 internal += 1 loc = "Other" if c.main_section: loc_name = c.main_section.name.lower() if hasattr(c.main_section, "name") else str(c.main_section).lower() if "inpatient" in loc_name or "ip" in loc_name: loc = "In-Patient" elif "outpatient" in loc_name or "op" in loc_name or "clinic" in loc_name: loc = "Out-Patient" elif "er" in loc_name or "emergency" in loc_name: loc = "ER" location_breakdown[loc] = location_breakdown.get(loc, 0) + 1 dept_inv = c.involved_departments.filter(is_primary=True).first() or c.involved_departments.first() dept_name = "" if dept_inv and dept_inv.department: dept_name = dept_inv.department.name elif c.department: dept_name = c.department.name cat = classify_department(dept_name) category_breakdown[cat] = category_breakdown.get(cat, 0) + 1 total = len(complaints_list) ws_table.cell(row=row, column=1, value="External vs Internal").font = SECTION_FONT row += 1 _write_header_row(ws_table, row, ["Category", "Count", "%"]) row += 1 for label, count in [("External (MOH/CCHI/Insurance)", external), ("Internal (Patients/Relatives)", internal)]: pct = (count / total * 100) if total else 0 ws_table.cell(row=row, column=1, value=label) ws_table.cell(row=row, column=2, value=count) ws_table.cell(row=row, column=3, value=f"{pct:.1f}%") for col in range(1, 4): ws_table.cell(row=row, column=col).border = THIN_BORDER row += 1 ws_table.cell(row=row, column=1, value="Total").font = Font(bold=True) ws_table.cell(row=row, column=2, value=total).font = Font(bold=True) row += 2 ws_table.cell(row=row, column=1, value="By Source").font = SECTION_FONT row += 1 _write_header_row(ws_table, row, ["Source", "Count", "%"]) row += 1 for src, cnt in sorted(source_breakdown.items(), key=lambda x: -x[1]): pct = (cnt / total * 100) if total else 0 ws_table.cell(row=row, column=1, value=src) ws_table.cell(row=row, column=2, value=cnt) ws_table.cell(row=row, column=3, value=f"{pct:.1f}%") for col in range(1, 4): ws_table.cell(row=row, column=col).border = THIN_BORDER row += 1 row += 2 ws_table.cell(row=row, column=1, value="By Location").font = SECTION_FONT row += 1 _write_header_row(ws_table, row, ["Location", "Count", "%"]) row += 1 for loc, cnt in sorted(location_breakdown.items(), key=lambda x: -x[1]): pct = (cnt / total * 100) if total else 0 ws_table.cell(row=row, column=1, value=loc) ws_table.cell(row=row, column=2, value=cnt) ws_table.cell(row=row, column=3, value=f"{pct:.1f}%") for col in range(1, 4): ws_table.cell(row=row, column=col).border = THIN_BORDER row += 1 row += 2 ws_table.cell(row=row, column=1, value="By Department Category").font = SECTION_FONT row += 1 _write_header_row(ws_table, row, ["Category", "Count", "%"]) row += 1 for cat, cnt in sorted(category_breakdown.items(), key=lambda x: -x[1]): pct = (cnt / total * 100) if total else 0 ws_table.cell(row=row, column=1, value=cat) ws_table.cell(row=row, column=2, value=cnt) ws_table.cell(row=row, column=3, value=f"{pct:.1f}%") for col in range(1, 4): ws_table.cell(row=row, column=col).border = THIN_BORDER row += 1 for col in range(1, 5): ws_table.column_dimensions[get_column_letter(col)].width = 35 ws_escalated = wb.create_sheet("3. Escalated Complaints") row = 1 ws_escalated.cell(row=row, column=1, value=f"{title} — Escalated Complaints by Department").font = Font( bold=True, size=14 ) row += 2 escalated_by_dept = {} escalated_by_cat = {} for c in complaints_list: if c.escalated_at: dept_inv = c.involved_departments.filter(is_primary=True).first() or c.involved_departments.first() dept_name = "" if dept_inv and dept_inv.department: dept_name = dept_inv.department.name elif c.department: dept_name = c.department.name if dept_name: escalated_by_dept[dept_name] = escalated_by_dept.get(dept_name, 0) + 1 cat = classify_department(dept_name) if cat not in escalated_by_cat: escalated_by_cat[cat] = {"escalated": 0, "total": 0} escalated_by_cat[cat]["escalated"] += 1 for c in complaints_list: dept_inv = c.involved_departments.filter(is_primary=True).first() or c.involved_departments.first() dept_name = "" if dept_inv and dept_inv.department: dept_name = dept_inv.department.name elif c.department: dept_name = c.department.name cat = classify_department(dept_name) if cat not in escalated_by_cat: escalated_by_cat[cat] = {"escalated": 0, "total": 0} escalated_by_cat[cat]["total"] += 1 _write_header_row(ws_escalated, row, ["Department Category", "Escalated", "Total", "Escalation Rate"]) row += 1 for cat, data in sorted(escalated_by_cat.items()): rate = (data["escalated"] / data["total"] * 100) if data["total"] else 0 ws_escalated.cell(row=row, column=1, value=cat) ws_escalated.cell(row=row, column=2, value=data["escalated"]) ws_escalated.cell(row=row, column=3, value=data["total"]) ws_escalated.cell(row=row, column=4, value=f"{rate:.1f}%") for col in range(1, 5): ws_escalated.cell(row=row, column=col).border = THIN_BORDER row += 1 row += 2 ws_escalated.cell(row=row, column=1, value="Escalated by Specific Department").font = SECTION_FONT row += 1 _write_header_row(ws_escalated, row, ["Department", "Escalated Count"]) row += 1 for dept, cnt in sorted(escalated_by_dept.items(), key=lambda x: -x[1]): ws_escalated.cell(row=row, column=1, value=dept) ws_escalated.cell(row=row, column=2, value=cnt) for col in range(1, 3): ws_escalated.cell(row=row, column=col).border = THIN_BORDER row += 1 for col in range(1, 5): ws_escalated.column_dimensions[get_column_letter(col)].width = 30 response_rate_sheets = [ ("5. Internal Response Rate", "internal"), ("6. CHI Response Rate", "chi"), ("7. MOH Response Rate", "moh"), ] for sheet_name, source_filter in response_rate_sheets: ws_rr = wb.create_sheet(sheet_name) row = 1 ws_rr.cell(row=row, column=1, value=f"{title} — {sheet_name}").font = Font(bold=True, size=14) row += 2 if source_filter == "internal": filtered = [c for c in complaints_list if not (c.source and c.source.code in ("moh", "chi"))] elif source_filter == "chi": filtered = [c for c in complaints_list if c.source and c.source.code == "chi"] else: filtered = [c for c in complaints_list if c.source and c.source.code == "moh"] buckets = {"24 Hours": 0, "48 Hours": 0, "72 Hours": 0, "More than 72 Hours": 0} for c in filtered: if c.resolved_at and c.created_at: hours = (c.resolved_at - c.created_at).total_seconds() / 3600 if hours <= 24: buckets["24 Hours"] += 1 elif hours <= 48: buckets["48 Hours"] += 1 elif hours <= 72: buckets["72 Hours"] += 1 else: buckets["More than 72 Hours"] += 1 else: buckets["More than 72 Hours"] += 1 total_filtered = len(filtered) _write_header_row(ws_rr, row, ["Timeline", "Count", "%"]) row += 1 for label, cnt in buckets.items(): pct = (cnt / total_filtered * 100) if total_filtered else 0 ws_rr.cell(row=row, column=1, value=label) ws_rr.cell(row=row, column=2, value=cnt) ws_rr.cell(row=row, column=3, value=f"{pct:.1f}%") for col in range(1, 4): ws_rr.cell(row=row, column=col).border = THIN_BORDER row += 1 ws_rr.cell(row=row, column=1, value="Total").font = Font(bold=True) ws_rr.cell(row=row, column=2, value=total_filtered).font = Font(bold=True) for col in range(1, 4): ws_rr.column_dimensions[get_column_letter(col)].width = 25 return wb def export_quarterly_calculations(queryset, year, quarter): """ Step 2 — Quarterly Calculations Excel export. Args: queryset: Complaint queryset filtered to the quarter year: int quarter: int (1-4) """ quarter_months = { 1: [(1, "Jan"), (2, "Feb"), (3, "Mar")], 2: [(4, "Apr"), (5, "May"), (6, "Jun")], 3: [(7, "Jul"), (8, "Aug"), (9, "Sep")], 4: [(10, "Oct"), (11, "Nov"), (12, "Dec")], } months = quarter_months[quarter] title = f"Q{quarter} {year}" wb = _build_quarterly_yearly_report(queryset, title, months, year=year) response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") response["Content-Disposition"] = f'attachment; filename="quarterly_calculations_Q{quarter}_{year}.xlsx"' wb.save(response) return response def export_yearly_calculations(queryset, year): """ Yearly Calculations Excel export. Args: queryset: Complaint queryset filtered to the year year: int """ months = [(m, datetime.strptime(f"{m}", "%m").strftime("%b")) for m in range(1, 13)] title = f"Yearly {year}" wb = _build_quarterly_yearly_report(queryset, title, months, year=year) response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") response["Content-Disposition"] = f'attachment; filename="yearly_calculations_{year}.xlsx"' wb.save(response) return response def export_inquiries_report(queryset, year, month, is_outgoing=False): """ Reports — Incoming/Outgoing Inquiries Excel export. Matches the 27-column template from the Excel documents. """ wb = Workbook() month_label = f"{year}-{month:02d}" ws = wb.active ws.title = month_label prefix = "Outgoing" if is_outgoing else "Incoming" headers = [ "Week", "No.", "Date/Time", "Visitor Name", "Mobile", "Department", "Employee ID", "Timeline", "Inquiry Date", "Inquiry Time", "Contacted NR Date", "Contacted NR Time", "Contacted NR Employee", "Response Duration", "Under Process Date", "Under Process Time", "Under Process Employee", "Processing Duration", "Contacted Date", "Contacted Time", "Contacted Employee", "Contact Duration", "", "Inquiry Description", "Status", "Employee Notes", "Supervisor Notes", ] _write_header(ws, 1, headers) ws.freeze_panes = "C2" qs = queryset.select_related( "hospital", "department", "assigned_to", "created_by", "outgoing_department", "responded_by", ).prefetch_related("updates") row_num = 2 for idx, inq in enumerate(qs, 1): week_of_month = (inq.created_at.day - 1) // 15 + 1 week_label = "1st Half" if week_of_month == 1 else "2nd Half" dept_name = "" if is_outgoing: dept_name = inq.outgoing_department.name if inq.outgoing_department else "" else: dept_name = inq.department.name if inq.department else "" created_date = inq.created_at.date() if inq.created_at else "" created_time = inq.created_at.strftime("%H:%M") if inq.created_at else "" activated_date = inq.activated_at or inq.assigned_at responded_date = inq.responded_at timeline = "" if inq.due_at and inq.created_at: hours = (inq.due_at - inq.created_at).total_seconds() / 3600 if hours <= 24: timeline = "24 Hours" elif hours <= 48: timeline = "48 Hours" elif hours <= 72: timeline = "72 Hours" else: timeline = "More than 72 hours" _write_row( ws, row_num, [ week_label, idx, f"{created_date} {created_time}", inq.contact_name or "", inq.contact_phone or "", dept_name, "", timeline, inq.assigned_at.date() if inq.assigned_at else "", inq.assigned_at.strftime("%H:%M") if inq.assigned_at else "", "", "", "", "", "", "", "", "", responded_date.date() if responded_date else "", responded_date.strftime("%H:%M") if responded_date else "", inq.responded_by.get_full_name() if inq.responded_by else "", "", "", inq.subject, inq.get_status_display(), "", "", ], ) row_num += 1 summary_row = row_num + 2 total = queryset.count() status_counts = queryset.values("status").annotate(count=Count("pk")) timeline_counts = {"24 Hours": 0, "48 Hours": 0, "72 Hours": 0, "More than 72 hours": 0} for inq in qs: if inq.due_at and inq.created_at: hours = (inq.due_at - inq.created_at).total_seconds() / 3600 if hours <= 24: timeline_counts["24 Hours"] += 1 elif hours <= 48: timeline_counts["48 Hours"] += 1 elif hours <= 72: timeline_counts["72 Hours"] += 1 else: timeline_counts["More than 72 hours"] += 1 ws.cell(row=summary_row, column=1, value="Summary").font = SECTION_FONT summary_row += 1 ws.cell(row=summary_row, column=1, value=f"Total {prefix} Inquiries: {total}") summary_row += 2 ws.cell(row=summary_row, column=1, value="By Status").font = SECTION_FONT summary_row += 1 _write_header(ws, summary_row, ["Status", "Count"]) summary_row += 1 for sc in status_counts: ws.cell(row=summary_row, column=1, value=sc["status"]) ws.cell(row=summary_row, column=2, value=sc["count"]) summary_row += 1 summary_row += 1 ws.cell(row=summary_row, column=1, value="By Timeline").font = SECTION_FONT summary_row += 1 _write_header(ws, summary_row, ["Timeline", "Count"]) summary_row += 1 for tl, cnt in timeline_counts.items(): ws.cell(row=summary_row, column=1, value=tl) ws.cell(row=summary_row, column=2, value=cnt) summary_row += 1 for col in range(1, 10): ws.column_dimensions[get_column_letter(col)].width = 18 ws.column_dimensions[get_column_letter(4)].width = 25 ws.column_dimensions[get_column_letter(23)].width = 40 ws.column_dimensions[get_column_letter(24)].width = 20 ws.column_dimensions[get_column_letter(26)].width = 30 response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") response["Content-Disposition"] = f'attachment; filename="{prefix.lower()}_inquiries_{month_label}.xlsx"' wb.save(response) return response def export_observations_report(queryset, year, month): """ Reports — Observations Excel export. Matches the 22-column template from the Observations Excel document. """ wb = Workbook() month_label = f"{year}-{month:02d}" ws = wb.active ws.title = month_label headers = [ "Portal", "Note No.", "Send Date", "Send Time", "Recipient Mobile", "File Number", "Sender Employee ID", "Observation Source", "Main Category", "Sub-Category", "", "Topic", "Details (Arabic)", "Details (English)", "Person Notified", "Department Notified", "Communication Method", "Communication Date", "Communication Time", "Action Plan / Action Taken", "Follow-Up Resolved?", "Solutions & Suggestions", ] _write_header(ws, 1, headers) ws.freeze_panes = "C2" row_num = 2 for idx, obs in enumerate(queryset, 1): action_taken = "" resolved = "" solutions = "" for note in obs.notes.all(): text = note.note.lower() if note.note else "" if not action_taken and ("action" in text or "taken" in text): action_taken = note.note if "resolved" in text or "done" in text: resolved = "Yes" if "suggestion" in text or "solution" in text: solutions = note.note obs_source = "" if obs.source: source_map = { "staff_portal": "Portal", "web_form": "Portal", "mobile_app": "Barcode", "email": "Referral", "call_center": "In-person", } obs_source = source_map.get(obs.source, obs.source) status_map = { "resolved": "done", "closed": "resolved", "in_progress": "under process", "new": "", } resolved_display = status_map.get(obs.status, "") _write_row( ws, row_num, [ obs_source, idx, obs.incident_datetime.date() if obs.incident_datetime else "", obs.incident_datetime.strftime("%H:%M") if obs.incident_datetime else "", obs.reporter_phone or "", obs.tracking_code or "", obs.reporter_staff_id or "", obs_source, obs.category.name if obs.category else "", obs.assigned_department.name if obs.assigned_department else "", "", obs.title or "", obs.description or "", obs.resolution_notes or "", obs.assigned_to.get_full_name() if obs.assigned_to else "", obs.assigned_department.name if obs.assigned_department else "", "", obs.activated_at.date() if obs.activated_at else "", obs.activated_at.strftime("%H:%M") if obs.activated_at else "", action_taken, resolved_display, solutions, ], ) row_num += 1 for col in range(1, 23): ws.column_dimensions[get_column_letter(col)].width = 20 ws.column_dimensions[get_column_letter(13)].width = 40 ws.column_dimensions[get_column_letter(14)].width = 40 ws.column_dimensions[get_column_letter(20)].width = 35 response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") response["Content-Disposition"] = f'attachment; filename="observations_{month_label}.xlsx"' wb.save(response) return response