Files
TimeTracker/app/utils/time_entries_pdf.py
T
Dries Peeters b4486a627f fix: CI tests, code quality, and duplicate DB indexes
- Webhook models: remove duplicate index definitions so db.create_all()
  no longer raises 'index already exists' (columns already have index=True)
- ImportService: fix circular import by late-importing ClientService,
  ProjectService, TimeTrackingService in __init__
- reports: fix F823 by renaming unpack variable _ to _entry_count to avoid
  shadowing gettext _ in export_task_excel()
- Code quality: add .flake8 with extend-ignore so flake8 CI passes;
  simplify pyproject.toml isort config (drop unsupported options)
- Format: run black and isort on app/
- tests: restore minimal app fixture in test_import_export_models
2026-03-15 10:51:52 +01:00

497 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Time entries PDF export professional report using ReportLab.
Produces a clean, readable PDF with a report header, date-grouped table,
summary totals, and page numbers.
"""
from collections import OrderedDict
from datetime import datetime
from io import BytesIO
from reportlab.lib import colors
from reportlab.lib.enums import TA_LEFT
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.units import cm
from reportlab.platypus import KeepTogether, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
# ---------------------------------------------------------------------------
# Color palette
# ---------------------------------------------------------------------------
BRAND_COLOR = colors.HexColor("#1e3a5f")
HEADER_BG = colors.HexColor("#1e3a5f")
HEADER_FG = colors.HexColor("#ffffff")
ROW_ALT_BG = colors.HexColor("#f0f4f8")
ROW_NORMAL_BG = colors.HexColor("#ffffff")
GRID_LIGHT = colors.HexColor("#dde3ea")
GRID_HEADER = colors.HexColor("#334155")
DATE_GROUP_BG = colors.HexColor("#e8edf3")
DATE_GROUP_FG = colors.HexColor("#1e3a5f")
TOTALS_BG = colors.HexColor("#1e3a5f")
TOTALS_FG = colors.HexColor("#ffffff")
MUTED_TEXT = colors.HexColor("#64748b")
# ---------------------------------------------------------------------------
# Font / spacing constants
# ---------------------------------------------------------------------------
FONT_SIZE = 9
HEADER_FONT_SIZE = 10
DATE_GROUP_FONT_SIZE = 9
CELL_PAD_H = 6
CELL_PAD_V = 5
HEADER_PAD_V = 7
# Page layout
PAGE_SIZE = landscape(A4)
MARGIN = 1.0 * cm
BOTTOM_MARGIN = 1.2 * cm # extra room for page number
# Usable width: 29.7cm - 2*1.0cm margin = 27.7cm
USABLE_WIDTH_CM = 27.7
# Column widths (8 columns) must sum to ~USABLE_WIDTH_CM
COL_WIDTHS_CM = [
2.6, # User
3.0, # Client
3.4, # Project
2.8, # Task
3.2, # Time (start end)
1.6, # Duration
9.5, # Notes
1.6, # Billable
]
COL_WIDTHS = [w * cm for w in COL_WIDTHS_CM]
NUM_COLS = len(COL_WIDTHS)
# Paragraph style for wrapping notes
NOTES_STYLE = ParagraphStyle(
"NotesCell",
fontName="Helvetica",
fontSize=FONT_SIZE,
leading=FONT_SIZE + 2,
alignment=TA_LEFT,
wordWrap="CJK",
splitLongWords=True,
)
# ---------------------------------------------------------------------------
# Helper functions
# ---------------------------------------------------------------------------
def _fmt_time(dt):
"""Format datetime using the current user's time format preference."""
if dt is None:
return ""
if isinstance(dt, datetime):
try:
from app.utils.timezone import get_user_time_format
return dt.strftime(get_user_time_format())
except Exception:
return dt.strftime("%H:%M")
return str(dt)
def _fmt_date_group(dt):
"""Format a date for the group header row, e.g. 'Monday, 06.02.2026'."""
if dt is None:
return "Unknown date"
try:
from app.utils.timezone import get_user_date_format
date_fmt = get_user_date_format()
return dt.strftime(f"%A, {date_fmt}")
except Exception:
return str(dt)
def _duration_hhmm(seconds):
"""Convert seconds to HH:MM string."""
if not seconds or seconds < 0:
return "00:00"
total_minutes = int(seconds) // 60
hours = total_minutes // 60
minutes = total_minutes % 60
return f"{hours:02d}:{minutes:02d}"
def _safe_str(val, fallback=""):
"""Safely convert a value to a stripped string."""
if val is None:
return fallback
s = str(val).strip()
return s if s else fallback
def _make_cell_paragraph(text):
"""Wrap text in a Paragraph for word-wrapping inside table cells (notes, task, client, project)."""
clean = _safe_str(text)
if not clean:
return ""
# Escape XML-special characters for ReportLab Paragraph
clean = clean.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
return Paragraph(clean, NOTES_STYLE)
def _make_notes_paragraph(text):
"""Wrap notes text in a Paragraph for word-wrapping inside the table cell."""
return _make_cell_paragraph(text)
# ---------------------------------------------------------------------------
# Page callback for page numbers
# ---------------------------------------------------------------------------
def _page_footer(canvas, doc):
"""Draw page number at bottom-right of every page."""
canvas.saveState()
canvas.setFont("Helvetica", 7)
canvas.setFillColor(MUTED_TEXT)
page_num = canvas.getPageNumber()
text = f"Page {page_num}"
canvas.drawRightString(
doc.pagesize[0] - MARGIN,
0.5 * cm,
text,
)
canvas.restoreState()
# ---------------------------------------------------------------------------
# Report header builder
# ---------------------------------------------------------------------------
def _build_report_header(start_date=None, end_date=None, filters=None):
"""Build the story elements for the report header section."""
elements = []
# Title
title_style = ParagraphStyle(
"ReportTitle",
fontName="Helvetica-Bold",
fontSize=18,
leading=22,
textColor=BRAND_COLOR,
)
elements.append(Paragraph("Time Entries Report", title_style))
elements.append(Spacer(1, 4))
# Accent line (drawn via a thin colored table)
accent = Table([[""]], colWidths=[USABLE_WIDTH_CM * cm], rowHeights=[2])
accent.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, -1), BRAND_COLOR),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 0),
("TOPPADDING", (0, 0), (-1, -1), 0),
("BOTTOMPADDING", (0, 0), (-1, -1), 0),
]
)
)
elements.append(accent)
elements.append(Spacer(1, 8))
# Meta info line(s)
meta_style = ParagraphStyle(
"ReportMeta",
fontName="Helvetica",
fontSize=9,
leading=13,
textColor=MUTED_TEXT,
)
# Period
if start_date and end_date:
period = f"Period: {start_date} to {end_date}"
elif start_date:
period = f"From: {start_date}"
elif end_date:
period = f"Until: {end_date}"
else:
period = "Period: All dates"
try:
from app.utils.timezone import get_user_datetime_format
gen_fmt = get_user_datetime_format()
except Exception:
gen_fmt = "%Y-%m-%d %H:%M"
generated = f"Generated: {datetime.now().strftime(gen_fmt)}"
# Build a two-column layout: period on left, generated on right
meta_left = Paragraph(period, meta_style)
meta_right_style = ParagraphStyle(
"ReportMetaRight",
parent=meta_style,
alignment=2, # TA_RIGHT
)
meta_right = Paragraph(generated, meta_right_style)
meta_table = Table(
[[meta_left, meta_right]],
colWidths=[USABLE_WIDTH_CM * 0.6 * cm, USABLE_WIDTH_CM * 0.4 * cm],
)
meta_table.setStyle(
TableStyle(
[
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 0),
("TOPPADDING", (0, 0), (-1, -1), 0),
("BOTTOMPADDING", (0, 0), (-1, -1), 0),
("VALIGN", (0, 0), (-1, -1), "TOP"),
]
)
)
elements.append(meta_table)
# Filter summary
if filters:
filter_parts = []
for label, value in filters.items():
if value:
filter_parts.append(f"{label}: {value}")
if filter_parts:
filter_style = ParagraphStyle(
"FilterMeta",
fontName="Helvetica-Oblique",
fontSize=8,
leading=11,
textColor=MUTED_TEXT,
)
elements.append(Spacer(1, 2))
elements.append(Paragraph("Filters: " + " | ".join(filter_parts), filter_style))
elements.append(Spacer(1, 12))
return elements
# ---------------------------------------------------------------------------
# Main PDF builder
# ---------------------------------------------------------------------------
def build_time_entries_pdf(entries, start_date=None, end_date=None, filters=None):
"""
Build a professional PDF report of time entry data.
Args:
entries: List of TimeEntry objects (with user, project, client, task loaded).
start_date: Optional start date string for the report header.
end_date: Optional end date string for the report header.
filters: Optional dict of active filter labels, e.g. {"User": "john", "Project": "WebApp"}.
Returns:
bytes: PDF file content.
"""
buffer = BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=PAGE_SIZE,
leftMargin=MARGIN,
rightMargin=MARGIN,
topMargin=MARGIN,
bottomMargin=BOTTOM_MARGIN,
)
story = []
# ── Report header ──────────────────────────────────────────────────
story.extend(_build_report_header(start_date, end_date, filters))
# ── Group entries by date ──────────────────────────────────────────
grouped = _group_entries_by_date(entries)
total_seconds = 0
billable_seconds = 0
entry_count = 0
if not entries:
# Empty state
empty_style = ParagraphStyle(
"EmptyState",
fontName="Helvetica-Oblique",
fontSize=11,
leading=14,
textColor=MUTED_TEXT,
)
story.append(Spacer(1, 20))
story.append(Paragraph("No time entries found for the selected filters.", empty_style))
else:
# ── Build tables per date group ────────────────────────────────
headers = ["User", "Client", "Project", "Task", "Time", "Duration", "Notes", "Billable"]
for date_key, date_entries in grouped.items():
group_elements = []
# Date group sub-header
date_label = _fmt_date_group(date_key)
date_style = ParagraphStyle(
"DateGroup",
fontName="Helvetica-Bold",
fontSize=DATE_GROUP_FONT_SIZE,
leading=DATE_GROUP_FONT_SIZE + 3,
textColor=DATE_GROUP_FG,
)
group_elements.append(Spacer(1, 6))
group_elements.append(Paragraph(date_label, date_style))
group_elements.append(Spacer(1, 3))
# Build table data for this date group
table_data = [headers]
data_row_indices = [] # track which rows are data (not header)
for entry in date_entries:
dur_sec = getattr(entry, "duration_seconds", None) or 0
total_seconds += dur_sec
if entry.billable:
billable_seconds += dur_sec
entry_count += 1
time_range = _fmt_time(entry.start_time)
if entry.end_time:
time_range += f" - {_fmt_time(entry.end_time)}"
# Use wrapping Paragraphs for long-text columns so they break across lines (like notes)
client_cell = _make_cell_paragraph(entry.client.name if entry.client else "")
project_cell = _make_cell_paragraph(entry.project.name if entry.project else "")
task_cell = _make_cell_paragraph(entry.task.name if entry.task else "")
notes_cell = _make_notes_paragraph(entry.notes)
row = [
_safe_str(entry.user.username if entry.user else ""),
client_cell if client_cell else " ",
project_cell if project_cell else " ",
task_cell if task_cell else " ",
time_range if time_range else " ",
_duration_hhmm(dur_sec),
notes_cell if notes_cell else " ",
"\u2713" if entry.billable else "\u2014", # checkmark or em-dash
]
data_row_indices.append(len(table_data))
table_data.append(row)
nrows = len(table_data)
t = Table(table_data, colWidths=COL_WIDTHS, repeatRows=1)
style_commands = [
# Header row styling
("BACKGROUND", (0, 0), (-1, 0), HEADER_BG),
("TEXTCOLOR", (0, 0), (-1, 0), HEADER_FG),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), HEADER_FONT_SIZE),
("TOPPADDING", (0, 0), (-1, 0), HEADER_PAD_V),
("BOTTOMPADDING", (0, 0), (-1, 0), HEADER_PAD_V),
("LINEBELOW", (0, 0), (-1, 0), 1.5, GRID_HEADER),
# Global cell styles
("VALIGN", (0, 0), (-1, -1), "TOP"),
("ALIGN", (0, 0), (-1, -1), "LEFT"),
("ALIGN", (5, 1), (5, -1), "CENTER"), # Duration centered
("ALIGN", (7, 0), (7, -1), "CENTER"), # Billable centered
("LEFTPADDING", (0, 0), (-1, -1), CELL_PAD_H),
("RIGHTPADDING", (0, 0), (-1, -1), CELL_PAD_H),
("TOPPADDING", (0, 1), (-1, -1), CELL_PAD_V),
("BOTTOMPADDING", (0, 1), (-1, -1), CELL_PAD_V),
# Body font size
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
("FONTSIZE", (0, 1), (-1, -1), FONT_SIZE),
# Grid
("LINEBELOW", (0, 0), (-1, -2), 0.5, GRID_LIGHT),
("BOX", (0, 0), (-1, -1), 0.75, GRID_HEADER),
]
# Alternating row backgrounds
for idx, row_num in enumerate(data_row_indices):
bg = ROW_ALT_BG if idx % 2 == 1 else ROW_NORMAL_BG
style_commands.append(("BACKGROUND", (0, row_num), (-1, row_num), bg))
t.setStyle(TableStyle(style_commands))
group_elements.append(t)
# Try to keep the date header with at least the first few rows
story.append(KeepTogether(group_elements[:5]))
if len(group_elements) > 5:
for el in group_elements[5:]:
story.append(el)
# ── Summary totals ─────────────────────────────────────────────────
if entry_count > 0:
story.append(Spacer(1, 14))
story.extend(_build_summary_totals(entry_count, total_seconds, billable_seconds))
# ── Build PDF ──────────────────────────────────────────────────────
doc.build(story, onFirstPage=_page_footer, onLaterPages=_page_footer)
buffer.seek(0)
return buffer.getvalue()
# ---------------------------------------------------------------------------
# Helpers for grouping and summary
# ---------------------------------------------------------------------------
def _group_entries_by_date(entries):
"""Group entries by their start date, preserving order."""
grouped = OrderedDict()
for entry in entries:
if entry.start_time:
date_key = entry.start_time.date() if hasattr(entry.start_time, "date") else entry.start_time
else:
date_key = None
if date_key not in grouped:
grouped[date_key] = []
grouped[date_key].append(entry)
return grouped
def _build_summary_totals(entry_count, total_seconds, billable_seconds):
"""Build the summary totals section at the bottom of the report."""
elements = []
total_dur = _duration_hhmm(total_seconds)
billable_dur = _duration_hhmm(billable_seconds)
summary_style = ParagraphStyle(
"SummaryLabel",
fontName="Helvetica-Bold",
fontSize=10,
leading=13,
textColor=TOTALS_FG,
)
# Build a single-row summary table spanning full width
summary_data = [
[
Paragraph(f"Total: {entry_count} entries", summary_style),
Paragraph(f"Total Duration: {total_dur}", summary_style),
Paragraph(f"Billable: {billable_dur}", summary_style),
]
]
third = USABLE_WIDTH_CM / 3.0
summary_table = Table(
summary_data,
colWidths=[third * cm, third * cm, third * cm],
)
summary_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, -1), TOTALS_BG),
("TEXTCOLOR", (0, 0), (-1, -1), TOTALS_FG),
("ALIGN", (0, 0), (0, 0), "LEFT"),
("ALIGN", (1, 0), (1, 0), "CENTER"),
("ALIGN", (2, 0), (2, 0), "RIGHT"),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 8),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
("ROUNDEDCORNERS", [4, 4, 4, 4]),
]
)
)
elements.append(summary_table)
return elements