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