Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android

This commit is contained in:
darkorion
2026-10-13 09:09:53 +03:00
commit c298f5a830
247 changed files with 23200 additions and 0 deletions
+142
View File
@@ -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}")