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

1"""PDF brochure generator for property listings.""" 

2 

3import io 

4import os 

5from datetime import datetime 

6from typing import Any 

7 

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) 

25 

26 

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. 

29 

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 

34 

35 Returns: 

36 PDF file as bytes 

37 """ 

38 if site_config is None: 

39 site_config = {"site": {"name": ""}, "contact": {}, "branding": {}} 

40 

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 ) 

50 

51 # Build PDF content 

52 story = [] 

53 styles = getSampleStyleSheet() 

54 

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 ) 

63 

64 subtitle_style = ParagraphStyle( 

65 "PropertySubtitle", 

66 parent=styles["Normal"], 

67 fontSize=14, 

68 textColor=colors.HexColor("#666666"), 

69 spaceAfter=12, 

70 ) 

71 

72 price_style = ParagraphStyle( 

73 "Price", 

74 parent=styles["Heading2"], 

75 fontSize=20, 

76 textColor=colors.HexColor("#f97316"), 

77 spaceAfter=12, 

78 ) 

79 

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 ) 

88 

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" 

96 

97 city_state = f"{property_data.get('city', '')}, {property_data.get('state_or_province', '')} {property_data.get('postal_code', '')}" 

98 

99 # ===== PAGE 1: Header, Price, Hero Image, Key Stats, QR Code ===== 

100 

101 # Header 

102 story.append(Paragraph(address, title_style)) 

103 story.append(Paragraph(city_state.strip(), subtitle_style)) 

104 

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)) 

110 

111 story.append(Spacer(1, 0.2 * inch)) 

112 

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 = [] 

121 

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}" 

129 

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 

138 

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) 

151 

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]) 

163 

164 story.append(Spacer(1, 0.3 * inch)) 

165 

166 # ===== TWO-COLUMN LAYOUT: Full Details (left) + Quick Facts Sidebar (right) ===== 

167 story.append(Spacer(1, 0.2 * inch)) 

168 

169 # ===== LEFT COLUMN: Full Details ===== 

170 left_column = [] 

171 

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)) 

182 

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 ]] 

190 

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)) 

203 

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)) 

225 

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 ) 

243 

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 

259 

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")) 

265 

266 # ===== RIGHT COLUMN: Quick Facts Sidebar ===== 

267 right_column = [] 

268 

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)) 

279 

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 ) 

296 

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 ] 

305 

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)) 

317 

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)) 

344 

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") 

351 

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) 

356 

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 ) 

372 

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)) 

383 

384 # Contact Information Box 

385 right_column.append(Spacer(1, 0.2 * inch)) 

386 

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 ) 

396 

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 ) 

405 

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 ) 

415 

416 # Build contact box content 

417 contact = site_config.get("contact", {}) 

418 contact_box_content = [] 

419 

420 contact_box_content.append(Paragraph("Ready to see this home?", contact_header_style)) 

421 contact_box_content.append(Spacer(1, 0.05 * inch)) 

422 

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)) 

427 

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)) 

431 

432 if contact.get("email"): 

433 contact_box_content.append(Paragraph(contact["email"], contact_box_style)) 

434 

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) 

447 

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) 

460 

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 

465 

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()) 

469 

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) 

479 

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) 

485 

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) 

495 

496 # Simple Footer 

497 story.append(Spacer(1, 0.5 * inch)) 

498 

499 # Add separator line 

500 story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor("#CCCCCC"), spaceBefore=0, spaceAfter=0.15*inch)) 

501 

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 ) 

510 

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", {}) 

515 

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) 

521 

522 if license_info.get("type") and license_info.get("number"): 

523 footer_parts.append(f"{license_info['type']} #{license_info['number']}") 

524 

525 footer_parts.append(f"Generated {datetime.now().strftime('%B %d, %Y at %I:%M %p')}") 

526 

527 if footer_parts: 

528 story.append(Paragraph(" • ".join(footer_parts), footer_style)) 

529 

530 # Build PDF 

531 doc.build(story) 

532 buffer.seek(0) 

533 return buffer.read()