Files
qlockify-backend-deployment/apps/reports/services/exporters.py

446 lines
18 KiB
Python

from __future__ import annotations
import io
import os
from datetime import datetime
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import Image as RLImage
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
from apps.reports.services.export_i18n import ExportLocale, safe_sheet_title, user_label
HEADER_FILL = PatternFill("solid", fgColor="E7F2FF")
SECTION_FILL = PatternFill("solid", fgColor="F8FAFC")
BORDER = Border(
left=Side(style="thin", color="D0D7DE"),
right=Side(style="thin", color="D0D7DE"),
top=Side(style="thin", color="D0D7DE"),
bottom=Side(style="thin", color="D0D7DE"),
)
def _register_pdf_fonts(locale: ExportLocale) -> None:
registered = set(pdfmetrics.getRegisteredFontNames())
if "Vazirmatn" not in registered:
pdfmetrics.registerFont(TTFont("Vazirmatn", locale.font_regular))
if "Vazirmatn-Bold" not in registered:
pdfmetrics.registerFont(TTFont("Vazirmatn-Bold", locale.font_bold))
def _apply_cell_style(cell, *, bold: bool = False, fill=None, rtl: bool = False) -> None:
cell.font = Font(name="Calibri", bold=bold, size=11)
cell.border = BORDER
cell.alignment = Alignment(horizontal="right" if rtl else "left", vertical="center")
if fill is not None:
cell.fill = fill
def _autosize_columns(worksheet) -> None:
widths: dict[int, int] = {}
for row in worksheet.iter_rows():
for cell in row:
if cell.value is None:
continue
widths[cell.column] = max(widths.get(cell.column, 0), len(str(cell.value)))
for column_index, width in widths.items():
worksheet.column_dimensions[get_column_letter(column_index)].width = min(max(width + 4, 12), 30)
def _money_label(locale: ExportLocale, income_totals: list[dict]) -> str:
return locale.format_money_label(income_totals)
def _money_label_excel(locale: ExportLocale, income_totals: list[dict]) -> str:
return locale.format_money_label(income_totals, ascii_digits=True)
def _section_headers(locale: ExportLocale) -> list[str]:
headers = [
locale.t("name"),
locale.t("billable_hours"),
locale.t("non_billable_hours"),
locale.t("total_hours"),
locale.t("income"),
]
return list(reversed(headers)) if locale.is_rtl else headers
def _rtl_row(locale: ExportLocale, row: list[str]) -> list[str]:
return list(reversed(row)) if locale.is_rtl else row
def _append_meta_block(worksheet, *, locale: ExportLocale, report_data: dict) -> None:
scope = report_data["scope"]
summary = report_data["summary"]
worksheet.append(_rtl_row(locale, [locale.t("report_title"), scope["workspace"]["name"]]))
worksheet.append(_rtl_row(locale, [locale.t("workspace"), scope["workspace"]["name"]]))
worksheet.append(_rtl_row(locale, [locale.t("period"), locale.period_label(scope["period"])]))
worksheet.append(_rtl_row(locale, [locale.t("from_date"), locale.format_date(scope["from_date"], ascii_digits=True)]))
worksheet.append(_rtl_row(locale, [locale.t("to_date"), locale.format_date(scope["to_date"], ascii_digits=True)]))
worksheet.append(_rtl_row(locale, [locale.t("user"), user_label(scope.get("user"), locale, ascii_digits=True)]))
worksheet.append(_rtl_row(locale, [locale.t("generated_at"), locale.format_date(datetime.now().date(), ascii_digits=True)]))
worksheet.append([])
worksheet.append([locale.t("summary")])
worksheet.append(_rtl_row(locale, [locale.t("total_hours"), locale.format_duration(summary["total_duration"], ascii_digits=True)]))
worksheet.append(_rtl_row(locale, [locale.t("billable_hours"), locale.format_duration(summary["billable_duration"], ascii_digits=True)]))
worksheet.append(_rtl_row(locale, [locale.t("non_billable_hours"), locale.format_duration(summary["non_billable_duration"], ascii_digits=True)]))
worksheet.append(_rtl_row(locale, [locale.t("income"), _money_label_excel(locale, summary["income_totals"])]))
for row_index in range(1, worksheet.max_row + 1):
first_cell = worksheet.cell(row=row_index, column=1)
second_cell = worksheet.cell(row=row_index, column=2)
if row_index in {1, 9}:
_apply_cell_style(first_cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl)
if second_cell.value:
_apply_cell_style(second_cell, bold=row_index == 1, fill=HEADER_FILL if row_index == 1 else None, rtl=locale.is_rtl)
elif first_cell.value:
_apply_cell_style(first_cell, bold=False, fill=SECTION_FILL if row_index == 8 else None, rtl=locale.is_rtl)
if second_cell.value:
_apply_cell_style(second_cell, rtl=locale.is_rtl)
def _append_daily_table(worksheet, *, locale: ExportLocale, report_data: dict) -> None:
worksheet.append([])
worksheet.append([locale.t("daily_summary")])
header_row = worksheet.max_row + 1
worksheet.append(
_rtl_row(
locale,
[
locale.t("date"),
locale.t("billable_hours"),
locale.t("non_billable_hours"),
locale.t("total_hours"),
locale.t("income"),
],
)
)
for cell in worksheet[header_row]:
_apply_cell_style(cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl)
if not report_data["days"]:
worksheet.append([locale.t("no_data")])
_apply_cell_style(worksheet.cell(row=worksheet.max_row, column=1), rtl=locale.is_rtl)
return
for row in report_data["days"]:
worksheet.append(
_rtl_row(
locale,
[
locale.format_date(row["date"], ascii_digits=True),
locale.format_duration(row["billable_duration"], ascii_digits=True),
locale.format_duration(row["non_billable_duration"], ascii_digits=True),
locale.format_duration(row["total_duration"], ascii_digits=True),
_money_label_excel(locale, row["income_totals"]),
],
)
)
for cell in worksheet[worksheet.max_row]:
_apply_cell_style(cell, rtl=locale.is_rtl)
worksheet.append(
_rtl_row(
locale,
[
locale.t("total"),
locale.format_duration(report_data["summary"]["billable_duration"], ascii_digits=True),
locale.format_duration(report_data["summary"]["non_billable_duration"], ascii_digits=True),
locale.format_duration(report_data["summary"]["total_duration"], ascii_digits=True),
_money_label_excel(locale, report_data["summary"]["income_totals"]),
],
)
)
for cell in worksheet[worksheet.max_row]:
_apply_cell_style(cell, bold=True, fill=SECTION_FILL, rtl=locale.is_rtl)
def _append_breakdown_table(worksheet, *, locale: ExportLocale, title_key: str, rows: list[dict]) -> None:
worksheet.append([])
worksheet.append([locale.t(title_key)])
header_row = worksheet.max_row + 1
worksheet.append(_section_headers(locale))
for cell in worksheet[header_row]:
_apply_cell_style(cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl)
if not rows:
worksheet.append([locale.t("no_data")])
_apply_cell_style(worksheet.cell(row=worksheet.max_row, column=1), rtl=locale.is_rtl)
return
for row in rows:
worksheet.append(
_rtl_row(
locale,
[
row["name"],
locale.format_duration(row["billable_duration"], ascii_digits=True),
locale.format_duration(row["non_billable_duration"], ascii_digits=True),
locale.format_duration(row["total_duration"], ascii_digits=True),
_money_label_excel(locale, row["income_totals"]),
],
)
)
for cell in worksheet[worksheet.max_row]:
_apply_cell_style(cell, rtl=locale.is_rtl)
def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) -> None:
if locale.is_rtl:
worksheet.sheet_view.rightToLeft = True
worksheet.freeze_panes = "E4"
else:
worksheet.freeze_panes = "A4"
_append_meta_block(worksheet, locale=locale, report_data=report_data)
_append_daily_table(worksheet, locale=locale, report_data=report_data)
_append_breakdown_table(worksheet, locale=locale, title_key="clients", rows=report_data["clients"])
_append_breakdown_table(worksheet, locale=locale, title_key="projects", rows=report_data["projects"])
_append_breakdown_table(worksheet, locale=locale, title_key="tags", rows=report_data["tags"])
_autosize_columns(worksheet)
def build_excel_report(*, report_data: dict, locale: ExportLocale, per_user_reports: list[dict] | None = None) -> bytes:
workbook = Workbook()
overall_sheet = workbook.active
overall_sheet.title = safe_sheet_title(locale.t("overall_sheet"), [])
_render_excel_sheet(overall_sheet, locale=locale, report_data=report_data)
used_titles = {overall_sheet.title}
for user_report in per_user_reports or []:
user_title = safe_sheet_title(user_label(user_report["scope"].get("user"), locale, ascii_digits=True), used_titles)
worksheet = workbook.create_sheet(title=user_title)
_render_excel_sheet(worksheet, locale=locale, report_data=user_report)
used_titles.add(user_title)
buffer = io.BytesIO()
workbook.save(buffer)
return buffer.getvalue()
def _paragraph(text: str, style: ParagraphStyle, locale: ExportLocale) -> Paragraph:
return Paragraph(locale.shape(text), style)
def _workspace_initial(name: str) -> str:
stripped = (name or "").strip()
return stripped[0].upper() if stripped else "W"
def _styled_table(data: list[list[str]], *, locale: ExportLocale, column_widths: list[float]) -> Table:
shaped_data = [
[locale.shape(cell) if cell is not None else "" for cell in row]
for row in data
]
table = Table(shaped_data, colWidths=column_widths, repeatRows=1)
table.setStyle(
TableStyle(
[
("FONTNAME", (0, 0), (-1, 0), "Vazirmatn-Bold" if locale.language == "fa" else "Helvetica-Bold"),
("FONTNAME", (0, 1), (-1, -1), "Vazirmatn" if locale.language == "fa" else "Helvetica"),
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#E7F2FF")),
("TEXTCOLOR", (0, 0), (-1, -1), colors.HexColor("#0F172A")),
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#CBD5E1")),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("ALIGN", (0, 0), (-1, -1), "RIGHT" if locale.is_rtl else "LEFT"),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
]
)
)
return table
def _report_table_rows(locale: ExportLocale, rows: list[dict]) -> list[list[str]]:
if not rows:
return [_rtl_row(locale, [locale.t("no_data"), "", "", "", ""])]
return [
_rtl_row(
locale,
[
locale.format_date(row.get("date")) if row.get("date") else row["name"],
locale.format_duration(row["billable_duration"]),
locale.format_duration(row["non_billable_duration"]),
locale.format_duration(row["total_duration"]),
_money_label(locale, row["income_totals"]),
],
)
for row in rows
]
def build_pdf_report(*, report_data: dict, locale: ExportLocale) -> bytes:
_register_pdf_fonts(locale)
font_regular = "Vazirmatn"
font_bold = "Vazirmatn-Bold"
styles = getSampleStyleSheet()
title_style = ParagraphStyle(
"ReportTitle",
parent=styles["Heading1"],
fontName=font_bold,
fontSize=18,
leading=24,
alignment=2 if locale.is_rtl else 0,
textColor=colors.HexColor("#0F172A"),
)
section_style = ParagraphStyle(
"ReportSection",
parent=styles["Heading3"],
fontName=font_bold,
fontSize=12,
leading=16,
alignment=2 if locale.is_rtl else 0,
textColor=colors.HexColor("#0F172A"),
)
body_style = ParagraphStyle(
"ReportBody",
parent=styles["BodyText"],
fontName=font_regular,
fontSize=10,
leading=14,
alignment=2 if locale.is_rtl else 0,
textColor=colors.HexColor("#334155"),
)
buffer = io.BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=landscape(A4),
leftMargin=14 * mm,
rightMargin=14 * mm,
topMargin=14 * mm,
bottomMargin=14 * mm,
)
scope = report_data["scope"]
summary = report_data["summary"]
workspace_name = scope["workspace"]["name"]
workspace_thumbnail_path = scope["workspace"].get("thumbnail_path")
title_text = f"{locale.t('report_title')} - {workspace_name}"
title_cell = _paragraph(title_text, title_style, locale)
badge_cell = _paragraph(_workspace_initial(workspace_name), title_style, locale)
if workspace_thumbnail_path and os.path.exists(workspace_thumbnail_path):
try:
badge_cell = RLImage(workspace_thumbnail_path, width=14 * mm, height=14 * mm)
except Exception: # noqa: BLE001
badge_cell = _paragraph(_workspace_initial(workspace_name), title_style, locale)
header_row = [badge_cell, title_cell] if locale.is_rtl else [title_cell, badge_cell]
header_col_widths = [doc.width * 0.08, doc.width * 0.92] if locale.is_rtl else [doc.width * 0.92, doc.width * 0.08]
header_table = Table([header_row], colWidths=header_col_widths)
header_table.setStyle(
TableStyle(
[
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("ALIGN", (0, 0), (-1, -1), "RIGHT" if locale.is_rtl else "LEFT"),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 0),
("TOPPADDING", (0, 0), (-1, -1), 0),
("BOTTOMPADDING", (0, 0), (-1, -1), 0),
]
)
)
story = [header_table, Spacer(1, 6 * mm)]
meta_rows = [
[locale.t("workspace"), scope["workspace"]["name"]],
[locale.t("period"), locale.period_label(scope["period"])],
[locale.t("from_date"), locale.format_date(scope["from_date"])],
[locale.t("to_date"), locale.format_date(scope["to_date"])],
[locale.t("user"), user_label(scope.get("user"), locale)],
[locale.t("generated_at"), locale.format_date(datetime.now().date())],
]
if locale.is_rtl:
meta_rows = [_rtl_row(locale, row) for row in meta_rows]
meta_rows = [[locale.shape(cell) for cell in row] for row in meta_rows]
meta_table = Table(meta_rows, colWidths=[doc.width * 0.24, doc.width * 0.76])
meta_table.setStyle(
TableStyle(
[
("FONTNAME", (0, 0), (0, -1), font_bold),
("FONTNAME", (1, 0), (1, -1), font_regular),
("BACKGROUND", (0, 0), (0, -1), colors.HexColor("#F8FAFC")),
("BOX", (0, 0), (-1, -1), 0.5, colors.HexColor("#CBD5E1")),
("INNERGRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#CBD5E1")),
("ALIGN", (0, 0), (-1, -1), "RIGHT" if locale.is_rtl else "LEFT"),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
]
)
)
story.extend([meta_table, Spacer(1, 5 * mm)])
summary_data = [
[locale.t("total_hours"), locale.format_duration(summary["total_duration"])],
[locale.t("billable_hours"), locale.format_duration(summary["billable_duration"])],
[locale.t("non_billable_hours"), locale.format_duration(summary["non_billable_duration"])],
[locale.t("income"), _money_label(locale, summary["income_totals"])],
]
if locale.is_rtl:
summary_data = [_rtl_row(locale, row) for row in summary_data]
summary_data = [[locale.shape(cell) for cell in row] for row in summary_data]
summary_table = Table(summary_data, colWidths=[doc.width * 0.38, doc.width * 0.62])
summary_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#EFF6FF")),
("BOX", (0, 0), (-1, -1), 0.5, colors.HexColor("#BFDBFE")),
("INNERGRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#BFDBFE")),
("FONTNAME", (0, 0), (0, -1), font_bold),
("FONTNAME", (1, 0), (1, -1), font_regular),
("ALIGN", (0, 0), (-1, -1), "RIGHT" if locale.is_rtl else "LEFT"),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
]
)
)
story.extend([summary_table, Spacer(1, 6 * mm)])
sections = [
("daily_summary", report_data["days"], True),
("clients", report_data["clients"], False),
("projects", report_data["projects"], False),
("tags", report_data["tags"], False),
]
for title_key, rows, is_daily in sections:
story.append(_paragraph(locale.t(title_key), section_style, locale))
story.append(Spacer(1, 2 * mm))
header = _rtl_row(
locale,
[
locale.t("date") if is_daily else locale.t("name"),
locale.t("billable_hours"),
locale.t("non_billable_hours"),
locale.t("total_hours"),
locale.t("income"),
],
)
table = _styled_table(
[header, *_report_table_rows(locale, rows)],
locale=locale,
column_widths=[
doc.width * 0.26,
doc.width * 0.15,
doc.width * 0.17,
doc.width * 0.14,
doc.width * 0.28,
],
)
story.extend([table, Spacer(1, 5 * mm)])
doc.build(story)
return buffer.getvalue()