"""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_tenant_modules(tenant_id): """Get enabled modules for a tenant from tenant_config.""" 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) conn = psycopg2.connect(dsn) cur = conn.cursor() try: modules = {} for key in ["module_whatsapp", "module_marketplace", "module_meli", "module_catalog"]: cur.execute("SELECT value FROM tenant_config WHERE key = %s", (key,)) row = cur.fetchone() modules[key.replace("module_", "")] = (row[0] or "").lower() == "true" if row else True return modules finally: cur.close() conn.close() def update_tenant_modules(tenant_id, modules): """Update enabled modules for a tenant in tenant_config.""" 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) conn = psycopg2.connect(dsn) cur = conn.cursor() try: key_map = { "whatsapp": "module_whatsapp", "marketplace": "module_marketplace", "meli": "module_meli", "catalog": "module_catalog", } for field, key in key_map.items(): value = "true" if modules.get(field) else "false" cur.execute(""" INSERT INTO tenant_config (key, value) VALUES (%s, %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value """, (key, value)) conn.commit() return {"success": True, "tenant_id": tenant_id, "modules": modules} finally: cur.close() conn.close() 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") } }