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>
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
"""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) работает.",
|
||||
)
|
||||
Reference in New Issue
Block a user