"""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: us = db.scalars(select(User).where(User.position_id == position_id, User.is_active.is_(True))).all() now = logic.config.now() return [u.id for u in us if logic.user_on_shift(u, 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}")