update: Major Overhault to all subsystems
This commit is contained in:
@@ -26,7 +26,7 @@ class MqttManager:
|
||||
|
||||
self._client = paho_mqtt.Client(
|
||||
callback_api_version=paho_mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id="bellsystems-admin-panel",
|
||||
client_id=settings.mqtt_client_id,
|
||||
clean_session=True,
|
||||
)
|
||||
|
||||
@@ -64,6 +64,8 @@ class MqttManager:
|
||||
client.subscribe([
|
||||
("vesper/+/data", 1),
|
||||
("vesper/+/status/heartbeat", 1),
|
||||
("vesper/+/status/alerts", 1),
|
||||
("vesper/+/status/info", 0),
|
||||
("vesper/+/logs", 1),
|
||||
])
|
||||
else:
|
||||
|
||||
@@ -76,6 +76,102 @@ SCHEMA_STATEMENTS = [
|
||||
)""",
|
||||
"CREATE INDEX IF NOT EXISTS idx_mfg_audit_time ON mfg_audit_log(timestamp)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_mfg_audit_action ON mfg_audit_log(action)",
|
||||
# Active device alerts (current state, not history)
|
||||
"""CREATE TABLE IF NOT EXISTS device_alerts (
|
||||
device_serial TEXT NOT NULL,
|
||||
subsystem TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
message TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (device_serial, subsystem)
|
||||
)""",
|
||||
"CREATE INDEX IF NOT EXISTS idx_device_alerts_serial ON device_alerts(device_serial)",
|
||||
# CRM communications log
|
||||
"""CREATE TABLE IF NOT EXISTS crm_comms_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
customer_id TEXT,
|
||||
type TEXT NOT NULL,
|
||||
mail_account TEXT,
|
||||
direction TEXT NOT NULL,
|
||||
subject TEXT,
|
||||
body TEXT,
|
||||
body_html TEXT,
|
||||
attachments TEXT NOT NULL DEFAULT '[]',
|
||||
ext_message_id TEXT,
|
||||
from_addr TEXT,
|
||||
to_addrs TEXT,
|
||||
logged_by TEXT,
|
||||
occurred_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)""",
|
||||
"CREATE INDEX IF NOT EXISTS idx_crm_comms_customer ON crm_comms_log(customer_id, occurred_at)",
|
||||
# CRM media references
|
||||
"""CREATE TABLE IF NOT EXISTS crm_media (
|
||||
id TEXT PRIMARY KEY,
|
||||
customer_id TEXT,
|
||||
order_id TEXT,
|
||||
filename TEXT NOT NULL,
|
||||
nextcloud_path TEXT NOT NULL,
|
||||
mime_type TEXT,
|
||||
direction TEXT,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
uploaded_by TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)""",
|
||||
"CREATE INDEX IF NOT EXISTS idx_crm_media_customer ON crm_media(customer_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_crm_media_order ON crm_media(order_id)",
|
||||
# CRM sync state (last email sync timestamp, etc.)
|
||||
"""CREATE TABLE IF NOT EXISTS crm_sync_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)""",
|
||||
# CRM Quotations
|
||||
"""CREATE TABLE IF NOT EXISTS crm_quotations (
|
||||
id TEXT PRIMARY KEY,
|
||||
quotation_number TEXT UNIQUE NOT NULL,
|
||||
title TEXT,
|
||||
subtitle TEXT,
|
||||
customer_id TEXT NOT NULL,
|
||||
language TEXT NOT NULL DEFAULT 'en',
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
order_type TEXT,
|
||||
shipping_method TEXT,
|
||||
estimated_shipping_date TEXT,
|
||||
global_discount_label TEXT,
|
||||
global_discount_percent REAL NOT NULL DEFAULT 0,
|
||||
vat_percent REAL NOT NULL DEFAULT 24,
|
||||
shipping_cost REAL NOT NULL DEFAULT 0,
|
||||
shipping_cost_discount REAL NOT NULL DEFAULT 0,
|
||||
install_cost REAL NOT NULL DEFAULT 0,
|
||||
install_cost_discount REAL NOT NULL DEFAULT 0,
|
||||
extras_label TEXT,
|
||||
extras_cost REAL NOT NULL DEFAULT 0,
|
||||
comments TEXT NOT NULL DEFAULT '[]',
|
||||
subtotal_before_discount REAL NOT NULL DEFAULT 0,
|
||||
global_discount_amount REAL NOT NULL DEFAULT 0,
|
||||
new_subtotal REAL NOT NULL DEFAULT 0,
|
||||
vat_amount REAL NOT NULL DEFAULT 0,
|
||||
final_total REAL NOT NULL DEFAULT 0,
|
||||
nextcloud_pdf_path TEXT,
|
||||
nextcloud_pdf_url TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)""",
|
||||
"""CREATE TABLE IF NOT EXISTS crm_quotation_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
quotation_id TEXT NOT NULL,
|
||||
product_id TEXT,
|
||||
description TEXT,
|
||||
unit_type TEXT NOT NULL DEFAULT 'pcs',
|
||||
unit_cost REAL NOT NULL DEFAULT 0,
|
||||
discount_percent REAL NOT NULL DEFAULT 0,
|
||||
quantity REAL NOT NULL DEFAULT 1,
|
||||
line_total REAL NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (quotation_id) REFERENCES crm_quotations(id)
|
||||
)""",
|
||||
"CREATE INDEX IF NOT EXISTS idx_crm_quotations_customer ON crm_quotations(customer_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_crm_quotation_items_quotation ON crm_quotation_items(quotation_id, sort_order)",
|
||||
]
|
||||
|
||||
|
||||
@@ -86,6 +182,65 @@ async def init_db():
|
||||
for stmt in SCHEMA_STATEMENTS:
|
||||
await _db.execute(stmt)
|
||||
await _db.commit()
|
||||
# Migrations: add columns that may not exist in older DBs
|
||||
_migrations = [
|
||||
"ALTER TABLE crm_comms_log ADD COLUMN body_html TEXT",
|
||||
"ALTER TABLE crm_comms_log ADD COLUMN mail_account TEXT",
|
||||
"ALTER TABLE crm_comms_log ADD COLUMN from_addr TEXT",
|
||||
"ALTER TABLE crm_comms_log ADD COLUMN to_addrs TEXT",
|
||||
"ALTER TABLE crm_comms_log ADD COLUMN is_important INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE crm_comms_log ADD COLUMN is_read INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE crm_quotation_items ADD COLUMN vat_percent REAL NOT NULL DEFAULT 24",
|
||||
"ALTER TABLE crm_quotations ADD COLUMN quick_notes TEXT NOT NULL DEFAULT '{}'",
|
||||
"ALTER TABLE crm_quotations ADD COLUMN client_org TEXT",
|
||||
"ALTER TABLE crm_quotations ADD COLUMN client_name TEXT",
|
||||
"ALTER TABLE crm_quotations ADD COLUMN client_location TEXT",
|
||||
"ALTER TABLE crm_quotations ADD COLUMN client_phone TEXT",
|
||||
"ALTER TABLE crm_quotations ADD COLUMN client_email TEXT",
|
||||
]
|
||||
for m in _migrations:
|
||||
try:
|
||||
await _db.execute(m)
|
||||
await _db.commit()
|
||||
except Exception:
|
||||
pass # column already exists
|
||||
|
||||
# Migration: drop NOT NULL on crm_comms_log.customer_id if it exists.
|
||||
# SQLite doesn't support ALTER COLUMN, so we check via table_info and
|
||||
# rebuild the table if needed.
|
||||
rows = await _db.execute_fetchall("PRAGMA table_info(crm_comms_log)")
|
||||
for row in rows:
|
||||
# row: (cid, name, type, notnull, dflt_value, pk)
|
||||
if row[1] == "customer_id" and row[3] == 1: # notnull=1
|
||||
logger.info("Migrating crm_comms_log: removing NOT NULL from customer_id")
|
||||
await _db.execute("ALTER TABLE crm_comms_log RENAME TO crm_comms_log_old")
|
||||
await _db.execute("""CREATE TABLE crm_comms_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
customer_id TEXT,
|
||||
type TEXT NOT NULL,
|
||||
mail_account TEXT,
|
||||
direction TEXT NOT NULL,
|
||||
subject TEXT,
|
||||
body TEXT,
|
||||
body_html TEXT,
|
||||
attachments TEXT NOT NULL DEFAULT '[]',
|
||||
ext_message_id TEXT,
|
||||
from_addr TEXT,
|
||||
to_addrs TEXT,
|
||||
logged_by TEXT,
|
||||
occurred_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)""")
|
||||
await _db.execute("""INSERT INTO crm_comms_log
|
||||
SELECT id, customer_id, type, NULL, direction, subject, body, body_html,
|
||||
attachments, ext_message_id, from_addr, to_addrs, logged_by,
|
||||
occurred_at, created_at
|
||||
FROM crm_comms_log_old""")
|
||||
await _db.execute("DROP TABLE crm_comms_log_old")
|
||||
await _db.execute("CREATE INDEX IF NOT EXISTS idx_crm_comms_customer ON crm_comms_log(customer_id, occurred_at)")
|
||||
await _db.commit()
|
||||
logger.info("Migration complete: crm_comms_log.customer_id is now nullable")
|
||||
break
|
||||
logger.info(f"SQLite database initialized at {settings.sqlite_db_path}")
|
||||
|
||||
|
||||
@@ -252,3 +407,37 @@ async def purge_loop():
|
||||
await purge_old_data()
|
||||
except Exception as e:
|
||||
logger.error(f"Purge failed: {e}")
|
||||
|
||||
|
||||
# --- Device Alerts ---
|
||||
|
||||
async def upsert_alert(device_serial: str, subsystem: str, state: str,
|
||||
message: str | None = None):
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""INSERT INTO device_alerts (device_serial, subsystem, state, message, updated_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(device_serial, subsystem)
|
||||
DO UPDATE SET state=excluded.state, message=excluded.message,
|
||||
updated_at=excluded.updated_at""",
|
||||
(device_serial, subsystem, state, message),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def delete_alert(device_serial: str, subsystem: str):
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"DELETE FROM device_alerts WHERE device_serial = ? AND subsystem = ?",
|
||||
(device_serial, subsystem),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_alerts(device_serial: str) -> list:
|
||||
db = await get_db()
|
||||
rows = await db.execute_fetchall(
|
||||
"SELECT * FROM device_alerts WHERE device_serial = ? ORDER BY updated_at DESC",
|
||||
(device_serial,),
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@@ -18,6 +18,10 @@ async def handle_message(serial: str, topic_type: str, payload: dict):
|
||||
try:
|
||||
if topic_type == "status/heartbeat":
|
||||
await _handle_heartbeat(serial, payload)
|
||||
elif topic_type == "status/alerts":
|
||||
await _handle_alerts(serial, payload)
|
||||
elif topic_type == "status/info":
|
||||
await _handle_info(serial, payload)
|
||||
elif topic_type == "logs":
|
||||
await _handle_log(serial, payload)
|
||||
elif topic_type == "data":
|
||||
@@ -29,6 +33,8 @@ async def handle_message(serial: str, topic_type: str, payload: dict):
|
||||
|
||||
|
||||
async def _handle_heartbeat(serial: str, payload: dict):
|
||||
# Store silently — do not log as a visible event.
|
||||
# The console surfaces an alert only when the device goes silent (no heartbeat for 90s).
|
||||
inner = payload.get("payload", {})
|
||||
await db.insert_heartbeat(
|
||||
device_serial=serial,
|
||||
@@ -55,6 +61,31 @@ async def _handle_log(serial: str, payload: dict):
|
||||
)
|
||||
|
||||
|
||||
async def _handle_alerts(serial: str, payload: dict):
|
||||
subsystem = payload.get("subsystem", "")
|
||||
state = payload.get("state", "")
|
||||
if not subsystem or not state:
|
||||
logger.warning(f"Malformed alert payload from {serial}: {payload}")
|
||||
return
|
||||
|
||||
if state == "CLEARED":
|
||||
await db.delete_alert(serial, subsystem)
|
||||
else:
|
||||
await db.upsert_alert(serial, subsystem, state, payload.get("msg"))
|
||||
|
||||
|
||||
async def _handle_info(serial: str, payload: dict):
|
||||
event_type = payload.get("type", "")
|
||||
data = payload.get("payload", {})
|
||||
|
||||
if event_type == "playback_started":
|
||||
logger.debug(f"{serial}: playback started — melody_uid={data.get('melody_uid')}")
|
||||
elif event_type == "playback_stopped":
|
||||
logger.debug(f"{serial}: playback stopped")
|
||||
else:
|
||||
logger.debug(f"{serial}: info event '{event_type}'")
|
||||
|
||||
|
||||
async def _handle_data_response(serial: str, payload: dict):
|
||||
status = payload.get("status", "")
|
||||
|
||||
|
||||
@@ -84,3 +84,15 @@ class CommandSendResponse(BaseModel):
|
||||
success: bool
|
||||
command_id: int
|
||||
message: str
|
||||
|
||||
|
||||
class DeviceAlertEntry(BaseModel):
|
||||
device_serial: str
|
||||
subsystem: str
|
||||
state: str
|
||||
message: Optional[str] = None
|
||||
updated_at: str
|
||||
|
||||
|
||||
class DeviceAlertsResponse(BaseModel):
|
||||
alerts: List[DeviceAlertEntry]
|
||||
|
||||
Reference in New Issue
Block a user