HH/apps/analytics/kpi_pdf_service.py

221 lines
7.2 KiB
Python

"""
Server-side PDF generation service for KPI Reports.
Uses matplotlib for chart rendering and WeasyPrint for HTML-to-PDF conversion.
No browser/JS required — works in Celery tasks and behind firewalls.
"""
import base64
import io
import logging
from datetime import datetime
from django.template.loader import render_to_string
logger = logging.getLogger(__name__)
def _render_trend_chart(monthly_data, target, threshold):
"""
Render the monthly performance trend chart as a base64-encoded PNG.
Args:
monthly_data: list of 12 KPIReportMonthlyData instances (or None)
target: target percentage (float)
threshold: threshold percentage (float)
Returns:
base64-encoded PNG string, or empty string on failure
"""
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
except ImportError:
logger.warning("matplotlib not available — skipping trend chart")
return ""
months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
values = []
for m in monthly_data:
if m and m.percentage is not None:
values.append(float(m.percentage))
else:
values.append(None)
fig, ax = plt.subplots(figsize=(14, 6.5))
fig.patch.set_facecolor("white")
non_none = [(i, v) for i, v in enumerate(values) if v is not None]
if non_none:
x_indices = [i for i, _ in non_none]
y_values = [v for _, v in non_none]
ax.plot(range(12), values, color="#005696", linewidth=2, marker="o", markersize=5, zorder=3)
ax.scatter(x_indices, y_values, color="#005696", s=40, zorder=4)
below = [(i, v) for i, v in non_none if v < threshold]
if below:
bx = [i for i, _ in below]
by = [v for _, v in below]
ax.scatter(bx, by, color="#dc2626", s=60, zorder=5, edgecolors="white", linewidths=1)
ax.axhline(y=target, color="#16a34a", linestyle="--", linewidth=1.2, alpha=0.7, label=f"Target ({target:.0f}%)")
ax.axhline(y=threshold, color="#dc2626", linestyle="--", linewidth=1.2, alpha=0.7, label=f"Threshold ({threshold:.0f}%)")
ax.set_ylim(0, 105)
ax.set_xticks(range(12))
ax.set_xticklabels(months, fontsize=8)
ax.set_ylabel("Percentage (%)", fontsize=9)
ax.set_title("Monthly Performance Trend", fontsize=11, fontweight="bold", color="#005696", pad=12)
ax.legend(loc="lower right", fontsize=7, framealpha=0.9)
ax.grid(True, axis="y", alpha=0.3, linestyle="-")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
plt.tight_layout()
buf = io.BytesIO()
plt.savefig(buf, format="png", dpi=200, bbox_inches="tight")
plt.close()
buf.seek(0)
return base64.b64encode(buf.getvalue()).decode("utf-8")
def _render_source_chart(source_breakdowns):
"""
Render the complaints-by-source donut chart as a base64-encoded PNG.
Args:
source_breakdowns: QuerySet of KPIReportSourceBreakdown
Returns:
base64-encoded PNG string, or empty string on failure
"""
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
except ImportError:
logger.warning("matplotlib not available — skipping source chart")
return ""
sources = list(source_breakdowns)
if not sources:
return ""
labels = [s.source_name for s in sources]
sizes = [float(s.percentage) for s in sources]
counts = [s.complaint_count for s in sources]
colors = ["#005696", "#007bbd", "#3b82f6", "#f59e0b", "#10b981", "#8b5cf6", "#ef4444", "#6b7280"]
fig, ax = plt.subplots(figsize=(8, 7))
fig.patch.set_facecolor("white")
wedges, texts, autotexts = ax.pie(
sizes,
labels=None,
autopct=lambda pct: f"{pct:.0f}%" if pct > 5 else "",
colors=colors[: len(sources)],
startangle=90,
pctdistance=0.80,
wedgeprops=dict(width=0.4, edgecolor="white", linewidth=2),
)
for autotext in autotexts:
autotext.set_color("white")
autotext.set_fontsize(8)
autotext.set_fontweight("bold")
total_count = sum(counts)
ax.text(0, 0.05, f"{total_count}", ha="center", va="center", fontsize=20, fontweight="bold", color="#005696")
ax.text(0, -0.15, "Total", ha="center", va="center", fontsize=8, color="#64748b")
ax.legend(
wedges,
[f"{l} ({c})" for l, c in zip(labels, counts)],
loc="center left",
bbox_to_anchor=(1, 0.5),
fontsize=8,
frameon=False,
)
ax.set_title("Complaints by Source", fontsize=11, fontweight="bold", color="#005696", pad=12)
plt.tight_layout()
buf = io.BytesIO()
plt.savefig(buf, format="png", dpi=200, bbox_inches="tight")
plt.close()
buf.seek(0)
return base64.b64encode(buf.getvalue()).decode("utf-8")
def _get_logo_base64():
"""
Load the hospital logo, thumbnail it, and return as a base64 data URI.
Returns:
base64 data URI string, or empty string on failure
"""
try:
import io
from django.conf import settings
from PIL import Image as PILImage
logo_path = settings.BASE_DIR / "static" / "img" / "hh-logo.png"
logo_img = PILImage.open(logo_path)
logo_img.thumbnail((500, 500), PILImage.LANCZOS)
buf = io.BytesIO()
logo_img.save(buf, format="PNG", optimize=True)
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("utf-8")
except Exception as e:
logger.warning(f"Could not load logo for KPI PDF: {e}")
return ""
def generate_kpi_report_pdf(report):
"""
Generate a PDF for a KPI Report using WeasyPrint.
Args:
report: KPIReport instance with related data loaded
Returns:
PDF file contents as bytes
"""
from weasyprint import HTML
monthly_data_qs = report.monthly_data.filter(month__gt=0).order_by("month")
total_data = report.monthly_data.filter(month=0).first()
monthly_data_dict = {m.month: m for m in monthly_data_qs}
monthly_data = [monthly_data_dict.get(i) for i in range(1, 13)]
source_breakdowns = report.source_breakdowns.all()
department_breakdowns = report.department_breakdowns.all()
location_breakdowns = report.location_breakdowns.all()
target = float(report.target_percentage) if report.target_percentage else 95.0
threshold = float(report.threshold_percentage) if report.threshold_percentage else 90.0
trend_chart_b64 = _render_trend_chart(monthly_data, target, threshold)
source_chart_b64 = _render_source_chart(source_breakdowns)
ai_analysis = report.ai_analysis or {}
context = {
"report": report,
"monthly_data": monthly_data,
"total_data": total_data,
"source_breakdowns": source_breakdowns,
"department_breakdowns": department_breakdowns,
"location_breakdowns": location_breakdowns,
"trend_chart": trend_chart_b64,
"source_chart": source_chart_b64,
"ai_analysis": ai_analysis,
"logo_path": _get_logo_base64(),
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M"),
}
html_string = render_to_string("analytics/kpi_report_weasyprint.html", context)
return HTML(string=html_string).write_pdf()