Files
AdventureLog/backend/server/adventures/services/collection_pdf.py
Sean Morley db9f4a66f6 Add PDF generation for collections and enhance admin functionality
- Implemented PDF generation for collections, allowing users to create printable itineraries.
- Added support for Noto fonts to ensure proper rendering of text and emojis in PDFs.
- Enhanced admin interfaces for achievements and adventures, improving usability and data management.
- Introduced new fields and methods in models and serializers to support geolocation features and improve data handling.
- Updated migrations to transition from latitude/longitude fields to a unified PointField for geographic data.
- Added tests to validate PDF generation functionality and ensure reliability.
2026-06-03 17:39:24 -04:00

788 lines
26 KiB
Python
Raw Permalink 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.
"""
Build a printable PDF itinerary from a Collection and its related content.
"""
from __future__ import annotations
import io
import re
from datetime import date, datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple
from django.db.models import Prefetch
from PIL import Image as PILImage
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER, TA_LEFT
from reportlab.lib.pagesizes import letter
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import inch
from reportlab.platypus import (
Image,
PageBreak,
Paragraph,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
from adventures.models import (
Checklist,
ChecklistItem,
Collection,
CollectionItineraryDay,
CollectionItineraryItem,
Location,
Lodging,
Note,
Transportation,
)
from adventures.services.pdf_fonts import (
apply_fonts_to_styles,
ensure_pdf_fonts,
escape_xml,
pdf_markup,
)
# Brand-adjacent palette (readable on white paper)
COLOR_PRIMARY = colors.HexColor('#0d9488')
COLOR_MUTED = colors.HexColor('#64748b')
COLOR_BORDER = colors.HexColor('#e2e8f0')
COLOR_LIGHT_BG = colors.HexColor('#f8fafc')
def get_collection_for_pdf(collection_id) -> Collection:
"""Load a collection with relations needed for PDF export."""
return (
Collection.objects.filter(pk=collection_id)
.select_related('primary_image', 'user')
.prefetch_related(
Prefetch(
'locations',
queryset=Location.objects.select_related(
'category', 'country', 'region', 'city'
).prefetch_related('visits'),
),
'transportation_set',
'note_set',
Prefetch(
'lodging_set',
queryset=Lodging.objects.all(),
),
Prefetch(
'checklist_set',
queryset=Checklist.objects.prefetch_related(
Prefetch('checklistitem_set', queryset=ChecklistItem.objects.order_by('name'))
),
),
Prefetch(
'itinerary_items',
queryset=CollectionItineraryItem.objects.select_related('content_type').order_by(
'date', 'order'
),
),
'itinerary_days',
)
.get()
)
def _plain_text(value: Optional[str], max_length: int = 2000) -> str:
if not value:
return ''
text = str(value)
text = re.sub(r'!\[.*?\]\(.*?\)', '', text)
text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text)
text = re.sub(r'#{1,6}\s*', '', text)
text = re.sub(r'\*\*([^*]+)\*\*', r'\1', text)
text = re.sub(r'\*([^*]+)\*', r'\1', text)
text = re.sub(r'`([^`]+)`', r'\1', text)
text = text.replace('\r\n', '\n').replace('\r', '\n')
text = re.sub(r'\n{3,}', '\n\n', text).strip()
if len(text) > max_length:
return text[: max_length - 3] + '...'
return text
def _format_date(d: Optional[date]) -> str:
if not d:
return ''
return d.strftime('%A, %B %d, %Y')
def _format_short_date(d: Optional[date]) -> str:
if not d:
return ''
return d.strftime('%b %d, %Y')
def _format_datetime(dt: Optional[datetime], tz_name: Optional[str] = None) -> str:
if not dt:
return ''
if dt.hour == 0 and dt.minute == 0 and dt.second == 0:
return dt.strftime('%b %d, %Y')
suffix = f' ({tz_name})' if tz_name else ''
return dt.strftime('%b %d, %Y %I:%M %p').lstrip('0') + suffix
def _format_money(amount, currency: Optional[str]) -> str:
if amount is None:
return ''
cur = currency or 'USD'
try:
return f'{amount} {cur}'
except Exception:
return str(amount)
def _trip_day_count(start: Optional[date], end: Optional[date]) -> Optional[int]:
if not start or not end:
return None
return (end - start).days + 1
def _build_lookups(collection: Collection) -> Dict[str, Dict[str, Any]]:
return {
'location': {str(loc.id): loc for loc in collection.locations.all()},
'transportation': {str(t.id): t for t in collection.transportation_set.all()},
'lodging': {str(l.id): l for l in collection.lodging_set.all()},
'note': {str(n.id): n for n in collection.note_set.all()},
'checklist': {str(c.id): c for c in collection.checklist_set.all()},
}
def _resolve_item(
itinerary_item: CollectionItineraryItem, lookups: Dict[str, Dict[str, Any]]
) -> Tuple[Optional[str], Optional[Any]]:
model = itinerary_item.content_type.model
obj = lookups.get(model, {}).get(str(itinerary_item.object_id))
return model, obj
def _overnight_lodging_for_date(collection: Collection, day: date, scheduled_lodging_ids: set) -> List[Lodging]:
result = []
target = day
for lodging in collection.lodging_set.all():
if str(lodging.id) not in scheduled_lodging_ids:
continue
if not lodging.check_in or not lodging.check_out:
continue
check_in = lodging.check_in.date()
check_out = lodging.check_out.date()
if check_in <= target < check_out:
result.append(lodging)
return result
def _iter_trip_dates(start: date, end: date):
current = start
while current <= end:
yield current
current += timedelta(days=1)
def _scheduled_keys(collection: Collection) -> set:
keys = set()
for item in collection.itinerary_items.all():
keys.add((item.content_type.model, str(item.object_id)))
return keys
def _group_itinerary_days(collection: Collection) -> Tuple[List[Dict[str, Any]], List[CollectionItineraryItem], Dict[str, Dict[str, Any]]]:
"""Mirror the web planner: one section per calendar day in the trip range."""
lookups = _build_lookups(collection)
day_meta = {d.date: d for d in collection.itinerary_days.all()}
dated_items: Dict[date, List[CollectionItineraryItem]] = {}
global_items: List[CollectionItineraryItem] = []
scheduled_lodging_ids = set()
for item in collection.itinerary_items.all():
model = item.content_type.model
if model == 'lodging':
scheduled_lodging_ids.add(str(item.object_id))
if item.is_global:
global_items.append(item)
elif item.date:
dated_items.setdefault(item.date, []).append(item)
for items in dated_items.values():
items.sort(key=lambda x: x.order)
start = collection.start_date
end = collection.end_date
if not start or not end:
if dated_items:
dates = sorted(dated_items.keys())
start = dates[0]
end = dates[-1]
else:
return []
days: List[Dict[str, Any]] = []
for day in _iter_trip_dates(start, end):
resolved = []
for it in dated_items.get(day, []):
model, obj = _resolve_item(it, lookups)
if obj is not None:
resolved.append({'order': it.order, 'type': model, 'object': obj})
overnight = _overnight_lodging_for_date(collection, day, scheduled_lodging_ids)
meta = day_meta.get(day)
days.append(
{
'date': day,
'display_date': _format_date(day),
'day_name': meta.name if meta and meta.name else None,
'day_description': _plain_text(meta.description) if meta and meta.description else None,
'items': resolved,
'overnight_lodging': overnight,
}
)
return days, global_items, lookups
def _item_type_label(model: str) -> str:
return {
'location': 'Location',
'transportation': 'Transport',
'lodging': 'Stay',
'note': 'Note',
'checklist': 'Checklist',
}.get(model, model.title())
def _location_paragraphs(location: Location, styles) -> List:
flowables = []
title = pdf_markup(location.name or 'Location')
category = ''
if location.category:
icon = location.category.icon or ''
label = location.category.display_name or location.category.name
category = pdf_markup(f'{icon} {label}'.strip())
flowables.append(Paragraph(f'<b>{title}</b>{f" <font color=\"#64748b\">{category}</font>" if category else ""}', styles['ItemTitle']))
if location.location:
flowables.append(Paragraph(pdf_markup(location.location), styles['ItemMeta']))
geo_bits = []
if location.city:
geo_bits.append(location.city.name)
if location.region:
geo_bits.append(location.region.name)
if location.country:
geo_bits.append(location.country.name)
if geo_bits:
flowables.append(Paragraph(pdf_markup(', '.join(geo_bits)), styles['ItemMeta']))
for visit in location.visits.all().order_by('start_date'):
start = visit.start_date.date() if visit.start_date else None
end = visit.end_date.date() if visit.end_date else None
if start and end and end != start:
flowables.append(
Paragraph(
f'Visit: {_format_short_date(start)} {_format_short_date(end)}',
styles['ItemMeta'],
)
)
elif start:
flowables.append(Paragraph(f'Visit: {_format_short_date(start)}', styles['ItemMeta']))
desc = _plain_text(location.description)
if desc:
flowables.append(Paragraph(pdf_markup(desc), styles['ItemBody']))
extras = []
if location.rating is not None:
extras.append(f'Rating: {location.rating}/5')
if location.price is not None:
extras.append(_format_money(location.price.amount, str(location.price.currency)))
if location.link:
extras.append(f'<link href="{escape_xml(location.link)}">Link</link>')
if extras:
flowables.append(Paragraph(' · '.join(extras), styles['ItemMeta']))
return flowables
def _transportation_paragraphs(transport: Transportation, styles) -> List:
flowables = []
title = pdf_markup(transport.name or transport.type or 'Transport')
flowables.append(Paragraph(f'<b>{title}</b> <font color="#64748b">({pdf_markup(transport.type)})</font>', styles['ItemTitle']))
route_parts = []
if transport.from_location or transport.start_code:
origin = transport.from_location or transport.start_code or ''
route_parts.append(pdf_markup(origin))
if transport.to_location or transport.end_code:
dest = transport.to_location or transport.end_code or ''
if route_parts:
route_parts.append('')
route_parts.append(pdf_markup(dest))
if route_parts:
flowables.append(Paragraph(' '.join(route_parts), styles['ItemMeta']))
if transport.date:
line = _format_datetime(transport.date, transport.start_timezone)
if transport.end_date:
line += f' {_format_datetime(transport.end_date, transport.end_timezone)}'
flowables.append(Paragraph(line, styles['ItemMeta']))
if transport.flight_number:
flowables.append(Paragraph(f'Flight: {pdf_markup(transport.flight_number)}', styles['ItemMeta']))
desc = _plain_text(transport.description)
if desc:
flowables.append(Paragraph(pdf_markup(desc), styles['ItemBody']))
extras = []
if transport.rating is not None:
extras.append(f'Rating: {transport.rating}/5')
if transport.price is not None:
extras.append(_format_money(transport.price, transport.price_currency))
if transport.link:
extras.append(f'<link href="{escape_xml(transport.link)}">Link</link>')
if extras:
flowables.append(Paragraph(' · '.join(extras), styles['ItemMeta']))
return flowables
def _lodging_paragraphs(lodging: Lodging, styles) -> List:
flowables = []
title = pdf_markup(lodging.name or 'Stay')
flowables.append(
Paragraph(
f'<b>{title}</b> <font color="#64748b">({pdf_markup(lodging.type)})</font>',
styles['ItemTitle'],
)
)
if lodging.location:
flowables.append(Paragraph(pdf_markup(lodging.location), styles['ItemMeta']))
if lodging.check_in or lodging.check_out:
check_in = _format_datetime(lodging.check_in, lodging.timezone) if lodging.check_in else ''
check_out = _format_datetime(lodging.check_out, lodging.timezone) if lodging.check_out else ''
flowables.append(Paragraph(f'Check-in: {check_in} · Check-out: {check_out}', styles['ItemMeta']))
if lodging.reservation_number:
flowables.append(Paragraph(f'Confirmation: {pdf_markup(lodging.reservation_number)}', styles['ItemMeta']))
desc = _plain_text(lodging.description)
if desc:
flowables.append(Paragraph(pdf_markup(desc), styles['ItemBody']))
extras = []
if lodging.rating is not None:
extras.append(f'Rating: {lodging.rating}/5')
if lodging.price is not None:
extras.append(_format_money(lodging.price, lodging.price_currency))
if lodging.link:
extras.append(f'<link href="{escape_xml(lodging.link)}">Link</link>')
if extras:
flowables.append(Paragraph(' · '.join(extras), styles['ItemMeta']))
return flowables
def _note_paragraphs(note: Note, styles) -> List:
flowables = []
flowables.append(Paragraph(f'<b>{pdf_markup(note.name or "Note")}</b>', styles['ItemTitle']))
if note.date:
flowables.append(Paragraph(_format_short_date(note.date), styles['ItemMeta']))
content = _plain_text(note.content)
if content:
flowables.append(Paragraph(pdf_markup(content), styles['ItemBody']))
if note.links:
for link in note.links:
if link:
flowables.append(
Paragraph(
f'<link href="{escape_xml(link)}">{pdf_markup(link)}</link>',
styles['ItemMeta'],
)
)
return flowables
def _checklist_paragraphs(checklist: Checklist, styles, _body_font: str, _emoji_font: str | None) -> List:
flowables = []
flowables.append(Paragraph(f'<b>{pdf_markup(checklist.name or "Checklist")}</b>', styles['ItemTitle']))
if checklist.date:
flowables.append(Paragraph(_format_short_date(checklist.date), styles['ItemMeta']))
mark_style = ParagraphStyle(
'ChecklistMark',
parent=styles['ItemMeta'],
fontSize=10,
textColor=COLOR_MUTED,
)
rows = []
for item in checklist.checklistitem_set.all():
mark = '' if item.is_checked else ''
rows.append([
Paragraph(pdf_markup(mark), mark_style),
Paragraph(pdf_markup(item.name or ''), styles['ItemBody']),
])
if rows:
table = Table(rows, colWidths=[0.35 * inch, 6.0 * inch])
table.setStyle(
TableStyle(
[
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('LEFTPADDING', (0, 0), (-1, -1), 0),
('RIGHTPADDING', (0, 0), (-1, -1), 4),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
]
)
)
flowables.append(table)
return flowables
def _item_flowables(
model: str, obj: Any, styles, body_font: str, emoji_font: str | None
) -> List:
if obj is None:
return []
if model == 'location':
return _location_paragraphs(obj, styles)
if model == 'transportation':
return _transportation_paragraphs(obj, styles)
if model == 'lodging':
return _lodging_paragraphs(obj, styles)
if model == 'note':
return _note_paragraphs(obj, styles)
if model == 'checklist':
return _checklist_paragraphs(obj, styles, body_font, emoji_font)
return []
def _item_card(
model: str, obj: Any, order: int, styles, body_font: str, emoji_font: str | None
) -> List:
"""Render a single itinerary entry inside a shaded card."""
inner = [
Paragraph(
f'<font color="#0d9488"><b>{order}.</b></font> '
f'<font color="#64748b">{_item_type_label(model).upper()}</font>',
styles['ItemLabel'],
),
Spacer(1, 4),
]
inner.extend(_item_flowables(model, obj, styles, body_font, emoji_font))
card = Table([[inner]], colWidths=[6.5 * inch])
card.setStyle(
TableStyle(
[
('BACKGROUND', (0, 0), (-1, -1), COLOR_LIGHT_BG),
('BOX', (0, 0), (-1, -1), 0.5, COLOR_BORDER),
('LEFTPADDING', (0, 0), (-1, -1), 12),
('RIGHTPADDING', (0, 0), (-1, -1), 12),
('TOPPADDING', (0, 0), (-1, -1), 10),
('BOTTOMPADDING', (0, 0), (-1, -1), 10),
]
)
)
return [card, Spacer(1, 10)]
def _cover_image_flowable(collection: Collection, max_width: float = 6.5 * inch) -> Optional[Image]:
image_field = None
if collection.primary_image and collection.primary_image.image:
image_field = collection.primary_image.image
if not image_field:
return None
try:
with image_field.open('rb') as fh:
pil = PILImage.open(fh)
pil = pil.convert('RGB')
buf = io.BytesIO()
pil.save(buf, format='JPEG', quality=85)
buf.seek(0)
img = Image(buf)
ratio = img.imageHeight / float(img.imageWidth)
img.drawWidth = max_width
img.drawHeight = max_width * ratio
if img.drawHeight > 3.5 * inch:
img.drawHeight = 3.5 * inch
img.drawWidth = img.drawHeight / ratio
return img
except Exception:
return None
def _section_heading(text: str, styles) -> List:
return [
Paragraph(text, styles['SectionHeading']),
Spacer(1, 8),
]
def _folder_sections(
collection: Collection,
styles,
body_font: str,
emoji_font: str | None,
*,
only_unscheduled: bool = False,
scheduled_keys: Optional[set] = None,
) -> List:
"""List linked content by type (optionally excluding itinerary-scheduled items)."""
story = []
scheduled_keys = scheduled_keys or set()
def include(model: str, obj_id: str) -> bool:
if not only_unscheduled:
return True
return (model, str(obj_id)) not in scheduled_keys
sections = [
('Locations', 'location', collection.locations.all()),
('Transport', 'transportation', collection.transportation_set.all()),
('Stays', 'lodging', collection.lodging_set.all()),
('Notes', 'note', collection.note_set.all()),
('Checklists', 'checklist', collection.checklist_set.all()),
]
order = 1
for heading, model, queryset in sections:
items = [obj for obj in queryset if include(model, obj.id)]
if not items:
continue
story.extend(_section_heading(heading, styles))
for obj in items:
story.extend(_item_card(model, obj, order, styles, body_font, emoji_font))
order += 1
return story
def build_collection_pdf(collection: Collection) -> bytes:
"""Return PDF bytes for the given collection."""
body_font, emoji_font = ensure_pdf_fonts()
buffer = io.BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=letter,
leftMargin=0.75 * inch,
rightMargin=0.75 * inch,
topMargin=0.65 * inch,
bottomMargin=0.65 * inch,
title=collection.name or 'Trip',
author='AdventureLog',
)
base = getSampleStyleSheet()
styles = {
'CoverTitle': ParagraphStyle(
'CoverTitle',
parent=base['Title'],
fontSize=26,
leading=30,
alignment=TA_CENTER,
textColor=colors.HexColor('#0f172a'),
spaceAfter=12,
),
'CoverSubtitle': ParagraphStyle(
'CoverSubtitle',
parent=base['Normal'],
fontSize=12,
leading=16,
alignment=TA_CENTER,
textColor=COLOR_MUTED,
spaceAfter=6,
),
'SectionHeading': ParagraphStyle(
'SectionHeading',
parent=base['Heading2'],
fontSize=14,
leading=18,
textColor=COLOR_PRIMARY,
spaceBefore=8,
spaceAfter=4,
),
'DayHeading': ParagraphStyle(
'DayHeading',
parent=base['Heading1'],
fontSize=16,
leading=20,
textColor=colors.HexColor('#0f172a'),
spaceBefore=14,
spaceAfter=2,
),
'DaySubheading': ParagraphStyle(
'DaySubheading',
parent=base['Normal'],
fontSize=11,
leading=14,
textColor=COLOR_MUTED,
spaceAfter=8,
),
'ItemLabel': ParagraphStyle(
'ItemLabel',
parent=base['Normal'],
fontSize=8,
leading=10,
textColor=COLOR_MUTED,
),
'ItemTitle': ParagraphStyle(
'ItemTitle',
parent=base['Normal'],
fontSize=12,
leading=15,
textColor=colors.HexColor('#0f172a'),
spaceAfter=4,
),
'ItemMeta': ParagraphStyle(
'ItemMeta',
parent=base['Normal'],
fontSize=9,
leading=12,
textColor=COLOR_MUTED,
spaceAfter=3,
),
'ItemBody': ParagraphStyle(
'ItemBody',
parent=base['Normal'],
fontSize=10,
leading=13,
textColor=colors.HexColor('#334155'),
spaceAfter=4,
),
'Footer': ParagraphStyle(
'Footer',
parent=base['Normal'],
fontSize=8,
leading=10,
alignment=TA_CENTER,
textColor=COLOR_MUTED,
),
}
apply_fonts_to_styles(styles, body_font)
story: List = []
# Cover
cover_img = _cover_image_flowable(collection)
if cover_img:
story.append(Spacer(1, 0.15 * inch))
story.append(cover_img)
story.append(Spacer(1, 0.25 * inch))
story.append(Paragraph(pdf_markup(collection.name or 'Trip'), styles['CoverTitle']))
if collection.start_date and collection.end_date:
days = _trip_day_count(collection.start_date, collection.end_date)
date_line = f'{_format_short_date(collection.start_date)} {_format_short_date(collection.end_date)}'
if days:
date_line += f' · {days} day{"s" if days != 1 else ""}'
story.append(Paragraph(date_line, styles['CoverSubtitle']))
elif collection.start_date:
story.append(Paragraph(f'Starts {_format_short_date(collection.start_date)}', styles['CoverSubtitle']))
else:
story.append(Paragraph('Trip folder', styles['CoverSubtitle']))
loc_count = collection.locations.count()
if loc_count:
story.append(
Paragraph(
f'{loc_count} location{"s" if loc_count != 1 else ""}',
styles['CoverSubtitle'],
)
)
desc = _plain_text(collection.description)
if desc:
story.append(Spacer(1, 0.15 * inch))
story.append(
Paragraph(pdf_markup(desc), styles['ItemBody']),
)
if collection.link:
story.append(
Paragraph(
f'<link href="{escape_xml(collection.link)}">{pdf_markup(collection.link)}</link>',
styles['CoverSubtitle'],
)
)
story.append(Spacer(1, 0.2 * inch))
story.append(Paragraph('Generated by AdventureLog', styles['Footer']))
story.append(PageBreak())
if collection.start_date and collection.end_date:
day_groups, global_items, lookups = _group_itinerary_days(collection)
if day_groups:
story.extend(_section_heading('Day-by-day itinerary', styles))
for day in day_groups:
day_title = day['display_date']
if day.get('day_name'):
day_title = f'{day["day_name"]}{day_title}'
story.append(Paragraph(day_title, styles['DayHeading']))
if day.get('day_description'):
story.append(
Paragraph(
pdf_markup(day['day_description']),
styles['DaySubheading'],
)
)
if day.get('overnight_lodging'):
names = ', '.join(pdf_markup(l.name) for l in day['overnight_lodging'])
story.append(
Paragraph(f'<b>Overnight:</b> {names}', styles['DaySubheading']),
)
if not day['items']:
story.append(Paragraph('No scheduled items this day.', styles['ItemMeta']))
story.append(Spacer(1, 8))
continue
for idx, entry in enumerate(day['items'], start=1):
story.extend(
_item_card(entry['type'], entry['object'], idx, styles, body_font, emoji_font)
)
if global_items:
story.append(PageBreak())
story.extend(_section_heading('Trip-wide items', styles))
for idx, it in enumerate(sorted(global_items, key=lambda x: x.order), start=1):
model, obj = _resolve_item(it, lookups)
if obj is not None:
story.extend(_item_card(model, obj, idx, styles, body_font, emoji_font))
else:
story.extend(_section_heading('Itinerary', styles))
story.append(
Paragraph(
'No items are scheduled on the itinerary yet. See the reference sections below.',
styles['ItemMeta'],
)
)
story.append(Spacer(1, 12))
scheduled_keys = _scheduled_keys(collection)
appendix = _folder_sections(
collection,
styles,
body_font,
emoji_font,
only_unscheduled=True,
scheduled_keys=scheduled_keys,
)
if appendix:
story.append(PageBreak())
story.extend(_section_heading('Additional items (not on itinerary)', styles))
story.extend(appendix)
else:
story.extend(_section_heading('Collection contents', styles))
story.extend(_folder_sections(collection, styles, body_font, emoji_font))
doc.build(story)
return buffer.getvalue()
def pdf_filename_for_collection(collection: Collection) -> str:
raw = (collection.name or 'collection').strip()
safe = re.sub(r'[^\w\s-]', '', raw, flags=re.UNICODE)
safe = re.sub(r'[-\s]+', '_', safe).strip('_') or 'collection'
return f'{safe}_itinerary.pdf'