finishing QDRO section

This commit is contained in:
HotSwapp
2025-08-15 17:19:51 -05:00
parent 006ef3d7b1
commit abc7f289d1
22 changed files with 2753 additions and 46 deletions

View File

@@ -0,0 +1,213 @@
"""
Notification service with pluggable adapters (email, webhook).
Sends best-effort, non-blocking notifications for domain events such as
QDRO status transitions. Failures are logged and never raise.
"""
from __future__ import annotations
import json
import hmac
import hashlib
import smtplib
from email.message import EmailMessage
from typing import Any, Dict, Iterable, List, Optional
import httpx
from app.config import settings
from sqlalchemy.orm import Session
from app.models.lookups import SystemSetup
class NotificationAdapter:
def send(self, event_type: str, payload: Dict[str, Any]) -> None:
raise NotImplementedError
class EmailAdapter(NotificationAdapter):
def __init__(
self,
*,
smtp_host: str,
smtp_port: int = 587,
username: Optional[str] = None,
password: Optional[str] = None,
starttls: bool = True,
send_from: str = "no-reply@localhost",
default_recipients: Optional[List[str]] = None,
) -> None:
self.smtp_host = smtp_host
self.smtp_port = smtp_port
self.username = username
self.password = password
self.starttls = starttls
self.send_from = send_from
self.default_recipients = default_recipients or []
def send(self, event_type: str, payload: Dict[str, Any]) -> None: # pragma: no cover - exercised via service
override = bool(payload.get("__notify_override"))
recipients: List[str] = [] if override else list(self.default_recipients)
# Allow payload to specify recipients override
extra_to = payload.get("__notify_to")
if isinstance(extra_to, str):
recipients.extend([addr.strip() for addr in extra_to.split(",") if addr.strip()])
elif isinstance(extra_to, list):
recipients.extend([str(x).strip() for x in extra_to if str(x).strip()])
if not recipients:
return
subject = f"{event_type}"
body = json.dumps(payload, default=str, indent=2)
msg = EmailMessage()
msg["From"] = self.send_from
msg["To"] = ", ".join(recipients)
msg["Subject"] = subject
msg.set_content(body)
with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=10) as smtp:
if self.starttls:
try:
smtp.starttls()
except Exception:
# Continue without TLS if not supported
pass
if self.username and self.password:
smtp.login(self.username, self.password)
smtp.send_message(msg)
class WebhookAdapter(NotificationAdapter):
def __init__(self, *, url: str, secret: Optional[str] = None, timeout: float = 5.0) -> None:
self.url = url
self.secret = secret
self.timeout = timeout
def _signature(self, body: bytes) -> Optional[str]:
if not self.secret:
return None
sig = hmac.new(self.secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
return f"sha256={sig}"
def send(self, event_type: str, payload: Dict[str, Any]) -> None: # pragma: no cover - exercised via service
body = json.dumps({"type": event_type, "payload": payload}, default=str).encode("utf-8")
headers = {"Content-Type": "application/json"}
sig = self._signature(body)
if sig:
headers["X-Signature"] = sig
try:
with httpx.Client(timeout=self.timeout) as client:
client.post(self.url, content=body, headers=headers)
except Exception:
# Swallow errors by design
pass
class NotificationService:
def __init__(self, adapters: Iterable[NotificationAdapter]) -> None:
self.adapters = list(adapters)
def emit(self, event_type: str, payload: Dict[str, Any]) -> None:
for adapter in self.adapters:
try:
# If a per-event webhook override is present and this is a default webhook adapter
# and override flag is set, skip default webhook send
if isinstance(adapter, WebhookAdapter) and payload.get("__webhook_url") and payload.get("__webhook_override"):
continue
adapter.send(event_type, payload)
except Exception:
# Never block or raise from notification adapters
continue
# If explicit webhook override is provided, send a one-off webhook request
if payload.get("__webhook_url"):
try:
WebhookAdapter(
url=str(payload.get("__webhook_url")),
secret=str(payload.get("__webhook_secret")) if payload.get("__webhook_secret") else None,
).send(event_type, payload)
except Exception:
pass
def build_default_notification_service() -> NotificationService:
if not settings.notifications_enabled:
return NotificationService([])
adapters: List[NotificationAdapter] = []
# Email adapter if SMTP host is configured
if settings.smtp_host:
default_to: List[str] = []
if settings.qdro_notify_email_to:
default_to = [addr.strip() for addr in settings.qdro_notify_email_to.split(",") if addr.strip()]
email_adapter = EmailAdapter(
smtp_host=settings.smtp_host,
smtp_port=settings.smtp_port,
username=settings.smtp_username,
password=settings.smtp_password,
starttls=settings.smtp_starttls,
send_from=settings.notification_email_from,
default_recipients=default_to,
)
adapters.append(email_adapter)
# Webhook adapter if URL is configured
if settings.qdro_notify_webhook_url:
adapters.append(
WebhookAdapter(
url=settings.qdro_notify_webhook_url,
secret=settings.qdro_notify_webhook_secret,
)
)
return NotificationService(adapters)
# Singleton for app code to import
notification_service = build_default_notification_service()
def _get_setting(db: Session, key: str) -> Optional[str]:
row = db.query(SystemSetup).filter(SystemSetup.setting_key == key).first()
return row.setting_value if row else None
def resolve_qdro_routes(
db: Session,
*,
file_no: Optional[str],
plan_id: Optional[str],
) -> Dict[str, Optional[Any]]:
"""
Resolve per-file or per-plan routing from SystemSetup.
Precedence: file-specific overrides win over plan-specific, which win over defaults.
Returns a dict with keys: email_to (comma-separated string), webhook_url, webhook_secret.
"""
email_to: Optional[str] = None
webhook_url: Optional[str] = None
webhook_secret: Optional[str] = None
# File overrides
if file_no:
email_to = _get_setting(db, f"notifications.qdro.email.to.file.{file_no}") or email_to
webhook_url = _get_setting(db, f"notifications.qdro.webhook.url.file.{file_no}") or webhook_url
webhook_secret = _get_setting(db, f"notifications.qdro.webhook.secret.file.{file_no}") or webhook_secret
# Plan overrides (only if not set by file)
if plan_id:
if email_to is None:
email_to = _get_setting(db, f"notifications.qdro.email.to.plan.{plan_id}") or email_to
if webhook_url is None:
webhook_url = _get_setting(db, f"notifications.qdro.webhook.url.plan.{plan_id}") or webhook_url
if webhook_secret is None:
webhook_secret = _get_setting(db, f"notifications.qdro.webhook.secret.plan.{plan_id}") or webhook_secret
return {
"email_to": email_to,
"webhook_url": webhook_url,
"webhook_secret": webhook_secret,
}

86
app/services/storage.py Normal file
View File

@@ -0,0 +1,86 @@
"""
Storage abstraction for templates/documents.
MVP: Local filesystem implementation; S3-compatible interface ready.
"""
from __future__ import annotations
import os
import uuid
from typing import Optional
from app.config import settings
class StorageAdapter:
"""Abstract storage adapter."""
def save_bytes(self, *, content: bytes, filename_hint: str, subdir: Optional[str] = None, content_type: Optional[str] = None) -> str:
raise NotImplementedError
def open_bytes(self, storage_path: str) -> bytes:
raise NotImplementedError
def delete(self, storage_path: str) -> bool:
raise NotImplementedError
def exists(self, storage_path: str) -> bool:
raise NotImplementedError
def public_url(self, storage_path: str) -> Optional[str]:
return None
class LocalStorageAdapter(StorageAdapter):
"""Store bytes under settings.upload_dir using relative storage_path."""
def __init__(self, base_dir: Optional[str] = None) -> None:
self.base_dir = os.path.abspath(base_dir or settings.upload_dir)
def _ensure_dir(self, directory: str) -> None:
os.makedirs(directory, exist_ok=True)
def save_bytes(self, *, content: bytes, filename_hint: str, subdir: Optional[str] = None, content_type: Optional[str] = None) -> str:
safe_name = filename_hint.replace("/", "_").replace("\\", "_")
if not os.path.splitext(safe_name)[1]:
# Ensure a default extension when missing
safe_name = f"{safe_name}.bin"
unique = uuid.uuid4().hex
directory = os.path.join(self.base_dir, subdir) if subdir else self.base_dir
self._ensure_dir(directory)
final_name = f"{unique}_{safe_name}"
abs_path = os.path.join(directory, final_name)
with open(abs_path, "wb") as f:
f.write(content)
# Return storage path relative to base_dir for portability
rel_path = os.path.relpath(abs_path, self.base_dir)
return rel_path
def open_bytes(self, storage_path: str) -> bytes:
abs_path = os.path.join(self.base_dir, storage_path)
with open(abs_path, "rb") as f:
return f.read()
def delete(self, storage_path: str) -> bool:
abs_path = os.path.join(self.base_dir, storage_path)
try:
os.remove(abs_path)
return True
except FileNotFoundError:
return False
def exists(self, storage_path: str) -> bool:
abs_path = os.path.join(self.base_dir, storage_path)
return os.path.exists(abs_path)
def public_url(self, storage_path: str) -> Optional[str]:
# Uploads are mounted at /uploads in FastAPI main
# Map base_dir to /uploads; when base_dir is settings.upload_dir, this works.
return f"/uploads/{storage_path}".replace("\\", "/")
def get_default_storage() -> StorageAdapter:
# MVP: always local storage
return LocalStorageAdapter()

View File

@@ -0,0 +1,112 @@
"""
Template variable resolution and DOCX preview using docxtpl.
MVP features:
- Resolve variables from explicit context, FormVariable, ReportVariable
- Built-in variables (dates)
- Render DOCX using docxtpl when mime_type is docx; otherwise return bytes as-is
- Return unresolved tokens list
"""
from __future__ import annotations
import io
import re
from datetime import date, datetime
from typing import Any, Dict, List, Tuple
from sqlalchemy.orm import Session
from app.models.additional import FormVariable, ReportVariable
try:
from docxtpl import DocxTemplate
DOCXTPL_AVAILABLE = True
except Exception:
DOCXTPL_AVAILABLE = False
TOKEN_PATTERN = re.compile(r"\{\{\s*([a-zA-Z0-9_\.]+)\s*\}\}")
def extract_tokens_from_bytes(content: bytes) -> List[str]:
# Prefer docxtpl-based extraction for DOCX if available
if DOCXTPL_AVAILABLE:
try:
buf = io.BytesIO(content)
tpl = DocxTemplate(buf)
# jinja2 analysis for undeclared template variables
vars_set = tpl.get_undeclared_template_variables({})
return sorted({str(v) for v in vars_set})
except Exception:
pass
# Fallback: naive regex over decoded text
try:
text = content.decode("utf-8", errors="ignore")
except Exception:
text = ""
return sorted({m.group(1) for m in TOKEN_PATTERN.finditer(text)})
def build_context(payload_context: Dict[str, Any]) -> Dict[str, Any]:
# Built-ins
today = date.today()
builtins = {
"TODAY": today.strftime("%B %d, %Y"),
"TODAY_ISO": today.isoformat(),
"NOW": datetime.utcnow().isoformat() + "Z",
}
merged = {**builtins}
# Normalize keys to support both FOO and foo
for k, v in payload_context.items():
merged[k] = v
if isinstance(k, str):
merged.setdefault(k.upper(), v)
return merged
def _safe_lookup_variable(db: Session, identifier: str) -> Any:
# 1) FormVariable
fv = db.query(FormVariable).filter(FormVariable.identifier == identifier, FormVariable.active == 1).first()
if fv:
# MVP: use static response if present; otherwise treat as unresolved
if fv.response is not None:
return fv.response
return None
# 2) ReportVariable
rv = db.query(ReportVariable).filter(ReportVariable.identifier == identifier, ReportVariable.active == 1).first()
if rv:
# MVP: no evaluation yet; unresolved
return None
return None
def resolve_tokens(db: Session, tokens: List[str], context: Dict[str, Any]) -> Tuple[Dict[str, Any], List[str]]:
resolved: Dict[str, Any] = {}
unresolved: List[str] = []
for tok in tokens:
# Order: payload context (case-insensitive via upper) -> FormVariable -> ReportVariable
value = context.get(tok)
if value is None:
value = context.get(tok.upper())
if value is None:
value = _safe_lookup_variable(db, tok)
if value is None:
unresolved.append(tok)
else:
resolved[tok] = value
return resolved, unresolved
def render_docx(docx_bytes: bytes, context: Dict[str, Any]) -> bytes:
if not DOCXTPL_AVAILABLE:
# Return original bytes if docxtpl is not installed
return docx_bytes
# Write to BytesIO for docxtpl
in_buffer = io.BytesIO(docx_bytes)
tpl = DocxTemplate(in_buffer)
tpl.render(context)
out_buffer = io.BytesIO()
tpl.save(out_buffer)
return out_buffer.getvalue()