Coverage for src / idx_api / pdf_generator.py: 6%
206 statements
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-28 11:09 -0700
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-28 11:09 -0700
1"""PDF brochure generator for property listings."""
3import io
4import os
5from datetime import datetime
6from typing import Any
8import httpx
9import qrcode
10from PIL import Image
11from reportlab.lib import colors
12from reportlab.lib.pagesizes import letter
13from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
14from reportlab.lib.units import inch
15from reportlab.platypus import (
16 HRFlowable,
17 Image as RLImage,
18 PageBreak,
19 Paragraph,
20 SimpleDocTemplate,
21 Spacer,
22 Table,
23 TableStyle,
24)
27def generate_property_pdf(property_data: dict[str, Any], site_url: str, site_config: dict[str, Any] = None) -> bytes:
28 """Generate a PDF brochure for a property listing.
30 Args:
31 property_data: Property details from database
32 site_url: Base URL for QR code generation
33 site_config: Site configuration with broker/agent information
35 Returns:
36 PDF file as bytes
37 """
38 if site_config is None:
39 site_config = {"site": {"name": ""}, "contact": {}, "branding": {}}
41 buffer = io.BytesIO()
42 doc = SimpleDocTemplate(
43 buffer,
44 pagesize=letter,
45 rightMargin=0.75 * inch,
46 leftMargin=0.75 * inch,
47 topMargin=0.75 * inch,
48 bottomMargin=0.75 * inch,
49 )
51 # Build PDF content
52 story = []
53 styles = getSampleStyleSheet()
55 # Custom styles
56 title_style = ParagraphStyle(
57 "PropertyTitle",
58 parent=styles["Heading1"],
59 fontSize=24,
60 textColor=colors.HexColor("#1a1a1a"),
61 spaceAfter=6,
62 )
64 subtitle_style = ParagraphStyle(
65 "PropertySubtitle",
66 parent=styles["Normal"],
67 fontSize=14,
68 textColor=colors.HexColor("#666666"),
69 spaceAfter=12,
70 )
72 price_style = ParagraphStyle(
73 "Price",
74 parent=styles["Heading2"],
75 fontSize=20,
76 textColor=colors.HexColor("#f97316"),
77 spaceAfter=12,
78 )
80 section_style = ParagraphStyle(
81 "Section",
82 parent=styles["Heading3"],
83 fontSize=14,
84 textColor=colors.HexColor("#1a1a1a"),
85 spaceAfter=6,
86 spaceBefore=12,
87 )
89 # Format address
90 address_parts = [
91 property_data.get("street_number"),
92 property_data.get("street_name"),
93 property_data.get("street_suffix"),
94 ]
95 address = " ".join(filter(None, address_parts)) or "Address Available Upon Request"
97 city_state = f"{property_data.get('city', '')}, {property_data.get('state_or_province', '')} {property_data.get('postal_code', '')}"
99 # ===== PAGE 1: Header, Price, Hero Image, Key Stats, QR Code =====
101 # Header
102 story.append(Paragraph(address, title_style))
103 story.append(Paragraph(city_state.strip(), subtitle_style))
105 # Price
106 list_price = property_data.get("list_price")
107 if list_price:
108 price_formatted = f"${list_price:,.0f}"
109 story.append(Paragraph(price_formatted, price_style))
111 story.append(Spacer(1, 0.2 * inch))
113 # Download and prepare all photos
114 photos = property_data.get("photos", [])
115 if isinstance(photos, str):
116 import json
117 try:
118 photos = json.loads(photos)
119 except:
120 photos = []
122 photo_images = []
123 if photos:
124 for photo_url in photos:
125 try:
126 # Convert relative URLs to absolute URLs
127 if photo_url.startswith('/'):
128 photo_url = f"{site_url}{photo_url}"
130 # Download image
131 response = httpx.get(photo_url, timeout=5.0)
132 if response.status_code == 200:
133 img = Image.open(io.BytesIO(response.content))
134 photo_images.append(img)
135 except Exception as e:
136 print(f"Failed to load photo {photo_url}: {e}")
137 continue
139 # Page 1: Display first 2 photos as hero images (larger)
140 if photo_images:
141 hero_images = []
142 for img in photo_images[:2]:
143 # Larger hero images
144 img_copy = img.copy()
145 img_copy.thumbnail((350, 280), Image.Resampling.LANCZOS)
146 img_buffer = io.BytesIO()
147 img_copy.save(img_buffer, format="JPEG")
148 img_buffer.seek(0)
149 rl_img = RLImage(img_buffer, width=3.5*inch, height=2.8*inch)
150 hero_images.append(rl_img)
152 # Display hero images in a row
153 if len(hero_images) == 2:
154 hero_table = Table([hero_images], colWidths=[3.6*inch, 3.6*inch])
155 hero_table.setStyle(TableStyle([
156 ("VALIGN", (0, 0), (-1, -1), "TOP"),
157 ("LEFTPADDING", (0, 0), (-1, -1), 5),
158 ("RIGHTPADDING", (0, 0), (-1, -1), 5),
159 ]))
160 story.append(hero_table)
161 elif len(hero_images) == 1:
162 story.append(hero_images[0])
164 story.append(Spacer(1, 0.3 * inch))
166 # ===== TWO-COLUMN LAYOUT: Full Details (left) + Quick Facts Sidebar (right) =====
167 story.append(Spacer(1, 0.2 * inch))
169 # ===== LEFT COLUMN: Full Details =====
170 left_column = []
172 # Section header
173 full_details_header = ParagraphStyle(
174 "FullDetailsHeader",
175 parent=styles["Normal"],
176 fontSize=8,
177 textColor=colors.HexColor("#999999"),
178 letterSpacing=2,
179 )
180 left_column.append(Paragraph("FULL DETAILS", full_details_header))
181 left_column.append(Spacer(1, 0.15 * inch))
183 # Quick Stats (4-column grid)
184 quick_stats_data = [[
185 Paragraph(f"<b style='font-size:16'>{property_data.get('bedrooms_total') or '-'}</b><br/><font size='8' color='#666666'>Bedrooms</font>", styles["Normal"]),
186 Paragraph(f"<b style='font-size:16'>{property_data.get('bathrooms_total_integer') or '-'}</b><br/><font size='8' color='#666666'>Bathrooms</font>", styles["Normal"]),
187 Paragraph(f"<b style='font-size:16'>{property_data.get('living_area', '-'):,}</b><br/><font size='8' color='#666666'>Sq Ft</font>", styles["Normal"]),
188 Paragraph(f"<b style='font-size:16'>{property_data.get('year_built') or '-'}</b><br/><font size='8' color='#666666'>Year Built</font>", styles["Normal"]),
189 ]]
191 quick_stats_table = Table(quick_stats_data, colWidths=[1.1*inch]*4)
192 quick_stats_table.setStyle(TableStyle([
193 ("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#F3F4F6")),
194 ("ALIGN", (0, 0), (-1, -1), "CENTER"),
195 ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
196 ("TOPPADDING", (0, 0), (-1, -1), 10),
197 ("BOTTOMPADDING", (0, 0), (-1, -1), 10),
198 ("LEFTPADDING", (0, 0), (-1, -1), 5),
199 ("RIGHTPADDING", (0, 0), (-1, -1), 5),
200 ]))
201 left_column.append(quick_stats_table)
202 left_column.append(Spacer(1, 0.2 * inch))
204 # About This Property
205 if property_data.get("public_remarks"):
206 desc_title_style = ParagraphStyle(
207 "DescTitle",
208 parent=styles["Normal"],
209 fontSize=12,
210 leading=14,
211 textColor=colors.HexColor("#1a1a1a"),
212 fontName="Helvetica-Bold",
213 )
214 desc_style = ParagraphStyle(
215 "Description",
216 parent=styles["Normal"],
217 fontSize=9,
218 leading=12,
219 textColor=colors.HexColor("#333333"),
220 )
221 left_column.append(Paragraph("About This Property", desc_title_style))
222 left_column.append(Spacer(1, 0.1 * inch))
223 left_column.append(Paragraph(property_data["public_remarks"], desc_style))
224 left_column.append(Spacer(1, 0.15 * inch))
226 # Features sections
227 feature_title_style = ParagraphStyle(
228 "FeatureTitle",
229 parent=styles["Normal"],
230 fontSize=10,
231 leading=12,
232 textColor=colors.HexColor("#1a1a1a"),
233 fontName="Helvetica-Bold",
234 )
235 feature_item_style = ParagraphStyle(
236 "FeatureItem",
237 parent=styles["Normal"],
238 fontSize=8,
239 leading=11,
240 textColor=colors.HexColor("#666666"),
241 leftIndent=10,
242 )
244 # Helper function to add feature list
245 def add_feature_section(title, features_json):
246 if not features_json:
247 return
248 try:
249 import json
250 features = json.loads(features_json) if isinstance(features_json, str) else features_json
251 if features and len(features) > 0:
252 left_column.append(Paragraph(title, feature_title_style))
253 left_column.append(Spacer(1, 0.05 * inch))
254 for feature in features[:6]: # Limit to 6 features per section for space
255 left_column.append(Paragraph(f"✓ {feature}", feature_item_style))
256 left_column.append(Spacer(1, 0.1 * inch))
257 except:
258 pass
260 add_feature_section("Interior Features", property_data.get("interior_features"))
261 add_feature_section("Appliances", property_data.get("appliances"))
262 add_feature_section("Heating & Cooling", property_data.get("heating") or property_data.get("cooling"))
263 add_feature_section("Flooring", property_data.get("flooring"))
264 add_feature_section("Parking Features", property_data.get("parking_features"))
266 # ===== RIGHT COLUMN: Quick Facts Sidebar =====
267 right_column = []
269 # Quick Facts Header
270 quick_facts_header = ParagraphStyle(
271 "QuickFactsHeader",
272 parent=styles["Normal"],
273 fontSize=8,
274 textColor=colors.HexColor("#999999"),
275 letterSpacing=2,
276 )
277 right_column.append(Paragraph("QUICK FACTS", quick_facts_header))
278 right_column.append(Spacer(1, 0.15 * inch))
280 # Quick Facts data
281 quick_facts_style = ParagraphStyle(
282 "QuickFacts",
283 parent=styles["Normal"],
284 fontSize=8,
285 leading=10,
286 textColor=colors.HexColor("#666666"),
287 )
288 quick_facts_value_style = ParagraphStyle(
289 "QuickFactsValue",
290 parent=styles["Normal"],
291 fontSize=9,
292 leading=10,
293 textColor=colors.HexColor("#1a1a1a"),
294 fontName="Helvetica-Bold",
295 )
297 # Build quick facts table
298 quick_facts_data = [
299 [Paragraph("Property Type", quick_facts_style), Paragraph(str(property_data.get("property_type") or "-"), quick_facts_value_style)],
300 [Paragraph("Subtype", quick_facts_style), Paragraph(str(property_data.get("property_sub_type") or "-"), quick_facts_value_style)],
301 [Paragraph("Stories", quick_facts_style), Paragraph(str(property_data.get("stories") or "-"), quick_facts_value_style)],
302 [Paragraph("Garage", quick_facts_style), Paragraph(f"{property_data.get('garage_spaces')} car" if property_data.get("garage_spaces") else "-", quick_facts_value_style)],
303 [Paragraph("Lot Size", quick_facts_style), Paragraph(f"{property_data.get('lot_size_area', 0):.2f} ac" if property_data.get("lot_size_area") else "-", quick_facts_value_style)],
304 ]
306 quick_facts_table = Table(quick_facts_data, colWidths=[0.9*inch, 1.1*inch])
307 quick_facts_table.setStyle(TableStyle([
308 ("ALIGN", (0, 0), (0, -1), "LEFT"),
309 ("ALIGN", (1, 0), (1, -1), "RIGHT"),
310 ("TOPPADDING", (0, 0), (-1, -1), 4),
311 ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
312 ("LEFTPADDING", (0, 0), (-1, -1), 0),
313 ("RIGHTPADDING", (0, 0), (-1, -1), 0),
314 ]))
315 right_column.append(quick_facts_table)
316 right_column.append(Spacer(1, 0.1 * inch))
318 # MLS # separator and value
319 right_column.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#DDDDDD"), spaceBefore=5, spaceAfter=5))
320 mls_style = ParagraphStyle(
321 "MLS",
322 parent=styles["Normal"],
323 fontSize=7,
324 textColor=colors.HexColor("#999999"),
325 )
326 mls_value_style = ParagraphStyle(
327 "MLSValue",
328 parent=styles["Normal"],
329 fontSize=7,
330 textColor=colors.HexColor("#999999"),
331 fontName="Courier",
332 )
333 mls_table = Table([[Paragraph("MLS #", mls_style), Paragraph(str(property_data.get("listing_id")), mls_value_style)]], colWidths=[0.6*inch, 1.4*inch])
334 mls_table.setStyle(TableStyle([
335 ("ALIGN", (0, 0), (0, 0), "LEFT"),
336 ("ALIGN", (1, 0), (1, 0), "RIGHT"),
337 ("TOPPADDING", (0, 0), (-1, -1), 2),
338 ("BOTTOMPADDING", (0, 0), (-1, -1), 2),
339 ("LEFTPADDING", (0, 0), (-1, -1), 0),
340 ("RIGHTPADDING", (0, 0), (-1, -1), 0),
341 ]))
342 right_column.append(mls_table)
343 right_column.append(Spacer(1, 0.15 * inch))
345 # QR Code
346 property_url = f"{site_url}/property/{property_data.get('listing_id')}"
347 qr = qrcode.QRCode(version=1, box_size=6, border=2, error_correction=qrcode.constants.ERROR_CORRECT_H)
348 qr.add_data(property_url)
349 qr.make(fit=True)
350 qr_img = qr.make_image(fill_color="black", back_color="white")
352 qr_buffer = io.BytesIO()
353 qr_img.save(qr_buffer, format="PNG")
354 qr_buffer.seek(0)
355 qr_rl_img = RLImage(qr_buffer, width=1.5*inch, height=1.5*inch)
357 qr_label_style = ParagraphStyle(
358 "QRLabel",
359 parent=styles["Normal"],
360 fontSize=7,
361 textColor=colors.HexColor("#666666"),
362 alignment=1, # Center
363 )
364 url_style = ParagraphStyle(
365 "URLStyle",
366 parent=styles["Normal"],
367 fontSize=6,
368 textColor=colors.HexColor("#999999"),
369 wordWrap='CJK',
370 alignment=1, # Center
371 )
373 right_column.append(Paragraph("Scan to schedule a tour<br/>and see more photos", qr_label_style))
374 right_column.append(Spacer(1, 0.05 * inch))
375 # Center the QR code
376 qr_table = Table([[qr_rl_img]], colWidths=[2*inch])
377 qr_table.setStyle(TableStyle([
378 ("ALIGN", (0, 0), (0, 0), "CENTER"),
379 ]))
380 right_column.append(qr_table)
381 right_column.append(Spacer(1, 0.05 * inch))
382 right_column.append(Paragraph(property_url, url_style))
384 # Contact Information Box
385 right_column.append(Spacer(1, 0.2 * inch))
387 # Contact box background style
388 contact_box_style = ParagraphStyle(
389 "ContactBox",
390 parent=styles["Normal"],
391 fontSize=7,
392 leading=10,
393 textColor=colors.HexColor("#FFFFFF"),
394 alignment=1, # Center
395 )
397 contact_header_style = ParagraphStyle(
398 "ContactHeader",
399 parent=styles["Normal"],
400 fontSize=8,
401 leading=10,
402 textColor=colors.HexColor("#FFFFFF"),
403 alignment=1, # Center
404 )
406 contact_main_style = ParagraphStyle(
407 "ContactMain",
408 parent=styles["Normal"],
409 fontSize=9,
410 leading=11,
411 textColor=colors.HexColor("#FFFFFF"),
412 alignment=1, # Center
413 fontName="Helvetica-Bold",
414 )
416 # Build contact box content
417 contact = site_config.get("contact", {})
418 contact_box_content = []
420 contact_box_content.append(Paragraph("Ready to see this home?", contact_header_style))
421 contact_box_content.append(Spacer(1, 0.05 * inch))
423 broker_name = site_config.get("site", {}).get("name", "")
424 if broker_name:
425 contact_box_content.append(Paragraph(f"<b>{broker_name}</b>", contact_main_style))
426 contact_box_content.append(Spacer(1, 0.05 * inch))
428 if contact.get("phoneDisplay"):
429 contact_box_content.append(Paragraph(contact["phoneDisplay"], contact_main_style))
430 contact_box_content.append(Spacer(1, 0.02 * inch))
432 if contact.get("email"):
433 contact_box_content.append(Paragraph(contact["email"], contact_box_style))
435 # Create dark background box for contact info
436 contact_box_table = Table([[contact_box_content]], colWidths=[2*inch])
437 contact_box_table.setStyle(TableStyle([
438 ("BACKGROUND", (0, 0), (0, 0), colors.HexColor("#1e293b")),
439 ("ALIGN", (0, 0), (0, 0), "CENTER"),
440 ("VALIGN", (0, 0), (0, 0), "MIDDLE"),
441 ("TOPPADDING", (0, 0), (0, 0), 15),
442 ("BOTTOMPADDING", (0, 0), (0, 0), 15),
443 ("LEFTPADDING", (0, 0), (0, 0), 10),
444 ("RIGHTPADDING", (0, 0), (0, 0), 10),
445 ]))
446 right_column.append(contact_box_table)
448 # Combine left and right columns in a two-column table
449 main_layout_table = Table([[left_column, right_column]], colWidths=[4.8*inch, 2.2*inch])
450 main_layout_table.setStyle(TableStyle([
451 ("VALIGN", (0, 0), (-1, -1), "TOP"),
452 ("LEFTPADDING", (0, 0), (0, 0), 0),
453 ("RIGHTPADDING", (0, 0), (0, 0), 15),
454 ("LEFTPADDING", (1, 0), (1, 0), 15),
455 ("RIGHTPADDING", (1, 0), (1, 0), 0),
456 # Add a vertical separator line between columns
457 ("LINEAFTER", (0, 0), (0, 0), 0.5, colors.HexColor("#DDDDDD")),
458 ]))
459 story.append(main_layout_table)
461 # ===== ADDITIONAL PAGES: Remaining Photos (3+) =====
462 # If there are more than 2 photos (hero images), show remaining photos on subsequent pages
463 if len(photo_images) > 2:
464 remaining_photos = photo_images[2:] # Start from photo 3
466 # Process remaining photos in batches of 8 (2x4 grid per page - 2 columns, 4 rows)
467 for batch_start in range(0, len(remaining_photos), 8):
468 story.append(PageBreak())
470 batch_photos = []
471 for img in remaining_photos[batch_start:batch_start+8]:
472 img_copy = img.copy()
473 img_copy.thumbnail((280, 220), Image.Resampling.LANCZOS)
474 img_buffer = io.BytesIO()
475 img_copy.save(img_buffer, format="JPEG")
476 img_buffer.seek(0)
477 rl_img = RLImage(img_buffer, width=2.8*inch, height=2.2*inch)
478 batch_photos.append(rl_img)
480 # Arrange in 2-column grid (2 photos per row, 4 rows)
481 photo_grid = []
482 for i in range(0, len(batch_photos), 2):
483 row = batch_photos[i:i+2]
484 photo_grid.append(row)
486 photo_table = Table(photo_grid, colWidths=[3*inch, 3*inch])
487 photo_table.setStyle(TableStyle([
488 ("VALIGN", (0, 0), (-1, -1), "TOP"),
489 ("LEFTPADDING", (0, 0), (-1, -1), 5),
490 ("RIGHTPADDING", (0, 0), (-1, -1), 5),
491 ("TOPPADDING", (0, 0), (-1, -1), 5),
492 ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
493 ]))
494 story.append(photo_table)
496 # Simple Footer
497 story.append(Spacer(1, 0.5 * inch))
499 # Add separator line
500 story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor("#CCCCCC"), spaceBefore=0, spaceAfter=0.15*inch))
502 footer_style = ParagraphStyle(
503 "Footer",
504 parent=styles["Normal"],
505 fontSize=7,
506 leading=10,
507 textColor=colors.HexColor("#999999"),
508 alignment=1, # Center
509 )
511 # Tagline and timestamp
512 broker_name = site_config.get("site", {}).get("name", "")
513 broker_tagline = site_config.get("site", {}).get("tagline", "")
514 license_info = site_config.get("contact", {}).get("license", {})
516 footer_parts = []
517 if broker_name and broker_tagline:
518 footer_parts.append(f"{broker_name} • {broker_tagline}")
519 elif broker_name:
520 footer_parts.append(broker_name)
522 if license_info.get("type") and license_info.get("number"):
523 footer_parts.append(f"{license_info['type']} #{license_info['number']}")
525 footer_parts.append(f"Generated {datetime.now().strftime('%B %d, %Y at %I:%M %p')}")
527 if footer_parts:
528 story.append(Paragraph(" • ".join(footer_parts), footer_style))
530 # Build PDF
531 doc.build(story)
532 buffer.seek(0)
533 return buffer.read()