Files
Autoparts-DB/pos/services/push_service.py
consultoria-as a386ca5b4a
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos
- F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado
- F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment
- F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings)
- F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant
- Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas
- Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant)
- Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant
- Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
2026-10-12 00:57:31 +00:00

216 lines
6.5 KiB
Python

# /home/Autopartes/pos/services/push_service.py
"""Web Push notification service for Nexus POS.
Uses the pywebpush library to send push notifications via the Web Push API.
VAPID keys are generated once and stored in the tenant config table.
Usage:
from services.push_service import notify_owner
# Non-blocking: fire-and-forget push to owner(s)
notify_owner(conn, 'Venta Cancelada', f'Venta #{sale_id} cancelada por {reason}', '/pos')
"""
import json
import logging
import os
import traceback
logger = logging.getLogger(__name__)
# Try to import pywebpush — graceful degradation if not installed
try:
from pywebpush import webpush, WebPushException
HAS_WEBPUSH = True
except ImportError:
HAS_WEBPUSH = False
logger.warning("pywebpush not installed — push notifications disabled. Install with: pip install pywebpush")
try:
from py_vapid import Vapid
HAS_VAPID = True
except ImportError:
HAS_VAPID = False
def get_or_create_vapid_keys(conn):
"""Get VAPID keys from config, or generate new ones.
Returns: (private_key_pem, public_key_b64url) or (None, None) if unavailable.
"""
if not HAS_VAPID and not HAS_WEBPUSH:
return None, None
cur = conn.cursor()
# Check if keys already exist in config
try:
cur.execute("SELECT value FROM tenant_config WHERE key = 'vapid_private_key'")
row = cur.fetchone()
if row:
private_key = row[0]
cur.execute("SELECT value FROM tenant_config WHERE key = 'vapid_public_key'")
pub_row = cur.fetchone()
public_key = pub_row[0] if pub_row else None
cur.close()
if public_key:
return private_key, public_key
except Exception:
pass # config table might not exist yet
# Generate new VAPID keys
try:
vapid = Vapid()
vapid.generate_keys()
private_pem = vapid.private_pem().decode('utf-8')
public_b64 = vapid.public_key_urlsafe_base64()
# Store in config table
try:
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES ('vapid_private_key', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (private_pem,))
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES ('vapid_public_key', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (public_b64,))
conn.commit()
except Exception:
conn.rollback()
cur.close()
return private_pem, public_b64
except Exception as e:
logger.error(f"Failed to generate VAPID keys: {e}")
cur.close()
return None, None
def save_subscription(conn, employee_id, subscription_json):
"""Save a push subscription for an employee.
Args:
conn: psycopg2 connection
employee_id: int
subscription_json: dict (the PushSubscription object from the browser)
"""
cur = conn.cursor()
sub_str = json.dumps(subscription_json) if isinstance(subscription_json, dict) else subscription_json
# Upsert: one subscription per employee
cur.execute("""
INSERT INTO push_subscriptions (employee_id, subscription_data, created_at)
VALUES (%s, %s, NOW())
ON CONFLICT (employee_id) DO UPDATE
SET subscription_data = EXCLUDED.subscription_data, created_at = NOW()
""", (employee_id, sub_str))
conn.commit()
cur.close()
def send_push(conn, employee_id, title, body, url=None):
"""Send a push notification to a specific employee.
Returns True on success, False on failure.
"""
if not HAS_WEBPUSH:
logger.debug("pywebpush not available, skipping push")
return False
cur = conn.cursor()
# Get subscription
cur.execute("SELECT subscription_data FROM push_subscriptions WHERE employee_id = %s", (employee_id,))
row = cur.fetchone()
if not row:
cur.close()
return False
subscription = json.loads(row[0]) if isinstance(row[0], str) else row[0]
# Get VAPID keys
private_key, public_key = get_or_create_vapid_keys(conn)
if not private_key:
cur.close()
return False
cur.close()
payload = json.dumps({
'title': title,
'body': body,
'url': url or '/pos',
'icon': '/pos/static/icons/icon-192.png',
'badge': '/pos/static/icons/badge-72.png',
})
try:
webpush(
subscription_info=subscription,
data=payload,
vapid_private_key=private_key,
vapid_claims={'sub': 'mailto:notificaciones@nexusautoparts.mx'},
)
return True
except WebPushException as e:
logger.warning(f"Push failed for employee {employee_id}: {e}")
# If subscription expired, remove it
if e.response and e.response.status_code in (404, 410):
cur2 = conn.cursor()
cur2.execute("DELETE FROM push_subscriptions WHERE employee_id = %s", (employee_id,))
conn.commit()
cur2.close()
return False
except Exception as e:
logger.warning(f"Push error: {e}")
return False
def notify_owner(conn, title, body, url=None):
"""Send push notification to all owner/admin employees (non-blocking, best-effort).
This is the main integration point — call from sale cancellation, stock alerts, etc.
"""
if not HAS_WEBPUSH:
return
cur = conn.cursor()
try:
cur.execute("""
SELECT ps.employee_id
FROM push_subscriptions ps
JOIN employees e ON e.id = ps.employee_id
WHERE e.role IN ('owner', 'admin') AND e.is_active = true
""")
employee_ids = [r[0] for r in cur.fetchall()]
cur.close()
except Exception:
cur.close()
return
for eid in employee_ids:
try:
send_push(conn, eid, title, body, url)
except Exception:
pass # Never let push failures affect business logic
def ensure_push_table(conn):
"""Create push_subscriptions table if it doesn't exist."""
cur = conn.cursor()
try:
cur.execute("""
CREATE TABLE IF NOT EXISTS push_subscriptions (
id SERIAL PRIMARY KEY,
employee_id INTEGER NOT NULL UNIQUE,
subscription_data TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
)
""")
conn.commit()
except Exception:
conn.rollback()
finally:
cur.close()