""" Mailing utilities for generating printable labels and envelopes. MVP scope: - Build address blocks from `Rolodex` entries - Generate printable HTML for Avery 5160 labels (3 x 10) - Generate simple envelope HTML (No. 10) with optional return address - Save bytes via storage adapter for easy download at /uploads """ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timezone from typing import Iterable, List, Optional, Sequence from sqlalchemy.orm import Session from app.models.rolodex import Rolodex from app.models.files import File from app.services.storage import get_default_storage @dataclass class Address: display_name: str line1: Optional[str] = None line2: Optional[str] = None line3: Optional[str] = None city: Optional[str] = None state: Optional[str] = None postal_code: Optional[str] = None def compact_lines(self, include_name: bool = True) -> List[str]: lines: List[str] = [] if include_name and self.display_name: lines.append(self.display_name) for part in [self.line1, self.line2, self.line3]: if part: lines.append(part) city_state_zip: List[str] = [] if self.city: city_state_zip.append(self.city) if self.state: city_state_zip.append(self.state) if self.postal_code: city_state_zip.append(self.postal_code) if city_state_zip: # Join as "City, ST ZIP" when state and city present, otherwise simple join if self.city and self.state: last = " ".join([p for p in [self.state, self.postal_code] if p]) lines.append(f"{self.city}, {last}".strip()) else: lines.append(" ".join(city_state_zip)) return lines def build_address_from_rolodex(entry: Rolodex) -> Address: name_parts: List[str] = [] if getattr(entry, "prefix", None): name_parts.append(entry.prefix) if getattr(entry, "first", None): name_parts.append(entry.first) if getattr(entry, "middle", None): name_parts.append(entry.middle) # Always include last/company if getattr(entry, "last", None): name_parts.append(entry.last) if getattr(entry, "suffix", None): name_parts.append(entry.suffix) display_name = " ".join([p for p in name_parts if p]).strip() return Address( display_name=display_name or (entry.last or ""), line1=getattr(entry, "a1", None), line2=getattr(entry, "a2", None), line3=getattr(entry, "a3", None), city=getattr(entry, "city", None), state=getattr(entry, "abrev", None), postal_code=getattr(entry, "zip", None), ) def build_addresses_from_files(db: Session, file_nos: Sequence[str]) -> List[Address]: if not file_nos: return [] files = ( db.query(File) .filter(File.file_no.in_([fn for fn in file_nos if fn])) .all() ) addresses: List[Address] = [] # Resolve owners in one extra query across unique owner ids owner_ids = list({f.id for f in files if getattr(f, "id", None)}) if owner_ids: owners_by_id = { r.id: r for r in db.query(Rolodex).filter(Rolodex.id.in_(owner_ids)).all() } else: owners_by_id = {} for f in files: owner = owners_by_id.get(getattr(f, "id", None)) if owner: addresses.append(build_address_from_rolodex(owner)) return addresses def build_addresses_from_rolodex(db: Session, rolodex_ids: Sequence[str]) -> List[Address]: if not rolodex_ids: return [] entries = ( db.query(Rolodex) .filter(Rolodex.id.in_([rid for rid in rolodex_ids if rid])) .all() ) return [build_address_from_rolodex(r) for r in entries] def _labels_5160_css() -> str: # 3 columns x 10 rows; label size 2.625" x 1.0"; sheet Letter 8.5"x11" # Basic approximated layout suitable for quick printing. return """ @page { size: letter; margin: 0.5in; } body { font-family: Arial, sans-serif; margin: 0; } .sheet { display: grid; grid-template-columns: repeat(3, 2.625in); grid-auto-rows: 1in; column-gap: 0.125in; row-gap: 0.0in; } .label { box-sizing: border-box; padding: 0.1in 0.15in; overflow: hidden; } .label p { margin: 0; line-height: 1.1; font-size: 11pt; } .hint { margin: 12px 0; color: #666; font-size: 10pt; } """ def render_labels_html(addresses: Sequence[Address], *, start_position: int = 1, include_name: bool = True) -> bytes: # Fill with empty slots up to start_position - 1 to allow partial sheets blocks: List[str] = [] empty_slots = max(0, min(29, (start_position - 1))) for _ in range(empty_slots): blocks.append('
') for addr in addresses: lines = addr.compact_lines(include_name=include_name) inner = "".join([f"{line}
" for line in lines if line]) blocks.append(f'{line}
" for line in (return_address_lines or []) if line]) for addr in addresses: to_lines = addr.compact_lines(include_name=include_name) to_html = "".join([f"{line}
" for line in to_lines if line]) page = f"""