This commit is contained in:
HotSwapp
2025-08-18 20:20:04 -05:00
parent 89b2bc0aa2
commit bac8cc4bd5
114 changed files with 30258 additions and 1341 deletions

229
app/services/mailing.py Normal file
View 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),
}