finishing QDRO section
This commit is contained in:
213
app/services/notification.py
Normal file
213
app/services/notification.py
Normal 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
86
app/services/storage.py
Normal 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()
|
||||
|
||||
|
||||
112
app/services/template_merge.py
Normal file
112
app/services/template_merge.py
Normal 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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user