Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android
This commit is contained in:
+142
@@ -0,0 +1,142 @@
|
||||
"""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}")
|
||||
Reference in New Issue
Block a user