143 lines
5.8 KiB
Python
143 lines
5.8 KiB
Python
"""Web Push: уведомления в браузере (работают и при закрытой вкладке, нужен HTTPS)."""
|
|
import base64
|
|
import json
|
|
import logging
|
|
import threading
|
|
|
|
from cryptography.hazmat.primitives import serialization
|
|
from py_vapid import Vapid
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from . import mode, settings_store as ss, logic
|
|
from .netcheck import is_allowed_push_endpoint
|
|
from .db import SessionLocal
|
|
from .models import DeviceToken, PushSubscription, User, Position, Location
|
|
|
|
log = logging.getLogger("push")
|
|
|
|
|
|
def _b64(b: bytes) -> str:
|
|
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
|
|
|
|
|
|
def ensure_vapid(db: Session) -> None:
|
|
if ss.get(db, "vapid_private"):
|
|
return
|
|
v = Vapid()
|
|
v.generate_keys()
|
|
ss.set_(db, "vapid_private", v.private_pem().decode())
|
|
ss.set_(db, "vapid_public", _b64(v.public_key.public_bytes(
|
|
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint)))
|
|
db.commit()
|
|
|
|
|
|
def public_key(db: Session) -> str:
|
|
ensure_vapid(db)
|
|
return ss.get(db, "vapid_public")
|
|
|
|
|
|
def _send_all(payload: dict, user_ids: list[int]):
|
|
from pywebpush import webpush, WebPushException
|
|
with SessionLocal() as db:
|
|
ensure_vapid(db)
|
|
priv = Vapid.from_pem(ss.get(db, "vapid_private").encode())
|
|
claims_sub = ss.get(db, "vapid_contact")
|
|
subs = db.scalars(select(PushSubscription).where(PushSubscription.user_id.in_(user_ids))).all()
|
|
for s in subs:
|
|
if not is_allowed_push_endpoint(s.endpoint): # подписка до введения проверки: не обращаемся и удаляем
|
|
db.delete(s)
|
|
continue
|
|
try:
|
|
webpush({"endpoint": s.endpoint, "keys": {"p256dh": s.p256dh, "auth": s.auth}},
|
|
data=json.dumps(payload), vapid_private_key=priv,
|
|
vapid_claims={"sub": claims_sub}, ttl=3600)
|
|
except WebPushException as e:
|
|
if getattr(e.response, "status_code", None) in (404, 410):
|
|
db.delete(s) # подписка устарела
|
|
else:
|
|
log.warning("push failed: %s", e)
|
|
except Exception:
|
|
log.exception("push error")
|
|
_send_fcm(db, payload, user_ids)
|
|
db.commit()
|
|
|
|
|
|
_fcm_creds = {}
|
|
|
|
|
|
def fcm_enabled(db: Session) -> bool:
|
|
return bool(ss.get(db, "fcm_credentials_path"))
|
|
|
|
|
|
def _fcm_auth(path: str):
|
|
from google.oauth2 import service_account
|
|
from google.auth.transport.requests import Request
|
|
c = _fcm_creds.get(path)
|
|
if c is None:
|
|
c = _fcm_creds[path] = service_account.Credentials.from_service_account_file(
|
|
path, scopes=["https://www.googleapis.com/auth/firebase.messaging"])
|
|
if not c.valid:
|
|
c.refresh(Request())
|
|
return c
|
|
|
|
|
|
def _send_fcm(db: Session, payload: dict, user_ids: list[int]):
|
|
"""Android через Firebase Cloud Messaging (HTTP v1). Включается указанием ключа в настройках сервера."""
|
|
path = ss.get(db, "fcm_credentials_path")
|
|
if not path:
|
|
return
|
|
tokens = db.scalars(select(DeviceToken).where(DeviceToken.user_id.in_(user_ids))).all()
|
|
if not tokens:
|
|
return
|
|
try:
|
|
creds = _fcm_auth(path)
|
|
except Exception:
|
|
log.exception("FCM: не удалось получить токен доступа")
|
|
return
|
|
import httpx
|
|
url = f"https://fcm.googleapis.com/v1/projects/{creds.project_id}/messages:send"
|
|
for d in tokens:
|
|
msg = {"message": {"token": d.token,
|
|
"notification": {"title": payload["title"], "body": payload["body"]},
|
|
"data": {"url": payload.get("url", "/")},
|
|
"android": {"priority": "HIGH"}}}
|
|
try:
|
|
r = httpx.post(url, json=msg, headers={"Authorization": "Bearer " + creds.token}, timeout=10)
|
|
if r.status_code in (400, 404) and ("UNREGISTERED" in r.text or "INVALID_ARGUMENT" in r.text):
|
|
db.delete(d) # токен устарел
|
|
elif r.status_code >= 400:
|
|
log.warning("FCM %s: %s", r.status_code, r.text[:200])
|
|
except Exception:
|
|
log.exception("FCM error")
|
|
|
|
|
|
def notify(user_ids, title: str, body: str, url: str = "/"):
|
|
"""Асинхронная рассылка (в отдельном потоке, чтобы не задерживать ответ API)."""
|
|
ids = sorted({i for i in user_ids if i})
|
|
if not ids or mode.is_demo_request(): # демо-сессия: id из демо-БД, настоящим людям ничего не шлём
|
|
return
|
|
threading.Thread(target=_send_all, args=({"title": title, "body": body, "url": url}, ids),
|
|
daemon=True).start()
|
|
|
|
|
|
def users_for_assignment(db: Session, user_id, position_id) -> list[int]:
|
|
"""Кого уведомлять о назначении: конкретного человека или тех, кто сейчас на смене в должности."""
|
|
if user_id:
|
|
return [user_id]
|
|
if position_id:
|
|
pos = db.get(Position, position_id)
|
|
us = db.scalars(select(User).where(User.position_id == position_id, User.is_active.is_(True))).all()
|
|
return [u.id for u in us if pos and logic.shift_active(pos.schedule, logic.config.now())]
|
|
return []
|
|
|
|
|
|
def users_for_review(db: Session, user_id, position_id) -> list[int]:
|
|
return users_for_assignment(db, user_id, position_id)
|
|
|
|
|
|
def notify_assigned(db: Session, o) -> None:
|
|
ids = users_for_assignment(db, o.assignee_user_id, o.assignee_position_id)
|
|
loc = db.get(Location, o.location_id) if o.location_id else None
|
|
notify(ids, "Новый наряд", o.title + (f" · {loc.name}" if loc else ""), f"/#/order/{o.id}")
|