Files
Autoparts-DB/pos/services/push_service.py
consultoria-as 5d5a2777eb feat(pos): add 3 improvements — Spanish translations, PDF quotes, push notifications
1. Spanish translations for TecDoc catalog (translations.py) applied to
   catalog_service.py and dashboard server.py endpoints
2. Printable quotation HTML endpoint (/pos/api/quotations/<id>/pdf) with
   @media print CSS for clean browser-to-PDF output
3. Web Push notifications to owner/admin on sale cancellation, stock zero,
   and cash register differences > $500. Includes service worker, VAPID
   key management, and subscription endpoints.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 08:05:11 +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 config WHERE key = 'vapid_private_key'")
row = cur.fetchone()
if row:
private_key = row[0]
cur.execute("SELECT value FROM 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 config (key, value) VALUES ('vapid_private_key', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (private_pem,))
cur.execute("""
INSERT INTO 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()