230 lines
7.7 KiB
Python
230 lines
7.7 KiB
Python
"""
|
|
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('<div class="label"></div>')
|
|
for addr in addresses:
|
|
lines = addr.compact_lines(include_name=include_name)
|
|
inner = "".join([f"<p>{line}</p>" for line in lines if line])
|
|
blocks.append(f'<div class="label">{inner}</div>')
|
|
css = _labels_5160_css()
|
|
html = f"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset=\"utf-8\" />
|
|
<title>Mailing Labels (Avery 5160)</title>
|
|
<style>{css}</style>
|
|
<meta name=\"generator\" content=\"delphi\" />
|
|
</head>
|
|
<body>
|
|
<div class=\"hint\">Avery 5160 — 30 labels per sheet. Print at 100% scale. Do not fit to page.</div>
|
|
<div class=\"sheet\">{''.join(blocks)}</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
return html.encode("utf-8")
|
|
|
|
|
|
def _envelope_css() -> str:
|
|
# Simple layout: place return address top-left, recipient in center-right area.
|
|
return """
|
|
@page { size: letter; margin: 0.5in; }
|
|
body { font-family: Arial, sans-serif; margin: 0; }
|
|
.envelope { position: relative; width: 9.5in; height: 4.125in; border: 1px dashed #ddd; margin: 0 auto; }
|
|
.return { position: absolute; top: 0.5in; left: 0.6in; font-size: 10pt; line-height: 1.2; }
|
|
.recipient { position: absolute; top: 1.6in; left: 3.7in; font-size: 12pt; line-height: 1.25; }
|
|
.envelope p { margin: 0; }
|
|
.page { page-break-after: always; margin: 0 0 12px 0; }
|
|
.hint { margin: 12px 0; color: #666; font-size: 10pt; }
|
|
"""
|
|
|
|
|
|
def render_envelopes_html(
|
|
addresses: Sequence[Address],
|
|
*,
|
|
return_address_lines: Optional[Sequence[str]] = None,
|
|
include_name: bool = True,
|
|
) -> bytes:
|
|
css = _envelope_css()
|
|
pages: List[str] = []
|
|
return_html = "".join([f"<p>{line}</p>" 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"<p>{line}</p>" for line in to_lines if line])
|
|
page = f"""
|
|
<div class=\"page\">
|
|
<div class=\"envelope\">
|
|
{'<div class=\"return\">' + return_html + '</div>' if return_html else ''}
|
|
<div class=\"recipient\">{to_html}</div>
|
|
</div>
|
|
</div>
|
|
"""
|
|
pages.append(page)
|
|
html = f"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset=\"utf-8\" />
|
|
<title>Envelopes (No. 10)</title>
|
|
<style>{css}</style>
|
|
<meta name=\"generator\" content=\"delphi\" />
|
|
</head>
|
|
<body>
|
|
<div class=\"hint\">No. 10 envelope layout. Print at 100% scale.</div>
|
|
{''.join(pages)}
|
|
</body>
|
|
</html>
|
|
"""
|
|
return html.encode("utf-8")
|
|
|
|
|
|
def save_html_bytes(content: bytes, *, filename_hint: str, subdir: str) -> dict:
|
|
storage = get_default_storage()
|
|
storage_path = storage.save_bytes(
|
|
content=content,
|
|
filename_hint=filename_hint if filename_hint.endswith(".html") else f"{filename_hint}.html",
|
|
subdir=subdir,
|
|
content_type="text/html",
|
|
)
|
|
url = storage.public_url(storage_path)
|
|
return {
|
|
"storage_path": storage_path,
|
|
"url": url,
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
"mime_type": "text/html",
|
|
"size": len(content),
|
|
}
|
|
|
|
|