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 _rate_label(locale: ExportLocale, rate: dict | None) -> str: if not rate: return "-" return f"{locale.format_amount(rate['amount'])} {locale.currency_label(rate['currency'])}" def _rate_label_excel(locale: ExportLocale, rate: dict | None) -> str: if not rate: return "-" return f"{locale.format_amount(rate['amount'], ascii_digits=True)} {locale.currency_label(rate['currency'])}" 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"), scope["user"]["name"] if scope.get("user") else locale.t("all_users"), ], ) ) worksheet.append( _rtl_row( locale, [ locale.t("mobile"), locale.format_number(scope["user"]["mobile"], ascii_digits=True) if scope.get("user") and scope["user"].get("mobile") else "-", ], ) ) 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, 10}: _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=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("hourly_rate"), 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), _rate_label_excel(locale, row.get("latest_hourly_rate")), _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], *, is_daily: bool) -> list[list[str]]: if not rows: column_count = 6 if is_daily else 5 return [_rtl_row(locale, [locale.t("no_data"), *([""] * (column_count - 1))])] 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"]), *([_rate_label(locale, row.get("latest_hourly_rate"))] if is_daily else []), _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"), scope["user"]["name"] if scope.get("user") else locale.t("all_users")], [locale.t("mobile"), locale.format_number(scope["user"]["mobile"]) if scope.get("user") and scope["user"].get("mobile") else "-"], [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("hourly_rate")] if is_daily else [] ), locale.t("income"), ], ) table = _styled_table( [header, *_report_table_rows(locale, rows, is_daily=is_daily)], locale=locale, column_widths=( [ doc.width * 0.21, doc.width * 0.13, doc.width * 0.15, doc.width * 0.13, doc.width * 0.16, doc.width * 0.22, ] if is_daily else [ 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()