Initial resurrection kit — 81 scripts, 62 references, configs, systemd units, crons, Docker compose files, Caddy config, master README
This commit is contained in:
Executable
+665
@@ -0,0 +1,665 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
IT Pro Partner — RingLogix Sync Engine
|
||||
|
||||
Syncs customer VoIP data from RingLogix (NetSapiens/CPaaS) API to local DB.
|
||||
Designed to run as a cron job every 15-30 minutes.
|
||||
|
||||
Requires:
|
||||
- psycopg2 or sqlite3 (depending on backend)
|
||||
- requests or urllib (stdlib-compatible version below)
|
||||
- RingLogix OAuth credentials (client_id, client_secret, reseller username/password)
|
||||
|
||||
Usage:
|
||||
python3 sync_ringlogix.py # full sync all domains
|
||||
python3 sync_ringlogix.py --domain acme.ringlogix.com # single domain
|
||||
|
||||
Environment variables (or .env file):
|
||||
RINGLOGIX_CLIENT_ID
|
||||
RINGLOGIX_CLIENT_SECRET
|
||||
RINGLOGIX_USERNAME # Reseller account username
|
||||
RINGLOGIX_PASSWORD # Reseller account password
|
||||
DATABASE_URL # postgresql://... or sqlite:///path
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# ─── Config ─────────────────────────────────────────────────────────
|
||||
|
||||
BASE_URL = "https://api.ringlogix.com/pbx/v1/"
|
||||
TOKEN_URL = urllib.parse.urljoin(BASE_URL, "oauth2/token/")
|
||||
|
||||
CLIENT_ID = os.environ.get("RINGLOGIX_CLIENT_ID", "")
|
||||
CLIENT_SECRET = os.environ.get("RINGLOGIX_CLIENT_SECRET", "")
|
||||
USERNAME = os.environ.get("RINGLOGIX_USERNAME", "")
|
||||
PASSWORD = os.environ.get("RINGLOGIX_PASSWORD", "")
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///portal.db")
|
||||
|
||||
# ─── HTTP Helpers ───────────────────────────────────────────────────
|
||||
|
||||
_ctx = ssl.create_default_context()
|
||||
|
||||
|
||||
def _request(
|
||||
method: str,
|
||||
url: str,
|
||||
headers: dict[str, str] | None = None,
|
||||
body: str | bytes | None = None,
|
||||
) -> tuple[int, bytes]:
|
||||
if isinstance(body, str):
|
||||
body = body.encode("utf-8")
|
||||
req = urllib.request.Request(url, data=body, headers=headers or {}, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=_ctx, timeout=30) as resp:
|
||||
return resp.status, resp.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read()
|
||||
|
||||
|
||||
# ─── OAuth2 ─────────────────────────────────────────────────────────
|
||||
|
||||
class RingLogixAuth:
|
||||
"""Manages OAuth2 token lifecycle for RingLogix API."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.access_token: str = ""
|
||||
self.refresh_token: str = ""
|
||||
self.expires_at: float = 0 # timestamp
|
||||
|
||||
def _password_grant(self) -> dict[str, Any]:
|
||||
data = urllib.parse.urlencode({
|
||||
"grant_type": "password",
|
||||
"client_id": CLIENT_ID,
|
||||
"client_secret": CLIENT_SECRET,
|
||||
"username": USERNAME,
|
||||
"password": PASSWORD,
|
||||
})
|
||||
status, raw = _request("POST", TOKEN_URL, body=data)
|
||||
if status != 200:
|
||||
raise RuntimeError(f"OAuth password grant failed HTTP {status}: {raw[:300]}")
|
||||
return json.loads(raw)
|
||||
|
||||
def _refresh_grant(self) -> dict[str, Any]:
|
||||
data = urllib.parse.urlencode({
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": CLIENT_ID,
|
||||
"client_secret": CLIENT_SECRET,
|
||||
"refresh_token": self.refresh_token,
|
||||
})
|
||||
status, raw = _request("POST", TOKEN_URL, body=data)
|
||||
if status != 200:
|
||||
# Fall back to password grant
|
||||
return self._password_grant()
|
||||
return json.loads(raw)
|
||||
|
||||
def ensure_token(self) -> None:
|
||||
if self.access_token and time.time() < self.expires_at - 60:
|
||||
return # still valid
|
||||
if self.refresh_token:
|
||||
resp = self._refresh_grant()
|
||||
else:
|
||||
resp = self._password_grant()
|
||||
self.access_token = resp["access_token"]
|
||||
self.refresh_token = resp.get("refresh_token", "")
|
||||
self.expires_at = time.time() + resp.get("expires_in", 3600)
|
||||
|
||||
def api_call(
|
||||
self,
|
||||
object_name: str,
|
||||
action: str,
|
||||
params: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Make a POST to the RingLogix API with OAuth bearer token."""
|
||||
self.ensure_token()
|
||||
payload = {"object": object_name, "action": action}
|
||||
if params:
|
||||
payload.update(params)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.access_token}",
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
|
||||
}
|
||||
body = urllib.parse.urlencode(payload)
|
||||
status, raw = _request("POST", BASE_URL, headers=headers, body=body)
|
||||
if status == 401:
|
||||
# Token expired mid-call, force refresh and retry once
|
||||
self.access_token = ""
|
||||
self.ensure_token()
|
||||
headers["Authorization"] = f"Bearer {self.access_token}"
|
||||
status, raw = _request("POST", BASE_URL, headers=headers, body=body)
|
||||
if status not in (200, 201):
|
||||
raise RuntimeError(
|
||||
f"API call {object_name}/{action} failed HTTP {status}: {raw[:500]}"
|
||||
)
|
||||
result = json.loads(raw)
|
||||
if isinstance(result, list):
|
||||
return {"data": result, "total": len(result)}
|
||||
return result
|
||||
|
||||
def api_get(self, object_name: str, action: str, params: dict[str, str] | None = None) -> list[dict[str, Any]]:
|
||||
"""GET-style read returning a list of results."""
|
||||
resp = self.api_call(object_name, action, params)
|
||||
# NetSapiens API returns data in various shapes
|
||||
if isinstance(resp, dict):
|
||||
data = resp.get("data", resp.get("response", []))
|
||||
if isinstance(data, dict):
|
||||
return [data]
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
return [resp] if isinstance(resp, dict) else []
|
||||
|
||||
|
||||
# ─── DB Layer ───────────────────────────────────────────────────────
|
||||
|
||||
class Database:
|
||||
"""Minimal SQLite DB for MVP. Swap for psycopg2/asyncpg in production."""
|
||||
|
||||
def __init__(self, dsn: str) -> None:
|
||||
import sqlite3
|
||||
|
||||
self.conn = sqlite3.connect(dsn.replace("sqlite:///", ""))
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
self.conn.execute("PRAGMA journal_mode=WAL")
|
||||
self.conn.execute("PRAGMA foreign_keys=ON")
|
||||
self._ensure_schema()
|
||||
|
||||
def _ensure_schema(self) -> None:
|
||||
self.conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
id TEXT PRIMARY KEY,
|
||||
company_name TEXT NOT NULL,
|
||||
contact_email TEXT NOT NULL,
|
||||
contact_phone TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS service_subscriptions (
|
||||
id TEXT PRIMARY KEY,
|
||||
customer_id TEXT REFERENCES customers(id),
|
||||
service_type TEXT NOT NULL CHECK (service_type IN ('voip','web_hosting','fleet_tracking')),
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
plan_name TEXT,
|
||||
monthly_price REAL,
|
||||
next_billing_date TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS voip_subscriptions (
|
||||
id TEXT PRIMARY KEY,
|
||||
subscription_id TEXT REFERENCES service_subscriptions(id),
|
||||
ringlogix_domain TEXT NOT NULL UNIQUE,
|
||||
max_extensions INTEGER DEFAULT 5,
|
||||
max_auto_attendants INTEGER DEFAULT 1,
|
||||
max_call_queues INTEGER DEFAULT 1,
|
||||
last_synced_at TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS voip_extensions (
|
||||
id TEXT PRIMARY KEY,
|
||||
voip_subscription_id TEXT REFERENCES voip_subscriptions(id),
|
||||
extension TEXT NOT NULL,
|
||||
user_login TEXT,
|
||||
full_name TEXT,
|
||||
email TEXT,
|
||||
department TEXT,
|
||||
device_mac TEXT,
|
||||
device_model TEXT,
|
||||
device_status TEXT DEFAULT 'offline',
|
||||
features TEXT DEFAULT '{}',
|
||||
cf_always TEXT,
|
||||
cf_busy TEXT,
|
||||
cf_no_answer TEXT,
|
||||
vm_enabled INTEGER DEFAULT 1,
|
||||
vm_email_notification INTEGER DEFAULT 0,
|
||||
vm_transcription INTEGER DEFAULT 0,
|
||||
recording_enabled INTEGER DEFAULT 0,
|
||||
presence TEXT DEFAULT 'offline',
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(voip_subscription_id, extension)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS voip_dids (
|
||||
id TEXT PRIMARY KEY,
|
||||
voip_subscription_id TEXT REFERENCES voip_subscriptions(id),
|
||||
phone_number TEXT NOT NULL UNIQUE,
|
||||
label TEXT,
|
||||
routing TEXT,
|
||||
routing_target TEXT,
|
||||
caller_id_name TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS voip_auto_attendants (
|
||||
id TEXT PRIMARY KEY,
|
||||
voip_subscription_id TEXT REFERENCES voip_subscriptions(id),
|
||||
name TEXT NOT NULL,
|
||||
greeting_type TEXT,
|
||||
greeting_text TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS voip_call_queues (
|
||||
id TEXT PRIMARY KEY,
|
||||
voip_subscription_id TEXT REFERENCES voip_subscriptions(id),
|
||||
name TEXT NOT NULL,
|
||||
routing_strategy TEXT,
|
||||
max_wait_time_seconds INTEGER DEFAULT 300,
|
||||
agents TEXT DEFAULT '[]',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS voip_feature_usage (
|
||||
id TEXT PRIMARY KEY,
|
||||
voip_subscription_id TEXT REFERENCES voip_subscriptions(id),
|
||||
feature_key TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 0,
|
||||
current_qty INTEGER DEFAULT 0,
|
||||
max_qty INTEGER,
|
||||
last_verified_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(voip_subscription_id, feature_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS voip_plan_features (
|
||||
id TEXT PRIMARY KEY,
|
||||
plan_name TEXT NOT NULL,
|
||||
feature_key TEXT NOT NULL,
|
||||
feature_label TEXT NOT NULL,
|
||||
feature_category TEXT,
|
||||
included INTEGER DEFAULT 1,
|
||||
max_qty INTEGER,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
UNIQUE(plan_name, feature_key)
|
||||
);
|
||||
""")
|
||||
self.conn.commit()
|
||||
|
||||
def upsert(self, table: str, data: dict[str, Any], unique_cols: list[str]) -> None:
|
||||
"""Upsert by deleting existing matching row(s) then inserting."""
|
||||
if unique_cols:
|
||||
where = " AND ".join(f"{c}=?" for c in unique_cols)
|
||||
vals = tuple(data.get(c) for c in unique_cols)
|
||||
self.conn.execute(f"DELETE FROM {table} WHERE {where}", vals)
|
||||
cols = ", ".join(data.keys())
|
||||
placeholders = ", ".join("?" for _ in data)
|
||||
self.conn.execute(
|
||||
f"INSERT INTO {table} ({cols}) VALUES ({placeholders})",
|
||||
tuple(data.values()),
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
def get_customer_by_domain(self, domain: str) -> dict | None:
|
||||
row = self.conn.execute(
|
||||
"SELECT c.* FROM customers c "
|
||||
"JOIN service_subscriptions s ON s.customer_id = c.id "
|
||||
"JOIN voip_subscriptions v ON v.subscription_id = s.id "
|
||||
"WHERE v.ringlogix_domain = ?",
|
||||
(domain,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def upsert_customer_for_domain(self, domain: str, company: str, email: str) -> str:
|
||||
"""Create/find customer + subscription + voip_subscription for a domain."""
|
||||
existing = self.get_customer_by_domain(domain)
|
||||
import uuid
|
||||
|
||||
if existing:
|
||||
# Update fields
|
||||
self.conn.execute(
|
||||
"UPDATE customers SET company_name=?, contact_email=? WHERE id=?",
|
||||
(company, email, existing["id"]),
|
||||
)
|
||||
cust_id = existing["id"]
|
||||
else:
|
||||
cust_id = str(uuid.uuid4())
|
||||
self.conn.execute(
|
||||
"INSERT INTO customers (id, company_name, contact_email) VALUES (?, ?, ?)",
|
||||
(cust_id, company, email),
|
||||
)
|
||||
# Create subscription
|
||||
sub_id = str(uuid.uuid4())
|
||||
self.conn.execute(
|
||||
"INSERT INTO service_subscriptions (id, customer_id, service_type, plan_name) "
|
||||
"VALUES (?, ?, 'voip', 'Business Pro')",
|
||||
(sub_id, cust_id),
|
||||
)
|
||||
# Create voip_subscription
|
||||
vs_id = str(uuid.uuid4())
|
||||
self.conn.execute(
|
||||
"INSERT INTO voip_subscriptions (id, subscription_id, ringlogix_domain) "
|
||||
"VALUES (?, ?, ?)",
|
||||
(vs_id, sub_id, domain),
|
||||
)
|
||||
self.conn.commit()
|
||||
return cust_id
|
||||
|
||||
def get_voip_subscription_id(self, domain: str) -> str | None:
|
||||
row = self.conn.execute(
|
||||
"SELECT id FROM voip_subscriptions WHERE ringlogix_domain = ?",
|
||||
(domain,),
|
||||
).fetchone()
|
||||
return row["id"] if row else None
|
||||
|
||||
def close(self) -> None:
|
||||
self.conn.close()
|
||||
|
||||
|
||||
# ─── Sync Functions ─────────────────────────────────────────────────
|
||||
|
||||
def _uid() -> str:
|
||||
import uuid
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def sync_domain(auth: RingLogixAuth, db: Database, domain: str) -> dict[str, Any]:
|
||||
"""Full sync of a single RingLogix domain."""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
summary = {"domain": domain, "extensions": 0, "dids": 0, "auto_attendants": 0, "call_queues": 0}
|
||||
|
||||
vs_id = db.get_voip_subscription_id(domain)
|
||||
if not vs_id:
|
||||
vs_id = str(_uid())
|
||||
sub_id = str(_uid())
|
||||
cust_id = str(_uid())
|
||||
db.conn.execute(
|
||||
"INSERT INTO customers (id, company_name, contact_email) VALUES (?, ?, ?)",
|
||||
(cust_id, domain.split(".")[0].title() + " LLC", f"billing@{domain}"),
|
||||
)
|
||||
db.conn.execute(
|
||||
"INSERT INTO service_subscriptions (id, customer_id, service_type) VALUES (?, ?, 'voip')",
|
||||
(sub_id, cust_id),
|
||||
)
|
||||
db.conn.execute(
|
||||
"INSERT INTO voip_subscriptions (id, subscription_id, ringlogix_domain) VALUES (?, ?, ?)",
|
||||
(vs_id, sub_id, domain),
|
||||
)
|
||||
db.conn.commit()
|
||||
|
||||
# 1. Count subscribers
|
||||
try:
|
||||
count_resp = auth.api_call("subscriber", "count", {"domain": domain})
|
||||
total_subs = int(count_resp.get("total", 0))
|
||||
except Exception:
|
||||
total_subs = 0
|
||||
|
||||
# 2. Read all subscribers
|
||||
try:
|
||||
subs = auth.api_get("subscriber", "read", {
|
||||
"domain": domain, "scope": "all",
|
||||
"fields": "user,first_name,last_name,email,presence,account_status,time_zone,vmail_provisioned,voicemail_emergency,recording,cf_always,cf_busy,cf_no_answer",
|
||||
})
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Subscriber read failed: {e}")
|
||||
subs = []
|
||||
|
||||
for sub in subs:
|
||||
ext = sub.get("user", "")
|
||||
if not ext:
|
||||
continue
|
||||
features = {
|
||||
"voicemail": sub.get("vmail_provisioned", "no") == "yes",
|
||||
"recording": sub.get("recording", "no") == "yes",
|
||||
"call_forwarding": bool(sub.get("cf_always") or sub.get("cf_busy") or sub.get("cf_no_answer")),
|
||||
}
|
||||
db.upsert("voip_extensions", {
|
||||
"id": str(_uid()),
|
||||
"voip_subscription_id": vs_id,
|
||||
"extension": ext,
|
||||
"user_login": sub.get("login", ""),
|
||||
"full_name": f"{sub.get('first_name','')} {sub.get('last_name','')}".strip(),
|
||||
"email": sub.get("email", ""),
|
||||
"department": sub.get("group", ""),
|
||||
"presence": sub.get("presence", "offline"),
|
||||
"features": json.dumps(features),
|
||||
"cf_always": sub.get("cf_always", ""),
|
||||
"cf_busy": sub.get("cf_busy", ""),
|
||||
"cf_no_answer": sub.get("cf_no_answer", ""),
|
||||
"vm_enabled": 1 if features["voicemail"] else 0,
|
||||
"recording_enabled": 1 if features["recording"] else 0,
|
||||
"updated_at": now,
|
||||
}, ["voip_subscription_id", "extension"])
|
||||
summary["extensions"] += 1
|
||||
|
||||
# 3. Read devices
|
||||
try:
|
||||
devices = auth.api_get("device", "read", {"domain": domain})
|
||||
# We can match devices to extensions by user field
|
||||
for dev in devices:
|
||||
ext_user = dev.get("user", "")
|
||||
if ext_user:
|
||||
dev_mac = dev.get("mac", "")
|
||||
dev_model = dev.get("model", "")
|
||||
dev_status = "registered" if dev.get("registration_time") else "offline"
|
||||
# Update the matching extension's device info
|
||||
rows = db.conn.execute(
|
||||
"SELECT id FROM voip_extensions WHERE voip_subscription_id=? AND extension=?",
|
||||
(vs_id, ext_user),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
db.conn.execute(
|
||||
"UPDATE voip_extensions SET device_mac=?, device_model=?, device_status=?, updated_at=? WHERE id=?",
|
||||
(dev_mac, dev_model, dev_status, now, row["id"]),
|
||||
)
|
||||
db.conn.commit()
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Device read failed: {e}")
|
||||
|
||||
# 4. Read DIDs / phone numbers
|
||||
try:
|
||||
dids = auth.api_get("phonenumber", "read", {"domain": domain})
|
||||
for did in dids:
|
||||
number = did.get("phonenumber", did.get("number", ""))
|
||||
if not number:
|
||||
continue
|
||||
db.upsert("voip_dids", {
|
||||
"id": str(_uid()),
|
||||
"voip_subscription_id": vs_id,
|
||||
"phone_number": number,
|
||||
"label": did.get("description", did.get("label", "")),
|
||||
"routing": did.get("route_type", ""),
|
||||
"routing_target": did.get("route_dest", ""),
|
||||
"caller_id_name": did.get("caller_id_name_emerge", ""),
|
||||
}, ["phone_number"])
|
||||
summary["dids"] += 1
|
||||
except Exception as e:
|
||||
print(f" ⚠️ DID read failed: {e}")
|
||||
|
||||
# 5. Read auto attendants
|
||||
try:
|
||||
attendants = auth.api_get("autoattendant", "read", {"domain": domain})
|
||||
for aa in attendants:
|
||||
name = aa.get("name", aa.get("user", "Main"))
|
||||
db.upsert("voip_auto_attendants", {
|
||||
"id": str(_uid()),
|
||||
"voip_subscription_id": vs_id,
|
||||
"name": name,
|
||||
"greeting_type": aa.get("greeting_type", ""),
|
||||
"greeting_text": aa.get("greeting_text", ""),
|
||||
}, ["voip_subscription_id", "name"])
|
||||
summary["auto_attendants"] += 1
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Auto attendant read failed: {e}")
|
||||
|
||||
# 6. Read call queues
|
||||
try:
|
||||
queues = auth.api_get("callqueue", "read", {"domain": domain})
|
||||
for q in queues:
|
||||
name = q.get("name", q.get("queue_name", "Queue"))
|
||||
db.upsert("voip_call_queues", {
|
||||
"id": str(_uid()),
|
||||
"voip_subscription_id": vs_id,
|
||||
"name": name,
|
||||
"routing_strategy": q.get("routing_strategy", q.get("queue_type", "")),
|
||||
"max_wait_time_seconds": int(q.get("max_wait_time", q.get("queue_timeout", 300))),
|
||||
"agents": json.dumps(q.get("agents", [])),
|
||||
}, ["voip_subscription_id", "name"])
|
||||
summary["call_queues"] += 1
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Call queue read failed: {e}")
|
||||
|
||||
# Update last_synced_at
|
||||
db.conn.execute(
|
||||
"UPDATE voip_subscriptions SET last_synced_at=? WHERE id=?",
|
||||
(now, vs_id),
|
||||
)
|
||||
db.conn.commit()
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
def sync_all(auth: RingLogixAuth, db: Database, target_domain: str | None = None) -> list[dict[str, Any]]:
|
||||
"""Sync all domains under the reseller account, or a single domain."""
|
||||
if target_domain:
|
||||
print(f"Syncing single domain: {target_domain}")
|
||||
return [sync_domain(auth, db, target_domain)]
|
||||
|
||||
# List all domains
|
||||
try:
|
||||
domains = auth.api_get("domain", "read", {})
|
||||
except Exception as e:
|
||||
print(f"Failed to list domains: {e}")
|
||||
return []
|
||||
|
||||
results = []
|
||||
for d in domains:
|
||||
domain_name = d.get("domain", "")
|
||||
if domain_name:
|
||||
print(f"Syncing {domain_name}...")
|
||||
try:
|
||||
summary = sync_domain(auth, db, domain_name)
|
||||
results.append(summary)
|
||||
print(f" ✅ {summary['extensions']} ext, {summary['dids']} DIDs, "
|
||||
f"{summary['auto_attendants']} AA, {summary['call_queues']} queues")
|
||||
except Exception as e:
|
||||
print(f" ❌ {e}")
|
||||
return results
|
||||
|
||||
|
||||
# ─── Feature Definitions (seed data) ────────────────────────────────
|
||||
|
||||
DEFAULT_FEATURES = [
|
||||
# (plan, key, label, category, included, max_qty, sort)
|
||||
("Business Basic", "call_forwarding", "Call Forwarding (Always/Busy/No Answer)", "Call Management", True, None, 1),
|
||||
("Business Basic", "call_transfer", "Call Transfer (Attended/Blind/Intercom)", "Call Management", True, None, 2),
|
||||
("Business Basic", "three_way_calling", "Three-Way Calling", "Call Management", True, None, 3),
|
||||
("Business Basic", "voicemail", "Voicemail-to-Email", "Voicemail", True, None, 4),
|
||||
("Business Basic", "dnd", "Do Not Disturb", "Call Management", True, None, 5),
|
||||
("Business Basic", "auto_attendant", "Auto Attendant", "Auto Attendant", True, 1, 6),
|
||||
("Business Basic", "call_recording", "Call Recording", "Call Recording", False, None, 7),
|
||||
("Business Basic", "call_queue", "Call Queue", "Call Queues", False, None, 8),
|
||||
("Business Basic", "vm_transcription", "Voicemail Transcription", "Voicemail", False, None, 9),
|
||||
("Business Pro", "call_forwarding", "Call Forwarding (Always/Busy/No Answer/Find Me Follow Me)", "Call Management", True, None, 1),
|
||||
("Business Pro", "call_transfer", "Call Transfer (Attended/Blind/Intercom)", "Call Management", True, None, 2),
|
||||
("Business Pro", "three_way_calling", "Three-Way Calling", "Call Management", True, None, 3),
|
||||
("Business Pro", "voicemail", "Voicemail-to-Email", "Voicemail", True, None, 4),
|
||||
("Business Pro", "vm_transcription", "Voicemail Transcription", "Voicemail", True, None, 5),
|
||||
("Business Pro", "dnd", "Do Not Disturb", "Call Management", True, None, 6),
|
||||
("Business Pro", "auto_attendant", "Auto Attendant", "Auto Attendant", True, 2, 7),
|
||||
("Business Pro", "call_queue", "Call Queues", "Call Queues", True, 3, 8),
|
||||
("Business Pro", "call_recording", "Call Recording", "Call Recording", True, None, 9),
|
||||
("Business Pro", "call_parking", "Call Parking (up to 5)", "Call Management", True, None, 10),
|
||||
("Business Pro", "crm_integration", "CRM Integration", "Integrations", False, None, 11),
|
||||
("Enterprise", "call_forwarding", "Call Forwarding (All options)", "Call Management", True, None, 1),
|
||||
("Enterprise", "call_transfer", "Call Transfer (All options)", "Call Management", True, None, 2),
|
||||
("Enterprise", "three_way_calling", "Three-Way Calling", "Call Management", True, None, 3),
|
||||
("Enterprise", "voicemail", "Voicemail-to-Email", "Voicemail", True, None, 4),
|
||||
("Enterprise", "vm_transcription", "Voicemail Transcription", "Voicemail", True, None, 5),
|
||||
("Enterprise", "dnd", "Do Not Disturb", "Call Management", True, None, 6),
|
||||
("Enterprise", "auto_attendant", "Auto Attendant", "Auto Attendant", True, 5, 7),
|
||||
("Enterprise", "call_queue", "Call Queues", "Call Queues", True, 10, 8),
|
||||
("Enterprise", "call_recording", "Call Recording", "Call Recording", True, None, 9),
|
||||
("Enterprise", "call_parking", "Call Parking (unlimited)", "Call Management", True, None, 10),
|
||||
("Enterprise", "crm_integration", "CRM Integration", "Integrations", True, None, 11),
|
||||
("Enterprise", "skill_based_routing", "Skill-Based Routing", "Call Queues", True, None, 12),
|
||||
("Enterprise", "hot_desking", "Hot Desking / Hoteling", "Advanced", True, None, 13),
|
||||
]
|
||||
|
||||
|
||||
def seed_features(db: Database) -> None:
|
||||
"""Insert default feature definitions if empty."""
|
||||
count = db.conn.execute("SELECT COUNT(*) as c FROM voip_plan_features").fetchone()["c"]
|
||||
if count > 0:
|
||||
return
|
||||
for plan, key, label, cat, included, max_qty, sort in DEFAULT_FEATURES:
|
||||
import uuid
|
||||
db.conn.execute(
|
||||
"INSERT INTO voip_plan_features (id, plan_name, feature_key, feature_label, "
|
||||
"feature_category, included, max_qty, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(str(uuid.uuid4()), plan, key, label, cat, 1 if included else 0, max_qty, sort),
|
||||
)
|
||||
db.conn.commit()
|
||||
print(f" ✅ Seeded {len(DEFAULT_FEATURES)} plan features")
|
||||
|
||||
|
||||
# ─── Main ────────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> None:
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="RingLogix Sync Engine")
|
||||
parser.add_argument("--domain", help="Sync only this domain")
|
||||
parser.add_argument("--db", help="Database URL (overrides DATABASE_URL env)")
|
||||
parser.add_argument("--seed", action="store_true", help="Seed plan features only, no sync")
|
||||
args = parser.parse_args()
|
||||
|
||||
db_url = args.db or DATABASE_URL
|
||||
if not db_url:
|
||||
print("❌ DATABASE_URL not set. Use --db or set env.")
|
||||
sys.exit(1)
|
||||
|
||||
db = Database(db_url)
|
||||
print(f"📦 Database: {db_url}")
|
||||
|
||||
if args.seed:
|
||||
seed_features(db)
|
||||
db.close()
|
||||
return
|
||||
|
||||
# Validate RingLogix credentials
|
||||
missing = []
|
||||
for var, name in [
|
||||
(CLIENT_ID, "RINGLOGIX_CLIENT_ID"),
|
||||
(CLIENT_SECRET, "RINGLOGIX_CLIENT_SECRET"),
|
||||
(USERNAME, "RINGLOGIX_USERNAME"),
|
||||
(PASSWORD, "RINGLOGIX_PASSWORD"),
|
||||
]:
|
||||
if not var:
|
||||
missing.append(name)
|
||||
if missing:
|
||||
print(f"❌ Missing env vars: {', '.join(missing)}")
|
||||
print(" Set them in .env or export them.")
|
||||
db.close()
|
||||
sys.exit(1)
|
||||
|
||||
auth = RingLogixAuth()
|
||||
|
||||
# Seed features if empty
|
||||
seed_features(db)
|
||||
|
||||
# Sync
|
||||
results = sync_all(auth, db, args.domain)
|
||||
print(f"\n{'='*50}")
|
||||
print(f"Sync complete: {len(results)} domains processed")
|
||||
for r in results:
|
||||
print(f" {r['domain']}: {r['extensions']} ext, {r['dids']} DIDs, "
|
||||
f"{r['auto_attendants']} AA, {r['call_queues']} queues")
|
||||
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user