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>
216 lines
6.5 KiB
Python
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()
|