221 lines
7.2 KiB
Python
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()
|