- Add POS_INTERNAL_URL config for cross-VM API calls - create_demo now calls POS /internal/whatsapp-bridge after tenant creation - delete_tenant now destroys bridge container before dropping DB - Graceful fallback if bridge provisioning fails
351 lines
12 KiB
Python
351 lines
12 KiB
Python
"""Tenant management service wrapping POS tenant_manager."""
|
|
import os
|
|
import sys
|
|
import psycopg2
|
|
from psycopg2 import sql
|
|
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
|
|
|
|
# Add POS to path so we can reuse tenant_manager
|
|
POS_DIR = os.environ.get("POS_DIR", "/home/Autopartes/pos")
|
|
if POS_DIR not in sys.path:
|
|
sys.path.insert(0, POS_DIR)
|
|
|
|
from config import MASTER_DB_URL, TENANT_DB_URL_TEMPLATE, DEMO_DEFAULT_DAYS
|
|
|
|
|
|
def get_master_conn():
|
|
return psycopg2.connect(MASTER_DB_URL)
|
|
|
|
|
|
def list_tenants(include_stats=False):
|
|
"""List all tenants with optional per-tenant stats."""
|
|
conn = get_master_conn()
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT t.id, t.name, t.db_name, t.subdomain, t.rfc, t.plan, t.is_active,
|
|
t.created_at, COALESCE(s.expires_at, NULL) as expires_at,
|
|
COALESCE(v.version, 'v0.0') as schema_version
|
|
FROM tenants t
|
|
LEFT JOIN subscriptions s ON s.tenant_id = t.id
|
|
LEFT JOIN tenant_schema_version v ON v.tenant_id = t.id
|
|
ORDER BY t.id DESC
|
|
""")
|
|
cols = [desc[0] for desc in cur.description]
|
|
tenants = []
|
|
for row in cur.fetchall():
|
|
tenant = dict(zip(cols, row))
|
|
tenant["created_at"] = str(tenant["created_at"]) if tenant["created_at"] else None
|
|
tenant["expires_at"] = str(tenant["expires_at"]) if tenant["expires_at"] else None
|
|
tenant["is_demo"] = tenant["plan"] in ("demo", "trial")
|
|
tenant["demo_days_left"] = None
|
|
if tenant["expires_at"]:
|
|
from datetime import datetime
|
|
try:
|
|
exp = datetime.fromisoformat(tenant["expires_at"].replace("Z", "+00:00"))
|
|
now = datetime.now(exp.tzinfo) if exp.tzinfo else datetime.now()
|
|
tenant["demo_days_left"] = max(0, (exp - now).days)
|
|
except Exception:
|
|
pass
|
|
tenants.append(tenant)
|
|
cur.close()
|
|
conn.close()
|
|
|
|
if include_stats:
|
|
for t in tenants:
|
|
t["stats"] = _get_tenant_quick_stats(t["db_name"])
|
|
return tenants
|
|
|
|
|
|
def get_tenant(tenant_id):
|
|
"""Get single tenant details."""
|
|
conn = get_master_conn()
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT t.id, t.name, t.db_name, t.subdomain, t.rfc, t.plan, t.is_active,
|
|
t.created_at, COALESCE(s.expires_at, NULL) as expires_at,
|
|
COALESCE(s.status, 'unknown') as subscription_status,
|
|
COALESCE(v.version, 'v0.0') as schema_version
|
|
FROM tenants t
|
|
LEFT JOIN subscriptions s ON s.tenant_id = t.id
|
|
LEFT JOIN tenant_schema_version v ON v.tenant_id = t.id
|
|
WHERE t.id = %s
|
|
""", (tenant_id,))
|
|
row = cur.fetchone()
|
|
cur.close()
|
|
conn.close()
|
|
if not row:
|
|
return None
|
|
keys = ["id", "name", "db_name", "subdomain", "rfc", "plan", "is_active",
|
|
"created_at", "expires_at", "subscription_status", "schema_version"]
|
|
return {k: str(v) if v is not None else None for k, v in zip(keys, row)}
|
|
|
|
|
|
def _get_tenant_quick_stats(db_name):
|
|
"""Quick stats for a tenant DB."""
|
|
dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
|
|
try:
|
|
conn = psycopg2.connect(dsn, connect_timeout=5)
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT
|
|
(SELECT COUNT(*) FROM employees WHERE is_active = true),
|
|
(SELECT COUNT(*) FROM inventory WHERE is_active = true),
|
|
(SELECT COUNT(*) FROM customers WHERE is_active = true),
|
|
(SELECT COUNT(*) FROM sales WHERE status = 'completed'),
|
|
pg_database_size(current_database())
|
|
""")
|
|
emp, inv, cust, sales, size = cur.fetchone()
|
|
cur.close()
|
|
conn.close()
|
|
return {
|
|
"employees": emp,
|
|
"inventory_items": inv,
|
|
"customers": cust,
|
|
"completed_sales": sales,
|
|
"db_size_mb": round(size / (1024 * 1024), 2)
|
|
}
|
|
except Exception as e:
|
|
return {"error": str(e)}
|
|
|
|
|
|
def create_demo(name, email, demo_days=None, subdomain=None, pin="0000"):
|
|
"""Provision a new demo tenant using POS tenant_manager."""
|
|
from services.tenant_manager import provision_tenant
|
|
from datetime import datetime, timedelta
|
|
|
|
days = demo_days or DEMO_DEFAULT_DAYS
|
|
if not subdomain:
|
|
from services.tenant_manager import generate_subdomain
|
|
subdomain = generate_subdomain(name)
|
|
# Ensure uniqueness by appending random suffix if needed
|
|
conn = get_master_conn()
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT 1 FROM tenants WHERE subdomain = %s", (subdomain,))
|
|
if cur.fetchone():
|
|
import secrets
|
|
subdomain = f"{subdomain}-{secrets.token_hex(2)}"
|
|
cur.close()
|
|
conn.close()
|
|
|
|
result = provision_tenant(
|
|
name=name,
|
|
rfc=None,
|
|
owner_name="Admin Demo",
|
|
owner_email=email,
|
|
owner_pin=pin,
|
|
subdomain=subdomain
|
|
)
|
|
|
|
# Mark as demo plan and set expiration
|
|
tenant_id = result["tenant_id"]
|
|
conn = get_master_conn()
|
|
cur = conn.cursor()
|
|
cur.execute("UPDATE tenants SET plan = 'demo' WHERE id = %s", (tenant_id,))
|
|
cur.execute("""
|
|
INSERT INTO subscriptions (tenant_id, plan, status, expires_at)
|
|
VALUES (%s, 'demo', 'active', %s)
|
|
ON CONFLICT (tenant_id) DO UPDATE SET
|
|
plan = 'demo',
|
|
status = 'active',
|
|
expires_at = EXCLUDED.expires_at
|
|
""", (tenant_id, datetime.now() + timedelta(days=days)))
|
|
conn.commit()
|
|
cur.close()
|
|
conn.close()
|
|
|
|
# Auto-provision WhatsApp Bridge
|
|
try:
|
|
import urllib.request
|
|
import json as _json
|
|
from config import POS_INTERNAL_URL, INTERNAL_API_KEY
|
|
bridge_payload = _json.dumps({
|
|
"tenant_id": tenant_id,
|
|
"subdomain": subdomain,
|
|
"db_name": result["db_name"]
|
|
}).encode()
|
|
req = urllib.request.Request(
|
|
f"{POS_INTERNAL_URL}/pos/api/internal/whatsapp-bridge",
|
|
data=bridge_payload,
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"X-Internal-Key": INTERNAL_API_KEY
|
|
},
|
|
method="POST"
|
|
)
|
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
bridge_data = _json.loads(resp.read().decode())
|
|
result["whatsapp_bridge"] = bridge_data
|
|
except Exception as e:
|
|
result["whatsapp_bridge_error"] = str(e)
|
|
|
|
result["demo_days"] = days
|
|
result["expires_at"] = str(datetime.now() + timedelta(days=days))
|
|
result["access_url"] = f"https://{subdomain}.nexusautoparts.com.mx/pos/login"
|
|
result["owner_pin"] = pin
|
|
return result
|
|
|
|
|
|
def reset_tenant(tenant_id, keep_config=True):
|
|
"""Reset a tenant: truncate business data but keep structure and owner."""
|
|
tenant = get_tenant(tenant_id)
|
|
if not tenant:
|
|
raise ValueError("Tenant not found")
|
|
db_name = tenant["db_name"]
|
|
dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
|
|
|
|
tables_to_truncate = [
|
|
"inventory_operations",
|
|
"inventory",
|
|
"sale_items",
|
|
"sales",
|
|
"customer_payments",
|
|
"cash_register_closings",
|
|
"cash_register_movements",
|
|
"cash_registers",
|
|
"invoices",
|
|
"accounting_entries",
|
|
"journal_entries",
|
|
"service_orders",
|
|
"fleet_vehicles",
|
|
"crm_activities",
|
|
"quotations",
|
|
"quotation_items",
|
|
"savings_transactions",
|
|
"savings_accounts",
|
|
"supplier_orders",
|
|
"supplier_order_items",
|
|
"warranty_claims",
|
|
"notifications",
|
|
"inventory_uploads",
|
|
]
|
|
|
|
conn = psycopg2.connect(dsn)
|
|
cur = conn.cursor()
|
|
try:
|
|
for table in tables_to_truncate:
|
|
try:
|
|
cur.execute(f"TRUNCATE TABLE {table} RESTART IDENTITY CASCADE")
|
|
except Exception:
|
|
pass # Table may not exist
|
|
conn.commit()
|
|
success = True
|
|
except Exception as e:
|
|
conn.rollback()
|
|
success = False
|
|
raise RuntimeError(f"Reset failed: {e}")
|
|
finally:
|
|
cur.close()
|
|
conn.close()
|
|
|
|
return {"success": success, "tenant_id": tenant_id, "tables_reset": len(tables_to_truncate)}
|
|
|
|
|
|
def delete_tenant(tenant_id):
|
|
"""Permanently delete a tenant and its database."""
|
|
tenant = get_tenant(tenant_id)
|
|
if not tenant:
|
|
raise ValueError("Tenant not found")
|
|
db_name = tenant["db_name"]
|
|
subdomain = tenant.get("subdomain") or f"tenant-{tenant_id}"
|
|
|
|
# Destroy WhatsApp Bridge container
|
|
try:
|
|
import urllib.request
|
|
import json as _json
|
|
from config import POS_INTERNAL_URL, INTERNAL_API_KEY
|
|
bridge_payload = _json.dumps({"subdomain": subdomain}).encode()
|
|
req = urllib.request.Request(
|
|
f"{POS_INTERNAL_URL}/pos/api/internal/whatsapp-bridge",
|
|
data=bridge_payload,
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"X-Internal-Key": INTERNAL_API_KEY
|
|
},
|
|
method="DELETE"
|
|
)
|
|
urllib.request.urlopen(req, timeout=15)
|
|
except Exception:
|
|
pass # Bridge may not exist
|
|
|
|
conn = get_master_conn()
|
|
cur = conn.cursor()
|
|
|
|
# Drop database
|
|
try:
|
|
master_conn = psycopg2.connect(MASTER_DB_URL)
|
|
master_conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
|
|
master_cur = master_conn.cursor()
|
|
master_cur.execute(
|
|
sql.SQL('DROP DATABASE IF EXISTS {}').format(sql.Identifier(db_name))
|
|
)
|
|
master_cur.close()
|
|
master_conn.close()
|
|
except Exception as e:
|
|
pass
|
|
|
|
# Clean master records
|
|
cur.execute("DELETE FROM tenant_schema_version WHERE tenant_id = %s", (tenant_id,))
|
|
cur.execute("DELETE FROM subscriptions WHERE tenant_id = %s", (tenant_id,))
|
|
cur.execute("DELETE FROM tenants WHERE id = %s", (tenant_id,))
|
|
conn.commit()
|
|
cur.close()
|
|
conn.close()
|
|
return {"success": True, "tenant_id": tenant_id, "db_name": db_name}
|
|
|
|
|
|
def toggle_tenant(tenant_id, active):
|
|
"""Activate or deactivate a tenant."""
|
|
conn = get_master_conn()
|
|
cur = conn.cursor()
|
|
cur.execute("UPDATE tenants SET is_active = %s WHERE id = %s", (active, tenant_id))
|
|
conn.commit()
|
|
rowcount = cur.rowcount
|
|
cur.close()
|
|
conn.close()
|
|
return {"success": rowcount > 0, "tenant_id": tenant_id, "is_active": active}
|
|
|
|
|
|
def get_tenant_login_url(subdomain):
|
|
"""Generate login URL for a tenant."""
|
|
domain = os.environ.get("NEXUS_DOMAIN", "nexusautoparts.com.mx")
|
|
return f"https://{subdomain}.{domain}/pos/login"
|
|
|
|
|
|
def get_dashboard_stats():
|
|
"""Global stats for the manager dashboard."""
|
|
conn = get_master_conn()
|
|
cur = conn.cursor()
|
|
|
|
cur.execute("SELECT COUNT(*) FROM tenants")
|
|
total = cur.fetchone()[0]
|
|
|
|
cur.execute("SELECT COUNT(*) FROM tenants WHERE is_active = true")
|
|
active = cur.fetchone()[0]
|
|
|
|
cur.execute("SELECT COUNT(*) FROM tenants WHERE plan = 'demo'")
|
|
demos = cur.fetchone()[0]
|
|
|
|
cur.execute("""
|
|
SELECT COUNT(*) FROM subscriptions
|
|
WHERE status = 'active' AND expires_at < NOW() + INTERVAL '7 days'
|
|
""")
|
|
expiring_soon = cur.fetchone()[0]
|
|
|
|
cur.close()
|
|
conn.close()
|
|
|
|
# Get system health summary
|
|
from services.health_service import check_disk_space, check_memory
|
|
disk = check_disk_space()
|
|
mem = check_memory()
|
|
|
|
return {
|
|
"tenants": {"total": total, "active": active, "demos": demos, "expiring_soon": expiring_soon},
|
|
"system": {
|
|
"disk_percent": disk.get("percent_used"),
|
|
"memory_percent": mem.get("percent_used"),
|
|
"disk_free_gb": disk.get("free_gb"),
|
|
"memory_available_gb": mem.get("available_gb")
|
|
}
|
|
}
|