204000aabc
Resolve 3 merge conflicts and renumber the automation_hooks migration to follow main's migration chain (036_add_document_translation_fields). Conflicts resolved: - app/api/__init__.py: add automation_router alongside main's new routers - app/utils/settings_service.py: add automation_hooks_enabled alongside compliance_enabled - tests/conftest.py: add AutomationHook alongside AuditLog/ComplianceTemplate imports Migration renumbered: - 027_add_automation_hooks → 037_add_automation_hooks - down_revision: 026_add_scheduled_jobs → 036_add_document_translation_fields Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
131 lines
4.2 KiB
Python
131 lines
4.2 KiB
Python
"""Push notification sender for the DocuElevate mobile app.
|
||
|
||
Uses the **Expo Push Notification** service to deliver notifications to both
|
||
iOS (via APNs) and Android (via FCM) without requiring server-side APNs keys
|
||
or FCM credentials. The mobile app obtains an ``ExponentPushToken[…]`` at
|
||
startup and registers it with the backend via the mobile API.
|
||
|
||
Reference: https://docs.expo.dev/push-notifications/sending-notifications/
|
||
"""
|
||
|
||
import logging
|
||
from typing import Any
|
||
|
||
import httpx
|
||
|
||
from app.database import SessionLocal
|
||
from app.models import MobileDevice
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send"
|
||
|
||
# Maximum tokens per batch request (Expo limit).
|
||
_EXPO_BATCH_LIMIT = 100
|
||
|
||
|
||
def send_expo_push_notification(
|
||
tokens: list[str],
|
||
title: str,
|
||
body: str,
|
||
data: dict[str, Any] | None = None,
|
||
sound: str = "default",
|
||
badge: int | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
"""Send a push notification to one or more Expo push tokens.
|
||
|
||
Args:
|
||
tokens: List of Expo push tokens (``ExponentPushToken[…]``).
|
||
title: Notification title shown in the system tray.
|
||
body: Notification body text.
|
||
data: Optional JSON-serialisable dict attached to the notification
|
||
(available in the app via ``notification.request.content.data``).
|
||
sound: Notification sound. Use ``"default"`` or ``None`` for silent.
|
||
badge: iOS badge count. Pass ``0`` to clear.
|
||
|
||
Returns:
|
||
List of Expo push receipt dicts (one per token).
|
||
"""
|
||
if not tokens:
|
||
return []
|
||
|
||
results: list[dict[str, Any]] = []
|
||
|
||
# Send in batches to stay within Expo's per-request limit.
|
||
for i in range(0, len(tokens), _EXPO_BATCH_LIMIT):
|
||
batch = tokens[i : i + _EXPO_BATCH_LIMIT]
|
||
messages = []
|
||
for token in batch:
|
||
msg: dict[str, Any] = {
|
||
"to": token,
|
||
"title": title,
|
||
"body": body,
|
||
"sound": sound,
|
||
}
|
||
if data:
|
||
msg["data"] = data
|
||
if badge is not None:
|
||
msg["badge"] = badge
|
||
messages.append(msg)
|
||
|
||
try:
|
||
resp = httpx.post(
|
||
EXPO_PUSH_URL,
|
||
json=messages,
|
||
headers={
|
||
"Accept": "application/json",
|
||
"Accept-Encoding": "gzip, deflate",
|
||
"Content-Type": "application/json",
|
||
},
|
||
timeout=15,
|
||
)
|
||
resp.raise_for_status()
|
||
payload = resp.json()
|
||
batch_results = payload.get("data", [])
|
||
results.extend(batch_results)
|
||
logger.debug("Expo push batch sent: %d tokens, %d results", len(batch), len(batch_results))
|
||
except httpx.HTTPStatusError as exc:
|
||
logger.error("Expo push HTTP error: %s – %s", exc.response.status_code, exc.response.text)
|
||
except Exception:
|
||
logger.exception("Expo push notification failed for batch starting at index %d", i)
|
||
|
||
return results
|
||
|
||
|
||
def send_push_to_owner(
|
||
owner_id: str,
|
||
title: str,
|
||
body: str,
|
||
data: dict[str, Any] | None = None,
|
||
) -> None:
|
||
"""Look up all active push tokens for *owner_id* and send them a notification.
|
||
|
||
This function is safe to call from Celery task workers. Database errors
|
||
and push failures are logged but never raised so that the caller task is
|
||
not retried due to a notification failure.
|
||
"""
|
||
db = SessionLocal()
|
||
try:
|
||
devices = (
|
||
db.query(MobileDevice)
|
||
.filter(
|
||
MobileDevice.owner_id == owner_id,
|
||
MobileDevice.is_active.is_(True),
|
||
MobileDevice.push_token.isnot(None),
|
||
)
|
||
.all()
|
||
)
|
||
tokens = [d.push_token for d in devices if d.push_token]
|
||
except Exception:
|
||
logger.exception("Failed to query mobile devices for owner_id=%s", owner_id)
|
||
return
|
||
finally:
|
||
db.close()
|
||
|
||
if not tokens:
|
||
logger.debug("No active push tokens for owner_id=%s", owner_id)
|
||
return
|
||
|
||
logger.info("Sending push notification to %d device(s) for owner_id=%s", len(tokens), owner_id)
|
||
send_expo_push_notification(tokens=tokens, title=title, body=body, data=data)
|