2025-12-11 23:14:20 +01:00
|
|
|
"""
|
|
|
|
|
vTiger Cloud CRM Integration Service
|
|
|
|
|
Handles subscription and sales order data retrieval
|
|
|
|
|
"""
|
|
|
|
|
import logging
|
2025-12-13 12:06:28 +01:00
|
|
|
import json
|
2025-12-11 23:14:20 +01:00
|
|
|
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 []
|
|
|
|
|
|
2026-01-08 18:28:00 +01:00
|
|
|
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
|
|
|
|
|
|
2025-12-11 23:14:20 +01:00
|
|
|
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 []
|
|
|
|
|
|
2025-12-13 12:06:28 +01:00
|
|
|
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
|
|
|
|
|
|
2025-12-11 23:14:20 +01:00
|
|
|
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
|
2025-12-13 12:06:28 +01:00
|
|
|
|
|
|
|
|
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
|
2025-12-11 23:14:20 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# 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
|