414 lines
17 KiB
Python
414 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
import io
|
|
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 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 _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"]
|
|
story = [
|
|
_paragraph(f"{locale.t('report_title')} - {scope['workspace']['name']}", title_style, locale),
|
|
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()
|