bmc_hub/app/services/vtiger_service.py

487 lines
19 KiB
Python
Raw Normal View History

"""
vTiger Cloud CRM Integration Service
Handles subscription and sales order data retrieval
"""
import logging
import json
import aiohttp
from typing import List, Dict, Optional
from app.core.config import settings
logger = logging.getLogger(__name__)
class VTigerService:
"""Service for integrating with vTiger Cloud CRM via REST API"""
def __init__(self):
self.base_url = getattr(settings, 'VTIGER_URL', None)
self.username = getattr(settings, 'VTIGER_USERNAME', None)
self.api_key = getattr(settings, 'VTIGER_API_KEY', None)
# REST API endpoint
if self.base_url:
self.rest_endpoint = f"{self.base_url}/restapi/v1/vtiger/default"
else:
self.rest_endpoint = None
if not all([self.base_url, self.username, self.api_key]):
logger.warning("⚠️ vTiger credentials not fully configured")
def _get_auth(self):
"""Get HTTP Basic Auth credentials"""
if not self.api_key:
raise ValueError("VTIGER_API_KEY not configured")
return aiohttp.BasicAuth(self.username, self.api_key)
async def query(self, query_string: str) -> List[Dict]:
"""
Execute a query on vTiger REST API
Args:
query_string: SQL-like query (e.g., "SELECT * FROM Accounts;")
Returns:
List of records
"""
if not self.rest_endpoint:
raise ValueError("VTIGER_URL not configured")
try:
auth = self._get_auth()
async with aiohttp.ClientSession() as session:
async with session.get(
f"{self.rest_endpoint}/query",
params={"query": query_string},
auth=auth
) as response:
text = await response.text()
if response.status == 200:
# vTiger returns text/json instead of application/json
import json
try:
data = json.loads(text)
except json.JSONDecodeError as e:
logger.error(f"❌ Invalid JSON in query response: {text[:200]}")
return []
if data.get('success'):
result = data.get('result', [])
logger.info(f"✅ Query returned {len(result)} records")
return result
else:
logger.error(f"❌ vTiger query failed: {data.get('error')}")
return []
else:
logger.error(f"❌ vTiger query HTTP error {response.status}")
logger.error(f"Query: {query_string}")
logger.error(f"Response: {text[:500]}")
return []
except Exception as e:
logger.error(f"❌ vTiger query error: {e}")
return []
async def get_account_by_id(self, account_id: str) -> Optional[Dict]:
"""
Fetch a single account by ID from vTiger
Args:
account_id: vTiger account ID (e.g., "3x760")
Returns:
Account record or None
"""
if not account_id:
logger.warning("⚠️ No account ID provided")
return None
try:
query = f"SELECT * FROM Accounts WHERE id='{account_id}' LIMIT 1;"
results = await self.query(query)
if results and len(results) > 0:
logger.info(f"✅ Found account {account_id}")
return results[0]
else:
logger.warning(f"⚠️ No account found with ID {account_id}")
return None
except Exception as e:
logger.error(f"❌ Error fetching account {account_id}: {e}")
return None
async def update_account(self, account_id: str, update_data: Dict) -> bool:
"""
Update an account in vTiger
Args:
account_id: vTiger account ID (e.g., "3x760")
update_data: Dictionary of fields to update
Returns:
True if successful, False otherwise
"""
if not self.rest_endpoint:
raise ValueError("VTIGER_URL not configured")
try:
auth = self._get_auth()
# vTiger requires the ID in the data
payload = {
'id': account_id,
**update_data
}
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.rest_endpoint}/update",
json=payload,
auth=auth
) as response:
text = await response.text()
if response.status == 200:
import json
try:
data = json.loads(text)
if data.get('success'):
logger.info(f"✅ Updated vTiger account {account_id}")
return True
else:
logger.error(f"❌ vTiger update failed: {data.get('error')}")
return False
except json.JSONDecodeError:
logger.error(f"❌ Invalid JSON in update response: {text[:200]}")
return False
else:
logger.error(f"❌ vTiger update HTTP error {response.status}: {text[:500]}")
return False
except Exception as e:
logger.error(f"❌ Error updating vTiger account {account_id}: {e}")
return False
async def get_customer_sales_orders(self, vtiger_account_id: str) -> List[Dict]:
"""
Fetch sales orders for a customer from vTiger
Args:
vtiger_account_id: vTiger account ID (e.g., "3x760")
Returns:
List of sales order records
"""
if not vtiger_account_id:
logger.warning("⚠️ No vTiger account ID provided")
return []
try:
# Query for sales orders linked to this account
query = f"SELECT * FROM SalesOrder WHERE account_id='{vtiger_account_id}';"
logger.info(f"🔍 Fetching sales orders for vTiger account {vtiger_account_id}")
orders = await self.query(query)
logger.info(f"✅ Found {len(orders)} sales orders")
return orders
except Exception as e:
logger.error(f"❌ Error fetching sales orders: {e}")
return []
async def get_customer_subscriptions(self, vtiger_account_id: str) -> List[Dict]:
"""
Fetch subscriptions for a customer from vTiger
Args:
vtiger_account_id: vTiger account ID (e.g., "3x760")
Returns:
List of subscription records
"""
if not vtiger_account_id:
logger.warning("⚠️ No vTiger account ID provided")
return []
try:
# Query for subscriptions linked to this account (note: module name is singular "Subscription")
query = f"SELECT * FROM Subscription WHERE account_id='{vtiger_account_id}';"
logger.info(f"🔍 Fetching subscriptions for vTiger account {vtiger_account_id}")
subscriptions = await self.query(query)
logger.info(f"✅ Found {len(subscriptions)} subscriptions")
return subscriptions
except Exception as e:
logger.error(f"❌ Error fetching subscriptions: {e}")
return []
async def get_subscription(self, subscription_id: str) -> Optional[Dict]:
"""
Fetch full subscription details with LineItems from vTiger
Args:
subscription_id: vTiger subscription ID (e.g., "72x123")
Returns:
Subscription record with LineItems array or None
"""
if not self.rest_endpoint:
raise ValueError("VTIGER_URL not configured")
try:
auth = self._get_auth()
async with aiohttp.ClientSession() as session:
url = f"{self.rest_endpoint}/retrieve?id={subscription_id}"
async with session.get(url, auth=auth) as response:
if response.status == 200:
text = await response.text()
data = json.loads(text)
if data.get('success'):
logger.info(f"✅ Retrieved subscription {subscription_id}")
return data.get('result')
else:
logger.error(f"❌ vTiger API error: {data.get('error')}")
return None
else:
logger.error(f"❌ HTTP {response.status} retrieving subscription")
return None
except Exception as e:
logger.error(f"❌ Error retrieving subscription: {e}")
return None
async def get_account(self, vtiger_account_id: str) -> Optional[Dict]:
"""
Fetch account details from vTiger
Args:
vtiger_account_id: vTiger account ID (e.g., "3x760")
Returns:
Account record with BMC Låst field (cf_accounts_bmclst) or None
"""
if not vtiger_account_id:
logger.warning("⚠️ No vTiger account ID provided")
return None
try:
# Query for account by ID
query = f"SELECT * FROM Accounts WHERE id='{vtiger_account_id}';"
logger.info(f"🔍 Fetching account details for {vtiger_account_id}")
accounts = await self.query(query)
if accounts:
logger.info(f"✅ Found account: {accounts[0].get('accountname', 'Unknown')}")
return accounts[0]
else:
logger.warning(f"⚠️ No account found with ID {vtiger_account_id}")
return None
except Exception as e:
logger.error(f"❌ Error fetching account: {e}")
return None
async def test_connection(self) -> bool:
"""
Test vTiger connection using /me endpoint
Returns:
True if connection successful
"""
if not self.rest_endpoint:
raise ValueError("VTIGER_URL not configured in .env")
try:
auth = self._get_auth()
logger.info(f"🔑 Testing vTiger connection...")
async with aiohttp.ClientSession() as session:
async with session.get(
f"{self.rest_endpoint}/me",
auth=auth
) as response:
if response.status == 200:
# vTiger returns text/json instead of application/json
text = await response.text()
import json
data = json.loads(text)
if data.get('success'):
user_name = data['result'].get('user_name')
logger.info(f"✅ vTiger connection successful (user: {user_name})")
return True
else:
logger.error(f"❌ vTiger API returned success=false: {data}")
return False
else:
error_text = await response.text()
logger.error(f"❌ vTiger connection failed: HTTP {response.status}: {error_text}")
return False
except Exception as e:
logger.error(f"❌ vTiger connection error: {e}")
return False
async def create_subscription(
self,
account_id: str,
subject: str,
startdate: str,
generateinvoiceevery: str,
subscriptionstatus: str = "Active",
enddate: Optional[str] = None,
products: Optional[List[Dict]] = None
) -> Dict:
"""
Create a new subscription in vTiger
Args:
account_id: vTiger account ID (e.g., "3x123")
subject: Subscription subject/name
startdate: Start date (YYYY-MM-DD)
generateinvoiceevery: Frequency ("Monthly", "Quarterly", "Yearly")
subscriptionstatus: Status ("Active", "Cancelled", "Stopped")
enddate: End date (YYYY-MM-DD), optional
products: List of products [{"productid": "id", "quantity": 1, "listprice": 100}]
"""
if not self.rest_endpoint:
raise ValueError("VTIGER_URL not configured")
try:
auth = self._get_auth()
# Build subscription data
subscription_data = {
"account_id": account_id,
"subject": subject,
"startdate": startdate,
"generateinvoiceevery": generateinvoiceevery,
"subscriptionstatus": subscriptionstatus,
}
if enddate:
subscription_data["enddate"] = enddate
# Add products if provided
if products:
subscription_data["products"] = products
async with aiohttp.ClientSession() as session:
create_url = f"{self.rest_endpoint}/create"
payload = {
"elementType": "Subscription",
"element": subscription_data
}
logger.info(f"📤 Creating subscription: {subject}")
async with session.post(create_url, json=payload, auth=auth) as response:
if response.status == 200:
data = await response.json()
if data.get("success"):
result = data.get("result", {})
logger.info(f"✅ Created subscription: {result.get('id')}")
return result
else:
error_msg = data.get("error", {}).get("message", "Unknown error")
raise Exception(f"vTiger API error: {error_msg}")
else:
error_text = await response.text()
raise Exception(f"HTTP {response.status}: {error_text}")
except Exception as e:
logger.error(f"❌ Error creating subscription: {e}")
raise
async def update_subscription(self, subscription_id: str, updates: Dict, line_items: List[Dict] = None) -> Dict:
"""
Update a subscription in vTiger with optional line item price updates
Args:
subscription_id: vTiger subscription ID (e.g., "72x123")
updates: Dictionary of fields to update (subject, startdate, etc.)
line_items: Optional list of line items with updated prices
Each item: {"productid": "6x123", "quantity": "3", "listprice": "299.00"}
"""
if not self.rest_endpoint:
raise ValueError("VTIGER_URL not configured")
try:
auth = self._get_auth()
async with aiohttp.ClientSession() as session:
# For custom fields (cf_*), we need to retrieve first for context
if any(k.startswith('cf_') for k in updates.keys()):
logger.info(f"📥 Retrieving subscription {subscription_id} for custom field update")
current_sub = await self.get_subscription(subscription_id)
if current_sub:
# Only include essential fields + custom field update
essential_fields = ['id', 'account_id', 'subject', 'startdate',
'generateinvoiceevery', 'subscriptionstatus']
element_data = {
k: current_sub[k]
for k in essential_fields
if k in current_sub
}
element_data.update(updates)
element_data['id'] = subscription_id
else:
element_data = {"id": subscription_id, **updates}
else:
element_data = {"id": subscription_id, **updates}
update_url = f"{self.rest_endpoint}/update"
# Add LineItems if provided
if line_items:
element_data["LineItems"] = line_items
logger.info(f"📝 Updating {len(line_items)} line items")
payload = {
"elementType": "Subscription",
"element": element_data
}
logger.info(f"📤 Updating subscription {subscription_id}: {list(updates.keys())}")
logger.debug(f"Payload: {json.dumps(payload, indent=2)}")
async with session.post(update_url, json=payload, auth=auth) as response:
if response.status == 200:
text = await response.text()
data = json.loads(text)
if data.get("success"):
result = data.get("result", {})
logger.info(f"✅ Updated subscription: {subscription_id}")
return result
else:
error_msg = data.get("error", {}).get("message", "Unknown error")
logger.error(f"❌ vTiger error response: {data.get('error')}")
raise Exception(f"vTiger API error: {error_msg}")
else:
error_text = await response.text()
logger.error(f"❌ vTiger HTTP error: {error_text[:500]}")
raise Exception(f"HTTP {response.status}: {error_text}")
except Exception as e:
logger.error(f"❌ Error updating subscription: {e}")
raise
# Singleton instance
_vtiger_service = None
def get_vtiger_service() -> VTigerService:
"""Get or create vTiger service singleton"""
global _vtiger_service
if _vtiger_service is None:
_vtiger_service = VTigerService()
return _vtiger_service