mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-20 21:30:12 -05:00
b4486a627f
- 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
497 lines
17 KiB
Python
497 lines
17 KiB
Python
"""
|
||
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("&", "&").replace("<", "<").replace(">", ">")
|
||
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
|