Files
TimeTracker/app/utils/mileage_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

266 lines
8.5 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.
"""
Mileage PDF export professional report using ReportLab.
Same visual style as time_entries_pdf: header, table, totals, page numbers.
"""
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 Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
# Reuse same palette as time_entries_pdf
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")
TOTALS_BG = colors.HexColor("#1e3a5f")
TOTALS_FG = colors.HexColor("#ffffff")
MUTED_TEXT = colors.HexColor("#64748b")
FONT_SIZE = 9
HEADER_FONT_SIZE = 10
CELL_PAD_H = 6
CELL_PAD_V = 5
HEADER_PAD_V = 7
PAGE_SIZE = landscape(A4)
MARGIN = 1.0 * cm
BOTTOM_MARGIN = 1.2 * cm
USABLE_WIDTH_CM = 27.7
# 7 columns: Date, User, Purpose, Route, Distance, Amount, Status
COL_WIDTHS_CM = [2.2, 2.6, 4.0, 9.0, 2.2, 2.5, 2.2]
COL_WIDTHS = [w * cm for w in COL_WIDTHS_CM]
NOTES_STYLE = ParagraphStyle(
"NotesCell",
fontName="Helvetica",
fontSize=FONT_SIZE,
leading=FONT_SIZE + 2,
alignment=TA_LEFT,
wordWrap="CJK",
splitLongWords=True,
)
def _safe_str(val, fallback=""):
if val is None:
return fallback
s = str(val).strip()
return s if s else fallback
def _make_cell_paragraph(text):
clean = _safe_str(text)
if not clean:
return ""
clean = clean.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
return Paragraph(clean, NOTES_STYLE)
def _page_footer(canvas, doc):
canvas.saveState()
canvas.setFont("Helvetica", 7)
canvas.setFillColor(MUTED_TEXT)
page_num = canvas.getPageNumber()
canvas.drawRightString(doc.pagesize[0] - MARGIN, 0.5 * cm, f"Page {page_num}")
canvas.restoreState()
def _build_report_header(start_date=None, end_date=None, filters=None):
elements = []
title_style = ParagraphStyle(
"ReportTitle",
fontName="Helvetica-Bold",
fontSize=18,
leading=22,
textColor=BRAND_COLOR,
)
elements.append(Paragraph("Mileage Report", title_style))
elements.append(Spacer(1, 4))
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_style = ParagraphStyle(
"ReportMeta",
fontName="Helvetica",
fontSize=9,
leading=13,
textColor=MUTED_TEXT,
)
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)}"
meta_left = Paragraph(period, meta_style)
meta_right_style = ParagraphStyle("ReportMetaRight", parent=meta_style, alignment=2)
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)
if filters:
filter_parts = [f"{label}: {value}" for label, value in filters.items() if 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
def build_mileage_pdf(entries, start_date=None, end_date=None, filters=None):
"""
Build a PDF report of mileage entries.
Args:
entries: List of Mileage objects (with user, project, client 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.
Returns:
bytes: PDF file content.
"""
buffer = BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=PAGE_SIZE,
leftMargin=MARGIN,
rightMargin=MARGIN,
topMargin=MARGIN,
bottomMargin=BOTTOM_MARGIN,
)
story = []
story.extend(_build_report_header(start_date, end_date, filters))
headers = ["Date", "User", "Purpose", "Route", "Distance (km)", "Amount", "Status"]
if not entries:
empty_style = ParagraphStyle(
"EmptyState",
fontName="Helvetica-Oblique",
fontSize=11,
leading=14,
textColor=MUTED_TEXT,
)
story.append(Spacer(1, 20))
story.append(Paragraph("No mileage entries found for the selected filters.", empty_style))
else:
table_data = [headers]
total_km = 0
total_amount = 0
for i, entry in enumerate(entries):
mult = 2 if entry.is_round_trip else 1
dist_km = float(entry.distance_km or 0)
amount = float(entry.calculated_amount or 0) * mult
total_km += dist_km
total_amount += amount
route = f"{_safe_str(entry.start_location)}{_safe_str(entry.end_location)}"
route_cell = _make_cell_paragraph(route) if route.strip() else " "
row = [
entry.trip_date.strftime("%Y-%m-%d") if entry.trip_date else "",
_safe_str(entry.user.display_name if entry.user else ""),
_make_cell_paragraph(entry.purpose or "") or " ",
route_cell,
f"{dist_km:.2f}",
f"{amount:.2f}",
_safe_str(entry.status),
]
table_data.append(row)
table = Table(table_data, colWidths=COL_WIDTHS, repeatRows=1)
nrows = len(table_data)
style = [
("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),
("BOTTOMPADDING", (0, 0), (-1, 0), HEADER_PAD_V),
("TOPPADDING", (0, 0), (-1, 0), HEADER_PAD_V),
("LEFTPADDING", (0, 0), (-1, -1), CELL_PAD_H),
("RIGHTPADDING", (0, 0), (-1, -1), CELL_PAD_H),
("TOPPADDING", (0, 0), (-1, -1), CELL_PAD_V),
("BOTTOMPADDING", (0, 0), (-1, -1), CELL_PAD_V),
("GRID", (0, 0), (-1, -1), 0.5, GRID_LIGHT),
("VALIGN", (0, 0), (-1, -1), "TOP"),
]
for r in range(1, nrows):
bg = ROW_ALT_BG if r % 2 == 1 else ROW_NORMAL_BG
style.append(("BACKGROUND", (0, r), (-1, r), bg))
# Totals row
table_data.append(["", "", "Total", "", f"{total_km:.2f}", f"{total_amount:.2f}", ""])
total_row_idx = len(table_data) - 1
style.append(("BACKGROUND", (0, total_row_idx), (-1, total_row_idx), TOTALS_BG))
style.append(("TEXTCOLOR", (0, total_row_idx), (-1, total_row_idx), TOTALS_FG))
style.append(("FONTNAME", (0, total_row_idx), (-1, total_row_idx), "Helvetica-Bold"))
table = Table(table_data, colWidths=COL_WIDTHS, repeatRows=1)
table.setStyle(TableStyle(style))
story.append(table)
doc.build(story, onFirstPage=_page_footer, onLaterPages=_page_footer)
return buffer.getvalue()