changes
This commit is contained in:
229
app/services/mailing.py
Normal file
229
app/services/mailing.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user