Files
security-alert-center/backend/app/services/mobile_notify.py
T
PapaTramp 563b836acc feat: Seaca mobile API, enrollment, FCM push and admin UI (0.9.0)
Adds mobile device registration by admin codes, refresh tokens, push channel
in notification policy, and Settings section for managing Seaca clients.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 13:19:14 +10:00

207 lines
6.4 KiB
Python

"""Push-уведомления Seaca через Firebase Cloud Messaging (HTTP v1)."""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from pathlib import Path
import httpx
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models import Event, Problem
from app.models.mobile_device import MobileDevice
from app.services.notification_severity import severity_meets_minimum
from app.services.notification_policy import get_effective_notification_policy
logger = logging.getLogger(__name__)
FCM_SCOPE = "https://www.googleapis.com/auth/firebase.messaging"
class MobileNotConfiguredError(Exception):
"""FCM отключён или не задан service account."""
class MobileSendError(Exception):
def __init__(self, message: str, *, status_code: int | None = None) -> None:
super().__init__(message)
self.status_code = status_code
@dataclass(frozen=True)
class FcmConfig:
enabled: bool
project_id: str
service_account_path: str
@property
def configured(self) -> bool:
return bool(self.project_id.strip() and self.service_account_path.strip())
@property
def active(self) -> bool:
return self.enabled and self.configured
def get_effective_fcm_config() -> FcmConfig:
settings = get_settings()
return FcmConfig(
enabled=bool(settings.sac_fcm_enabled),
project_id=(settings.sac_fcm_project_id or "").strip(),
service_account_path=(settings.sac_fcm_service_account_json or "").strip(),
)
def _load_service_account(path: str) -> dict:
file_path = Path(path)
if not file_path.is_file():
raise MobileNotConfiguredError(f"FCM service account file not found: {path}")
with file_path.open(encoding="utf-8") as handle:
return json.load(handle)
def _fcm_access_token(service_account_path: str) -> str:
try:
from google.oauth2 import service_account
from google.auth.transport.requests import Request
except ImportError as exc:
raise MobileNotConfiguredError("google-auth package is required for FCM") from exc
creds = service_account.Credentials.from_service_account_file(
service_account_path,
scopes=[FCM_SCOPE],
)
creds.refresh(Request())
if not creds.token:
raise MobileSendError("Failed to obtain FCM access token")
return creds.token
def _active_device_tokens(db: Session | None) -> list[str]:
if db is None:
return []
rows = db.scalars(
select(MobileDevice).where(
MobileDevice.revoked_at.is_(None),
MobileDevice.fcm_token.is_not(None),
)
).all()
tokens: list[str] = []
for row in rows:
token = (row.fcm_token or "").strip()
if token:
tokens.append(token)
return tokens
def _send_fcm_data(tokens: list[str], data: dict[str, str], *, title: str, body: str) -> None:
if not tokens:
return
cfg = get_effective_fcm_config()
if not cfg.active:
return
access_token = _fcm_access_token(cfg.service_account_path)
url = f"https://fcm.googleapis.com/v1/projects/{cfg.project_id}/messages:send"
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
with httpx.Client(timeout=12.0) as client:
for token in tokens:
payload = {
"message": {
"token": token,
"notification": {"title": title, "body": body},
"data": data,
"android": {"priority": "high"},
}
}
try:
response = client.post(url, headers=headers, json=payload)
response.raise_for_status()
except httpx.HTTPStatusError as exc:
detail = exc.response.text[:200] if exc.response is not None else str(exc)
logger.warning("FCM send failed for token prefix %s: %s", token[:8], detail)
except Exception:
logger.exception("FCM send failed")
def _event_payload(event: Event) -> tuple[str, str, dict[str, str]]:
title = f"[{event.severity}] {event.title}"
body = (event.summary or "")[:200]
data = {
"kind": "event",
"id": str(event.id),
"severity": event.severity,
"type": event.type,
}
return title, body, data
def _problem_payload(problem: Problem) -> tuple[str, str, dict[str, str]]:
title = f"[{problem.severity}] {problem.title}"
body = (problem.summary or "")[:200]
data = {
"kind": "problem",
"id": str(problem.id),
"severity": problem.severity,
"status": problem.status,
}
return title, body, data
def notify_event(event: Event, *, db: Session | None = None, apply_policy_gate: bool = True) -> None:
policy = get_effective_notification_policy(db)
if apply_policy_gate and not severity_meets_minimum(event.severity, policy.min_severity):
return
tokens = _active_device_tokens(db)
if not tokens:
return
title, body, data = _event_payload(event)
_send_fcm_data(tokens, data, title=title, body=body)
def notify_problem(
problem: Problem,
event: Event | None = None,
*,
db: Session | None = None,
apply_policy_gate: bool = True,
) -> None:
policy = get_effective_notification_policy(db)
if apply_policy_gate and not severity_meets_minimum(problem.severity, policy.min_severity):
return
tokens = _active_device_tokens(db)
if not tokens:
return
title, body, data = _problem_payload(problem)
_send_fcm_data(tokens, data, title=title, body=body)
def send_test_push(*, db: Session, device_id: int) -> None:
cfg = get_effective_fcm_config()
if not cfg.enabled:
raise MobileNotConfiguredError("FCM отключён (SAC_FCM_ENABLED=false)")
if not cfg.configured:
raise MobileNotConfiguredError("Не задан SAC_FCM_PROJECT_ID или SAC_FCM_SERVICE_ACCOUNT_JSON")
device = db.get(MobileDevice, device_id)
if device is None or device.revoked_at is not None:
raise ValueError("device not found or revoked")
token = (device.fcm_token or "").strip()
if not token:
raise ValueError("device has no FCM token")
_send_fcm_data(
[token],
{"kind": "test", "id": "0"},
title="SAC: тестовое push",
body="Канал Seaca (FCM) работает.",
)