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>
This commit is contained in:
215
pos/services/push_service.py
Normal file
215
pos/services/push_service.py
Normal file
@@ -0,0 +1,215 @@
|
||||
# /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()
|
||||
Reference in New Issue
Block a user