""" 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() if complaint.patient else "", complaint.patient.mrn if complaint.patient else "", complaint.hospital.name if complaint.hospital else "", complaint.department.name if complaint.department else "", str(complaint.category) if complaint.category else "", complaint.get_severity_display(), complaint.get_priority_display(), complaint.get_status_display(), complaint.get_complaint_source_type_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") if complaint.due_at else "", "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] if complaint.description else "", ] ) 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() if complaint.patient else "") ws.cell(row=row_num, column=4, value=complaint.patient.mrn if complaint.patient else "") ws.cell(row=row_num, column=5, value=complaint.hospital.name if complaint.hospital else "") ws.cell(row=row_num, column=6, value=complaint.department.name if complaint.department else "") ws.cell(row=row_num, column=7, value=str(complaint.category) if complaint.category else "") 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_complaint_source_type_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") if complaint.due_at else "" ) 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] if complaint.description else "") # 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.activated_at: return "More than 72 hours" delta = complaint.resolved_at - complaint.activated_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 and c.source.code.upper() == "MOH": source_counts["MOH"] += 1 elif c.source and c.source.code and c.source.code.upper() in ("CHI", "CCHI"): source_counts["CCHI"] += 1 elif c.complaint_source_type == "internal": 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.activated_at and (c.resolved_at - c.activated_at).total_seconds() <= 86400) h48 = sum( 1 for c in emp_qs if c.resolved_at and c.activated_at and 86400 < (c.resolved_at - c.activated_at).total_seconds() <= 172800 ) h72 = sum( 1 for c in emp_qs if c.resolved_at and c.activated_at and 172800 < (c.resolved_at - c.activated_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.activated_at: if (c.resolved_at - c.activated_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.activated_at: if (c.resolved_at - c.activated_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) reopen_data = [] for m_num, _ in months_list: mc = _month_qs(m_num) total_m = len(mc) reopened_m = sum(1 for c in mc if c.reopened_from_id is not None) reopen_data.append((reopened_m, total_m)) write_kpi_block("Reopen Rate", reopen_data, target=0.10, threshold=0.20) reassign_data = [] ComplaintUpdate = apps.get_model("complaints", "ComplaintUpdate") for m_num, _ in months_list: mc_ids = list(mc.values_list("id", flat=True)) if hasattr(mc, 'values_list') else [] total_m = len(mc_ids) if mc_ids else len(_month_qs(m_num)) if mc_ids: mc_qs = _month_qs(m_num) else: mc_qs = _month_qs(m_num) reassigned_m = ComplaintUpdate.objects.filter( complaint__in=mc_qs, update_type="assignment", created_at__month=m_num, ).count() reassign_data.append((reassigned_m, total_m)) write_kpi_block("Reassign Count", reassign_data) 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 = {"Inpatient": 0, "Outpatient": 0, "Emergency": 0} category_breakdown = {} for c in complaints_list: is_external = False if c.source and c.source.code and c.source.code.upper() == "MOH": source_breakdown["MOH"] += 1 external += 1 is_external = True elif c.source and c.source.code and c.source.code.upper() in ("CHI", "CCHI"): 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 = "Inpatient" elif "outpatient" in loc_name or "op" in loc_name or "clinic" in loc_name: loc = "Outpatient" elif "er" in loc_name or "emergency" in loc_name: loc = "Emergency" 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 and c.source.code.upper() in ("MOH", "CHI", "CCHI"))] elif source_filter == "chi": filtered = [c for c in complaints_list if c.source and c.source.code and c.source.code.upper() in ("CHI", "CCHI")] else: filtered = [c for c in complaints_list if c.source and c.source.code and c.source.code.upper() == "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.activated_at: hours = (c.resolved_at - c.activated_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 _fmt_date(dt): if dt is None: return None dt = dt.replace(tzinfo=None) if hasattr(dt, "replace") and getattr(dt, "tzinfo", None) else dt return dt.date() if hasattr(dt, "date") else dt def _fmt_time(dt): if dt is None: return None if hasattr(dt, "strftime"): return dt.strftime("%H:%M") return None def _strip_tz(dt): if dt is None: return None if hasattr(dt, "tzinfo") and dt.tzinfo is not None: return dt.replace(tzinfo=None) return dt def _fmt_duration(td): if td is None: return None if isinstance(td, timedelta): total_sec = int(td.total_seconds()) h, rem = divmod(total_sec, 3600) m, s = divmod(rem, 60) return f"{h:02d}:{m:02d}:{s:02d}" return td _INCOMING_MONTHS = [ "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JULY", "AUG", "SEP", "OCT", "NOV", "Dec", ] def export_inquiries_report(queryset, year, month, is_outgoing=False): """ Reports — Incoming/Outgoing Inquiries Excel export. Layout matches the production Excel templates exactly: - Row 1: merged group headers (Portal / Time Line / PDF / رأيك الشخصي) - Row 2: Arabic sub-headers with merged timeline groups - Data rows starting at row 3 - Calculations sheet with per-employee/per-department breakdowns - Drop-down sheet with timeline options, employee list, status options """ wb = Workbook() month_idx = month - 1 month_label = f"{_INCOMING_MONTHS[month_idx]}-{year % 100}" ws = wb.active ws.title = month_label CENTER = Alignment(horizontal="center", vertical="center") RIGHT_CENTER = Alignment(horizontal="right", vertical="center") WRAP_CENTER = Alignment(horizontal="center", vertical="center", wrap_text=True) R1_FONT = Font(name="Aptos Narrow", bold=True, size=14) R1_FONT_LG = Font(name="Aptos Narrow", bold=True, size=16) R1_FONT_XL = Font(name="Aptos Narrow", bold=True, size=18) R2_FONT = Font(bold=True) DATA_FONT = Font(name="Calibri", size=11) PORTAL_FILL = PatternFill("solid", fgColor="FFC7CE") PORTAL_FONT = Font(name="Aptos Narrow", bold=True, size=14, color="9C0006") TIMELINE_FILL = PatternFill("solid", fgColor="FFFFCC") TIMELINE_FONT = Font(name="Aptos Narrow", bold=True, size=16) PDF_FILL = PatternFill("solid", fgColor="C6EFCE") PDF_FONT = Font(name="Aptos Narrow", bold=True, size=18, color="006100") OPINION_FILL = PatternFill("solid", fgColor="FFCC99") OPINION_FONT = Font(name="Aptos Narrow", bold=True, size=18, color="3F3F76") R2_FILL = PatternFill("solid", fgColor="4472C4") R2_FONT_WHITE = Font(bold=True, color="FFFFFF", size=11) THIN = Border( left=Side("thin"), right=Side("thin"), top=Side("thin"), bottom=Side("thin"), ) if is_outgoing: _build_outgoing_sheet( ws, queryset, year, month, CENTER, RIGHT_CENTER, WRAP_CENTER, PORTAL_FILL, PORTAL_FONT, TIMELINE_FILL, TIMELINE_FONT, PDF_FILL, PDF_FONT, OPINION_FILL, OPINION_FONT, R2_FILL, R2_FONT_WHITE, THIN, DATA_FONT, ) _build_outgoing_calculations(wb, queryset, year, month) _build_outgoing_dropdown(wb, queryset) else: _build_incoming_sheet( ws, queryset, year, month, CENTER, RIGHT_CENTER, WRAP_CENTER, PORTAL_FILL, PORTAL_FONT, TIMELINE_FILL, TIMELINE_FONT, PDF_FILL, PDF_FONT, OPINION_FILL, OPINION_FONT, R2_FILL, R2_FONT_WHITE, THIN, DATA_FONT, ) _build_incoming_calculations(wb, queryset, year, month) _build_incoming_dropdown(wb, queryset) prefix = "Outgoing" if is_outgoing else "Incoming" file_label = f"{year}-{month:02d}" response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") response["Content-Disposition"] = f'attachment; filename="{prefix.lower()}_inquiries_{file_label}.xlsx"' wb.save(response) return response def _build_incoming_sheet( ws, queryset, year, month, center, right_center, wrap_center, portal_fill, portal_font, timeline_fill, timeline_font, pdf_fill, pdf_font, opinion_fill, opinion_font, r2_fill, r2_font, border, data_font, ): ws.merge_cells("A1:B1") ws.merge_cells("C1:F1") ws.merge_cells("G1:V1") ws.merge_cells("X1:Y1") ws.merge_cells("Z1:AA1") for col, val, fill, font in [ (3, "Portal", portal_fill, portal_font), (7, "Time Line", timeline_fill, timeline_font), (24, "PDF", pdf_fill, pdf_font), (26, "رأيك الشخصي", opinion_fill, opinion_font), ]: c = ws.cell(row=1, column=col, value=val) c.font = font c.fill = fill c.alignment = center ws.merge_cells("K2:N2") ws.merge_cells("O2:R2") ws.merge_cells("S2:V2") r2_headers = { 1: "week", 2: "No.", 3: "التاريخ", 4: "اسم المراجع", 5: "رقم الجوال", 6: "القسم المرسل", 7: "ID NO.", 8: "Time line", 9: "تاريخ الاستفسار", 10: "وقت الاستفسار", 11: "تم التواصل ولم يتم الرد", 15: "تحت الإجراء", 19: "تم التواصل ", 24: "الاستفسار (من الPDF)", 25: "حالة الاستفسار", 26: "ملاحظات الموظف", 27: "ملاحظات المشرف", } for col, val in r2_headers.items(): c = ws.cell(row=2, column=col, value=val) c.font = r2_font c.fill = r2_fill c.alignment = wrap_center c.border = border for col in range(1, 28): ws.cell(row=2, column=col).border = border if col not in r2_headers: c = ws.cell(row=2, column=col) c.fill = r2_fill c.border = border ws.freeze_panes = "C3" qs = queryset.select_related( "hospital", "department", "assigned_to", "created_by", "outgoing_department", "responded_by", "contacted_nr_by", "under_process_by", "contacted_by", "source", ).order_by("created_at") first_half_end = 15 row_num = 3 first_half_start_row = 3 second_half_start_row = None idx = 0 for inq in qs: idx += 1 week_of_month = (inq.created_at.day - 1) // first_half_end + 1 is_first_half = inq.created_at.day <= first_half_end if is_first_half: week_label = "1st half of month, 1 to 15" else: week_label = "2nd half of month, 16 to end" if second_half_start_row is None: second_half_start_row = row_num dept_name = inq.source.name_ar if inq.source else "" emp_id = inq.created_by.employee_id if inq.created_by and inq.created_by.employee_id else "" timeline = inq.get_timeline_sla_display() if inq.timeline_sla else "" if not timeline and 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" row_data = [None] * 27 row_data[0] = week_label row_data[1] = idx row_data[2] = _strip_tz(inq.created_at) row_data[3] = inq.contact_name or "" row_data[4] = inq.contact_phone or "" row_data[5] = dept_name row_data[6] = emp_id row_data[7] = timeline row_data[8] = _fmt_date(inq.created_at) row_data[9] = _fmt_time(inq.created_at) if inq.contacted_nr_at: row_data[10] = _fmt_date(inq.contacted_nr_at) row_data[11] = inq.contacted_nr_time or _fmt_time(inq.contacted_nr_at) row_data[12] = inq.contacted_nr_by.get_full_name() if inq.contacted_nr_by else "" row_data[13] = _fmt_duration(inq.contacted_nr_duration) if inq.under_process_at: row_data[14] = _fmt_date(inq.under_process_at) row_data[15] = inq.under_process_time or _fmt_time(inq.under_process_at) row_data[16] = inq.under_process_by.get_full_name() if inq.under_process_by else "" row_data[17] = _fmt_duration(inq.under_process_duration) if inq.contacted_at: row_data[18] = _fmt_date(inq.contacted_at) row_data[19] = inq.contacted_time or _fmt_time(inq.contacted_at) row_data[20] = inq.contacted_by.get_full_name() if inq.contacted_by else "" row_data[21] = _fmt_duration(inq.contacted_duration) row_data[22] = None row_data[23] = inq.subject status_ar = { "open": "جديد", "in_progress": "تحت الإجراء", "resolved": "تم التواصل", "closed": "تم التواصل", "contacted": "تم التواصل", "contacted_no_response": "تم التواصل ولم يتم الرد", } row_data[24] = status_ar.get(inq.status, inq.get_status_display()) row_data[25] = inq.staff_notes row_data[26] = inq.supervisor_notes for col_idx, val in enumerate(row_data): c = ws.cell(row=row_num, column=col_idx + 1, value=val) c.font = data_font c.border = border if col_idx in (2, 8): c.number_format = "YYYY-MM-DD" elif col_idx in (9, 11, 15, 19): c.number_format = "HH:MM" elif col_idx in (10, 14, 18): c.number_format = "YYYY-MM-DD" row_num += 1 last_data_row = row_num - 1 if first_half_start_row and first_half_start_row <= last_data_row: first_half_end_row = (second_half_start_row or (last_data_row + 1)) - 1 if first_half_start_row < first_half_end_row: ws.merge_cells( start_row=first_half_start_row, start_column=1, end_row=first_half_end_row, end_column=1, ) if second_half_start_row and second_half_start_row <= last_data_row: if second_half_start_row < last_data_row: ws.merge_cells( start_row=second_half_start_row, start_column=1, end_row=last_data_row, end_column=1, ) _set_incoming_widths(ws) ws.sheet_view.rightToLeft = True def _set_incoming_widths(ws): widths = { "A": 9.13, "B": 8.75, "C": 15.13, "D": 24.25, "E": 20.38, "F": 11.38, "G": 13.38, "H": 16.63, "I": 26.5, "J": 16.63, "K": 23.0, "L": 20.38, "M": 20.63, "N": 10.75, "O": 23.25, "P": 16.88, "Q": 26.5, "R": 15.38, "S": 23.5, "T": 16.88, "U": 18.63, "V": 18.25, "W": 0.25, "X": 25.5, "Y": 22.88, "Z": 57.88, "AA": 14.75, } for col_letter, w in widths.items(): ws.column_dimensions[col_letter].width = w def _build_outgoing_sheet( ws, queryset, year, month, center, right_center, wrap_center, portal_fill, portal_font, timeline_fill, timeline_font, pdf_fill, pdf_font, opinion_fill, opinion_font, r2_fill, r2_font, border, data_font, ): ws.merge_cells("A1:B1") ws.merge_cells("C1:G1") ws.merge_cells("H1:U1") ws.merge_cells("V1:W1") ws.merge_cells("X1:Y1") for col, val, fill, font in [ (3, "Portal", portal_fill, portal_font), (8, "Time Line", timeline_fill, timeline_font), (22, "PDF", pdf_fill, pdf_font), (24, "رأيك الشخصي", opinion_fill, opinion_font), ]: c = ws.cell(row=1, column=col, value=val) c.font = font c.fill = fill c.alignment = center ws.merge_cells("J2:M2") ws.merge_cells("N2:Q2") ws.merge_cells("R2:U2") r2_headers = { 1: "week", 2: "No.", 3: "التاريخ", 4: "اسم المراجع", 5: "رقم الجوال", 6: "القسم المرسل اليه", 7: "time line", 8: "تاريخ الاستفسار", 9: "وقت الاستفسار", 10: "تم التواصل ولم يتم الرد", 14: "تحت الإجراء", 18: "تم التواصل ", 22: "الاستفسار (من الPDF)", 23: "حالة الاستفسار", 24: "ملاحظات الموظف", 25: "ملاحظات المشرف", } for col, val in r2_headers.items(): c = ws.cell(row=2, column=col, value=val) c.font = r2_font c.fill = r2_fill c.alignment = wrap_center c.border = border for col in range(1, 26): ws.cell(row=2, column=col).border = border if col not in r2_headers: c = ws.cell(row=2, column=col) c.fill = r2_fill c.border = border ws.freeze_panes = "C3" qs = queryset.select_related( "hospital", "department", "assigned_to", "created_by", "outgoing_department", "responded_by", "contacted_nr_by", "under_process_by", "contacted_by", "source", ).order_by("created_at") first_half_end = 15 row_num = 3 first_half_start_row = 3 second_half_start_row = None idx = 0 for inq in qs: idx += 1 is_first_half = inq.created_at.day <= first_half_end if is_first_half: week_label = "1st Half of the month, From 1 to 15" else: week_label = "2nd Half of the month, From 16 to end" if second_half_start_row is None: second_half_start_row = row_num dept_name = inq.outgoing_department.name if inq.outgoing_department else "" timeline = inq.get_timeline_sla_display() if inq.timeline_sla else "" if not timeline and 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" row_data = [None] * 25 row_data[0] = week_label row_data[1] = idx row_data[2] = _strip_tz(inq.created_at) row_data[3] = inq.contact_name or "" row_data[4] = inq.contact_phone or "" row_data[5] = dept_name row_data[6] = timeline row_data[7] = _fmt_date(inq.created_at) row_data[8] = _fmt_time(inq.created_at) if inq.contacted_nr_at: row_data[9] = _fmt_date(inq.contacted_nr_at) row_data[10] = inq.contacted_nr_time or _fmt_time(inq.contacted_nr_at) row_data[11] = inq.contacted_nr_by.get_full_name() if inq.contacted_nr_by else "" row_data[12] = _fmt_duration(inq.contacted_nr_duration) if inq.under_process_at: row_data[13] = _fmt_date(inq.under_process_at) row_data[14] = inq.under_process_time or _fmt_time(inq.under_process_at) row_data[15] = inq.under_process_by.get_full_name() if inq.under_process_by else "" row_data[16] = _fmt_duration(inq.under_process_duration) if inq.contacted_at: row_data[17] = _fmt_date(inq.contacted_at) row_data[18] = inq.contacted_time or _fmt_time(inq.contacted_at) row_data[19] = inq.contacted_by.get_full_name() if inq.contacted_by else "" row_data[20] = _fmt_duration(inq.contacted_duration) row_data[21] = inq.subject status_ar = { "open": "جديد", "in_progress": "تحت الإجراء", "resolved": "تم التواصل", "closed": "تم التواصل", "contacted": "تم التواصل", "contacted_no_response": "تم التواصل ولم يتم الرد", } row_data[22] = status_ar.get(inq.status, inq.get_status_display()) row_data[23] = inq.staff_notes row_data[24] = inq.supervisor_notes for col_idx, val in enumerate(row_data): c = ws.cell(row=row_num, column=col_idx + 1, value=val) c.font = data_font c.border = border if col_idx == 2: c.number_format = "YYYY-MM-DD" elif col_idx in (8, 10, 14, 18): c.number_format = "HH:MM" elif col_idx in (7, 9, 13, 17): c.number_format = "YYYY-MM-DD" row_num += 1 last_data_row = row_num - 1 if first_half_start_row and first_half_start_row <= last_data_row: first_half_end_row = (second_half_start_row or (last_data_row + 1)) - 1 if first_half_start_row < first_half_end_row: ws.merge_cells( start_row=first_half_start_row, start_column=1, end_row=first_half_end_row, end_column=1, ) if second_half_start_row and second_half_start_row <= last_data_row: if second_half_start_row < last_data_row: ws.merge_cells( start_row=second_half_start_row, start_column=1, end_row=last_data_row, end_column=1, ) _set_outgoing_widths(ws) ws.sheet_view.rightToLeft = True def _set_outgoing_widths(ws): widths = { "A": 9.13, "B": 8.75, "C": 15.13, "D": 24.25, "E": 20.38, "F": 30.0, "G": 16.63, "H": 26.5, "I": 16.63, "J": 23.0, "K": 20.38, "L": 20.63, "M": 10.75, "N": 23.25, "O": 16.88, "P": 26.5, "Q": 15.38, "R": 23.5, "S": 16.88, "T": 18.63, "U": 18.25, "V": 25.5, "W": 22.88, "X": 57.88, "Y": 14.75, } for col_letter, w in widths.items(): ws.column_dimensions[col_letter].width = w def _get_employee_names(queryset): creator_ids = queryset.values_list("created_by_id", flat=True).distinct() from apps.accounts.models import User users = User.objects.filter(id__in=[uid for uid in creator_ids if uid]) return [(u.get_full_name(), u.employee_id) for u in users if u.get_full_name()] def _build_incoming_calculations(wb, queryset, year, month): ws = wb.create_sheet("Calculations tamplete") BOLD = Font(bold=True) CENTER = Alignment(horizontal="center", vertical="center") ws.cell(row=3, column=1, value="1st Half").font = BOLD ws.cell(row=4, column=1, value="2nd Half").font = BOLD ws.cell(row=5, column=1, value="TOTAL").font = BOLD headers = {4: "Employees", 5: "تم التواصل ولم يتم الرد", 6: "تحت الاجراء", 7: "تم التواصل", 8: "مجموع الاستفسارات للموظف"} for col, val in headers.items(): ws.cell(row=3, column=col, value=val).font = BOLD employees = _get_employee_names(queryset) for i, (name, _eid) in enumerate(employees): row = 4 + i ws.cell(row=row, column=4, value=name) for col in (5, 6, 7, 8): ws.cell(row=row, column=col, value=0) emp_count = len(employees) total_row = 4 + emp_count ws.cell(row=total_row, column=4, value="Total").font = BOLD for col in (5, 6, 7, 8): if emp_count > 0: first_data_row = 4 last_data_row = 4 + emp_count - 1 col_letter = get_column_letter(col) ws.cell(row=total_row, column=col, value=f"=SUM({col_letter}{first_data_row}:{col_letter}{last_data_row})") pct_row = total_row + 1 for col in (5, 6, 7, 8): col_letter = get_column_letter(col) ws.cell(row=pct_row, column=col, value=f"=IF({col_letter}{total_row}=0,0,{col_letter}{total_row}/B{5})") ws.cell(row=pct_row, column=col).number_format = "0.00%" ws.cell(row=3, column=11, value="Number of incoming inquiries").font = BOLD for col, val in {12: "24 Hours", 13: "48 Hours", 14: "72 Hours", 15: "More than 72 hours"}.items(): ws.cell(row=3, column=col, value=val).font = BOLD ws.cell(row=4, column=10, value="Total ").font = BOLD ws.cell(row=4, column=11, value=0) ws.cell(row=4, column=11, value=f"=SUM({get_column_letter(12)}4:{get_column_letter(15)}4)") ws.cell(row=5, column=10, value="Percentage ").font = BOLD for col in range(11, 16): col_letter = get_column_letter(col) ws.cell(row=5, column=col, value=f"=IF({col_letter}4=0,0,{col_letter}4/K4)") ws.cell(row=5, column=col).number_format = "0.00%" status_labels = {18: "تم التواصل ولم يتم الرد", 19: "تحت الاجراء ", 20: "تم التواصل ", 21: "جديد", 22: "مجموع الاستفسارات خلال الشهر "} for col, val in status_labels.items(): ws.cell(row=3, column=col, value=val).font = BOLD ws.cell(row=4, column=17, value="Total ").font = BOLD for col in range(18, 23): ws.cell(row=4, column=col, value=0) ws.cell(row=5, column=17, value="Percentage ").font = BOLD for col in range(18, 22): col_letter = get_column_letter(col) ws.cell(row=5, column=col, value=f"=IF({col_letter}4=0,0,{col_letter}4/V4)") ws.cell(row=5, column=col).number_format = "0.00%" ws.cell(row=5, column=22, value=f"=SUM({get_column_letter(18)}5:{get_column_letter(21)}5)") ws.cell(row=5, column=22).number_format = "0.00%" def _build_outgoing_calculations(wb, queryset, year, month): ws = wb.create_sheet("Calculations tamplete") BOLD = Font(bold=True) ws.cell(row=6, column=1, value="1st Half").font = BOLD ws.cell(row=7, column=1, value="2nd Half").font = BOLD ws.cell(row=8, column=1, value="TOTAL").font = BOLD ws.cell(row=6, column=4, value="Department").font = BOLD ws.cell(row=6, column=5, value="Status").font = BOLD ws.cell(row=6, column=8, value="Percentage").font = BOLD ws.cell(row=6, column=11, value="Total Percentage").font = BOLD ws.cell(row=6, column=12, value="Total").font = BOLD status_headers = {5: "تحت الإجراء ", 6: "تم التواصل ولم يتم الرد", 7: "تم التواصل"} for col, val in status_headers.items(): ws.cell(row=7, column=col, value=val).font = BOLD pct_headers = {8: "تحت الإجراء ", 9: "تم التواصل ولم يتم الرد", 10: "تم التواصل"} for col, val in pct_headers.items(): ws.cell(row=7, column=col, value=val).font = BOLD outgoing_depts = queryset.values_list( "outgoing_department__name", flat=True ).distinct().order_by("outgoing_department__name") dept_names = [d for d in outgoing_depts if d] for i, dept in enumerate(dept_names): row = 8 + i ws.cell(row=row, column=4, value=dept) for col in (5, 6, 7, 12): ws.cell(row=row, column=col, value=0) for col in (8, 9, 10, 11): ws.cell(row=row, column=col, value="#DIV/0!") total_row = 8 + len(dept_names) ws.cell(row=total_row, column=4, value="Total").font = BOLD for col in (5, 6, 7): col_letter = get_column_letter(col) ws.cell(row=total_row, column=col, value=f"=SUM({col_letter}8:{col_letter}{total_row - 1})") ws.cell(row=7, column=14, value="Total ").font = BOLD ws.cell(row=8, column=14, value="Percentage ").font = BOLD ws.cell(row=6, column=15, value="Number of Outgoing inquiries").font = BOLD for col, val in {16: "24 Hours", 17: "48 Hours", 18: "72 Hours", 19: "More than 72 hours"}.items(): ws.cell(row=6, column=col, value=val).font = BOLD ws.cell(row=7, column=15, value=0) ws.cell(row=8, column=15, value=f"=IF(O7=0,0,O7/O7)") status_labels = {22: "تم التواصل ولم يتم الرد", 23: "تحت الاجراء ", 24: "تم التواصل ", 25: "جديد", 26: "مجموع الاستفسارات الصادرة خلال الشهر "} for col, val in status_labels.items(): ws.cell(row=6, column=col, value=val).font = BOLD ws.cell(row=7, column=21, value="Total ").font = BOLD ws.cell(row=8, column=21, value="Percentage ").font = BOLD def _build_incoming_dropdown(wb, queryset): ws = wb.create_sheet("Drop-down") timeline_options = ["24 Hours", "48 Hours", "72 Hours", "More than 72 hours"] for i, opt in enumerate(timeline_options): ws.cell(row=i + 1, column=1, value=opt) ws.cell(row=1, column=2, value="More than 72 hours") employees = _get_employee_names(queryset) for i, (name, eid) in enumerate(employees): ws.cell(row=7 + i, column=1, value=eid or "") ws.cell(row=7 + i, column=2, value=name) status_options = ["تم التواصل", "تحت الإجراء ", "تم التواصل ولم يتم الرد"] for i, opt in enumerate(status_options): ws.cell(row=17 + i, column=1, value=opt) def _build_outgoing_dropdown(wb, queryset): ws = wb.create_sheet("Drop-down") timeline_options = ["24 Hours", "48 Hours", "72 Hours", "More than 72 hours"] for i, opt in enumerate(timeline_options): ws.cell(row=i + 1, column=1, value=opt) ws.cell(row=1, column=2, value="More than 72 hours") employees = _get_employee_names(queryset) for i, (name, _eid) in enumerate(employees): ws.cell(row=7 + i, column=1, value=name) status_options = ["تم التواصل ", "تحت الإجراء ", "تم التواصل ولم يتم الرد"] for i, opt in enumerate(status_options): ws.cell(row=16 + i, column=1, value=opt) 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 def export_historical_excel(queryset, date_start=None, date_end=None): """ Export complaints to historical Excel format with Arabic headers. Creates a single sheet with all 105 columns matching the historical complaint report format used in the original Excel files. Args: queryset: Complaint queryset to export date_start: Optional start date filter date_end: Optional end date filter Returns: HttpResponse with Excel file """ wb = Workbook() ws = wb.active # Set RTL direction for Arabic ws.sheet_view.rightToLeft = True # Define styles 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) thin_border = Border( left=Side(style="thin"), right=Side(style="thin"), top=Side(style="thin"), bottom=Side(style="thin") ) # All 105 headers in Arabic (matching historical Excel format) headers = [ "Week", # 1 "No.", # 2 "رقم الشكوى", # 3 - Complaint Number "رقم الملف", # 4 - File Number (MRN) "جهة الشكوى", # 5 - Complaint Source "الموقع", # 6 - Location "القسم الرئيس", # 7 - Main Department "القسم الفرعي", # 8 - Sub Department "تاريخ إستلام الشكوى", # 9 - Date Received "المدخل", # 10 - Entered By "Time line", # 11 "إرسال نموذج الشكوى", # 12 - Form Sent "الوقت", # 13 - Time (Form Sent) "الموظف", # 14 - Employee "تحرير الشكوى", # 15 - Complaint Filed "الوقت", # 16 - Time (Complaint Filed) "تفعيل الشكوى", # 17 - Activated "الوقت", # 18 - Time (Activated) "الموظف .1", # 19 - Employee "تم ارسال الشكوى", # 20 - Sent "الوقت", # 21 - Time (Sent) "الموظف .2", # 22 - Employee "الوقت بين التحرير والارسال", # 23 - Time: Filed to Sent "First Reminder Sent", # 24 "الوقت", # 25 - Time (First Reminder) "الموظف .3", # 26 - Employee "الوقت بين اول ايميل والارسال", # 27 - Time: 1st Rem to Sent "Second Reminder Sent", # 28 "الوقت", # 29 - Time (Second Reminder) "الموظف .4", # 30 - Employee "الوقت بين ثاني ايميل والاول", # 31 - Time: 2nd Rem to 1st "Escalated", # 32 "الوقت", # 33 - Time (Escalation) "الموظف .5", # 34 - Employee "Reason of Escalation ", # 35 "الوقت بين التصعيد والارسال", # 36 - Time: Escalation to Sent "Closed", # 37 "الوقت", # 38 - Time (Closure) "الموظف .6", # 39 - Employee "الوقت بين الاغلاق والارسال", # 40 - Time: Close to Sent "تاريخ الرد", # 41 - Response Date "الوقت", # 42 - Time (Response) "الوقت بين الرد والارسال", # 43 - Time: Response to Sent "Resolved", # 44 "الوقت", # 45 - Time (Resolution) "الموظف .7", # 46 - Employee "الوقت بين المعالجة و الارسال", # 47 - Time: Resolve to Sent "ID", # 48 "اسم الشخص المشتكى عليه - ان وجد", # 49 - Accused Staff Name "Domain", # 50 "Category", # 51 "Sub-Category", # 52 "Classification", # 53 "محتوى الشكوى (عربي)", # 54 - Content Arabic "محتوى الشكوى (English)", # 55 - Content English "Satisfied/Dissatisfied", # 56 "The Rightful Side", # 57 "Recommendation/Action plan", # 58 "رقم الشكوى.1", # 59 - Duplicate columns (for formulas) "رقم الملف.1", # 60 "جهة الشكوى.1", # 61 "الموقع.1", # 62 "القسم الرئيس.1", # 63 "القسم الفرعي.1", # 64 "تاريخ إستلام الشكوى.1", # 65 "المدخل.1", # 66 "Time line.1", # 67 "الوقت", # 68 - Time (Duplicate) "رقم الشكوى.2", # 69 "رقم الملف.2", # 70 "جهة الشكوى.2", # 71 "الموقع.2", # 72 "القسم الرئيس.2", # 73 "القسم الفرعي.2", # 74 "تاريخ إستلام الشكوى.2", # 75 "المدخل.2", # 76 "Time line.2", # 77 "الوقت", # 78 - Time (Duplicate) "رقم الشكوى.3", # 79 "رقم الملف.3", # 80 "جهة الشكوى.3", # 81 "الموقع.3", # 82 "القسم الرئيس.3", # 83 "القسم الفرعي.3", # 84 "تاريخ إستلام الشكوى.3", # 85 "المدخل.3", # 86 "Time line.3", # 87 "الوقت", # 88 - Time (Duplicate) "رقم الشكوى.4", # 89 "رقم الملف.4", # 90 "جهة الشكوى.4", # 91 "الموقع.4", # 92 "القسم الرئيس.4", # 93 "القسم الفرعي.4", # 94 "تاريخ إستلام الشكوى.4", # 95 "المدخل.4", # 96 "Time line.4", # 97 ] # Write headers 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 cell.border = thin_border # Freeze top row ws.freeze_panes = "A2" # Prepare queryset with related fields qs = queryset.select_related( "patient", "hospital", "department", "main_section", "subsection", "assigned_to", "resolved_by", "closed_by", "created_by", "source", "domain", "category", "subcategory_obj", "classification_obj", ).prefetch_related("updates") row_num = 2 for idx, c in enumerate(qs, 1): # Get week number from created_at cal = c.created_at week_of_month = (cal.day - 1) // 7 + 1 if cal else "" # Helper to get name with fallback def get_name(obj, attr="name"): if not obj: return "" if hasattr(obj, attr): val = getattr(obj, attr) if hasattr(val, "name_ar"): return val.name_ar elif hasattr(val, "name"): return val.name return str(val) return "" def get_name_ar(obj): if not obj: return "" if hasattr(obj, "name_ar"): return obj.name_ar elif hasattr(obj, "name"): return obj.name return str(obj) def time_diff(dt1, dt2): if dt1 and dt2: delta = dt1 - dt2 return str(delta) return "" # Source name source_name = "" if c.source: source_name = c.source.name_ar or c.source.name_en or str(c.source) # Location (main_section or location) location_name = "" if c.main_section: location_name = get_name_ar(c.main_section) elif c.location: location_name = get_name_ar(c.location) # Departments main_dept = "" if c.main_section: main_dept = get_name_ar(c.main_section) elif c.department: main_dept = get_name_ar(c.department) sub_dept = "" if c.subsection: sub_dept = get_name_ar(c.subsection) # Entered by (assigned_to or created_by) entered_by = "" if c.assigned_to: entered_by = c.assigned_to.get_full_name() or c.assigned_to.username elif c.created_by: entered_by = c.created_by.get_full_name() or c.created_by.username # Taxonomy fields domain_name = "" if c.domain: domain_name = c.domain.name_ar or c.domain.name_en or str(c.domain) category_name = "" if c.category: category_name = c.category.name_ar or c.category.name_en or str(c.category) subcategory_name = "" if c.subcategory_obj: subcategory_name = c.subcategory_obj.name_ar or c.subcategory_obj.name_en or str(c.subcategory_obj) classification_name = "" if c.classification_obj: classification_name = ( c.classification_obj.name_ar or c.classification_obj.name_en or str(c.classification_obj) ) # Description - use as is (not splitting) description = c.description or "" # Satisfaction satisfaction = "" if c.satisfaction: satisfaction = "Satisfied" if c.satisfaction == "satisfied" else "Dissatisfied" # Rightful side rightful_side = "" if c.resolution_outcome: rightful_side = c.get_resolution_outcome_display() or c.resolution_outcome # Staff name staff_name = c.staff_name or "" if not staff_name and c.staff: staff_name = c.staff.get_full_name() or str(c.staff) # Reference number components ref_parts = c.reference_number.split("-") if c.reference_number else [] complaint_num = ref_parts[-1] if len(ref_parts) >= 4 else "" # Time difference calculations time_filed_to_sent = time_diff(c.forwarded_to_dept_at, c.created_at) time_rem1_to_sent = time_diff(c.forwarded_to_dept_at, c.reminder_sent_at) if c.reminder_sent_at and c.forwarded_to_dept_at else "" time_rem2_to_rem1 = time_diff(c.second_reminder_sent_at, c.reminder_sent_at) if c.second_reminder_sent_at and c.reminder_sent_at else "" time_escal_to_sent = time_diff(c.forwarded_to_dept_at, c.escalated_at) if c.escalated_at and c.forwarded_to_dept_at else "" time_close_to_sent = time_diff(c.resolved_at, c.closed_at) if c.resolved_at and c.closed_at else "" time_response_to_sent = time_diff(c.response_date, c.resolved_at) if c.response_date and c.resolved_at else "" time_resolve_to_sent = time_diff(c.resolution_sent_at, c.resolved_at) if c.resolution_sent_at and c.resolved_at else "" # Build row data (only first ~60 columns have meaningful data) row_data = [""] * 97 row_data[0] = week_of_month # Week row_data[1] = idx # No. row_data[2] = complaint_num # رقم الشكوى row_data[3] = c.patient.mrn if c.patient else "" # رقم الملف row_data[4] = source_name # جهة الشكوى row_data[5] = location_name # الموقع row_data[6] = main_dept # القسم الرئيس row_data[7] = sub_dept # القسم الفرعي row_data[8] = cal.strftime("%Y-%m-%d %H:%M:%S") if cal else "" # تاريخ إستلام الشكوى row_data[9] = entered_by # المدخل # Time difference calculations (timeline columns) row_data[22] = time_filed_to_sent row_data[26] = time_rem1_to_sent row_data[30] = time_rem2_to_rem1 row_data[35] = time_escal_to_sent row_data[39] = time_close_to_sent row_data[42] = time_response_to_sent row_data[46] = time_resolve_to_sent row_data[47] = c.metadata.get("original_complaint_id") or "" if c.metadata else "" # ID row_data[48] = staff_name # اسم الشخص المشتكى عليه row_data[49] = domain_name # Domain row_data[50] = category_name # Category row_data[51] = subcategory_name # Sub-Category row_data[52] = classification_name # Classification row_data[53] = description # محتوى الشكوى (عربي) - full description row_data[54] = "" # محتوى الشكوى (English) - empty, Arabic column has full content row_data[55] = satisfaction # Satisfied/Dissatisfied row_data[56] = rightful_side # The Rightful Side row_data[57] = c.recommendation_action_plan or "" # Recommendation/Action plan # Duplicate columns (67-105) - formulas reference first set, keep empty # Write row 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 # Auto-adjust column widths for main columns for col_num in range(1, 60): col_letter = get_column_letter(col_num) if col_num in [3, 4, 5, 6, 7, 8]: # Arabic text columns ws.column_dimensions[col_letter].width = 25 elif col_num in [54, 55]: # Description columns ws.column_dimensions[col_letter].width = 50 elif col_num == 49: # Staff name ws.column_dimensions[col_letter].width = 30 else: ws.column_dimensions[col_letter].width = 15 # Hide the duplicate columns (67-105) as they're for formulas for col_num in range(59, 98): col_letter = get_column_letter(col_num) ws.column_dimensions[col_letter].hidden = True # Generate filename if date_start and date_end: sheet_name = f"{date_start}_to_{date_end}" elif date_start: sheet_name = f"from_{date_start}" elif date_end: sheet_name = f"until_{date_end}" else: sheet_name = "all_complaints" ws.title = sheet_name[:31] # Excel sheet name limit filename = f"historical_complaints_{sheet_name}.xlsx" # Save to response response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") response["Content-Disposition"] = f'attachment; filename="{filename}"' wb.save(response) return response