HH/apps/complaints/utils.py
ismail c5f76b3855
Some checks are pending
Build and Push Docker Image / build (push) Waiting to run
updates
2026-05-11 14:45:30 +03:00

2691 lines
99 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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