feat: Add Analytics, Odoo Integration, A/B Testing, and Content features
Phase 1 - Analytics y Reportes: - PostMetrics and AnalyticsReport models for tracking engagement - Analytics service with dashboard stats, top posts, optimal times - 8 API endpoints at /api/analytics/* - Interactive dashboard with Chart.js charts - Celery tasks for metrics fetch (15min) and weekly reports Phase 2 - Integración Odoo: - Lead and OdooSyncLog models for CRM integration - Odoo fields added to Product and Service models - XML-RPC service for bidirectional sync - Lead management API at /api/leads/* - Leads dashboard template - Celery tasks for product/service sync and lead export Phase 3 - A/B Testing y Recycling: - ABTest, ABTestVariant, RecycledPost models - Statistical winner analysis using chi-square test - Content recycling with engagement-based scoring - APIs at /api/ab-tests/* and /api/recycling/* - Automated test evaluation and content recycling tasks Phase 4 - Thread Series y Templates: - ThreadSeries and ThreadPost models for multi-post threads - AI-powered thread generation - Enhanced ImageTemplate with HTML template support - APIs at /api/threads/* and /api/templates/* - Thread scheduling with reply chain support Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
493
app/services/odoo_service.py
Normal file
493
app/services/odoo_service.py
Normal file
@@ -0,0 +1,493 @@
|
||||
"""
|
||||
Odoo Integration Service - Sync products, services, and leads with Odoo ERP.
|
||||
"""
|
||||
|
||||
import xmlrpc.client
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Optional, Any
|
||||
import logging
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.product import Product
|
||||
from app.models.service import Service
|
||||
from app.models.lead import Lead
|
||||
from app.models.odoo_sync_log import OdooSyncLog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OdooService:
|
||||
"""Service for Odoo ERP integration."""
|
||||
|
||||
def __init__(self):
|
||||
self.url = settings.ODOO_URL
|
||||
self.db = settings.ODOO_DB
|
||||
self.username = settings.ODOO_USERNAME
|
||||
self.password = settings.ODOO_PASSWORD
|
||||
self.enabled = settings.ODOO_SYNC_ENABLED
|
||||
self._uid = None
|
||||
self._common = None
|
||||
self._models = None
|
||||
|
||||
def _get_db(self):
|
||||
"""Get database session."""
|
||||
return SessionLocal()
|
||||
|
||||
def _connect(self) -> bool:
|
||||
"""Establish connection to Odoo."""
|
||||
if not self.enabled or not all([self.url, self.db, self.username, self.password]):
|
||||
logger.warning("Odoo not configured or disabled")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Common endpoint for authentication
|
||||
self._common = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/common")
|
||||
|
||||
# Authenticate
|
||||
self._uid = self._common.authenticate(
|
||||
self.db, self.username, self.password, {}
|
||||
)
|
||||
|
||||
if not self._uid:
|
||||
logger.error("Odoo authentication failed")
|
||||
return False
|
||||
|
||||
# Models endpoint for data operations
|
||||
self._models = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/object")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Odoo connection error: {e}")
|
||||
return False
|
||||
|
||||
def _execute(self, model: str, method: str, *args, **kwargs) -> Any:
|
||||
"""Execute an Odoo model method."""
|
||||
if not self._models or not self._uid:
|
||||
if not self._connect():
|
||||
raise ConnectionError("Cannot connect to Odoo")
|
||||
|
||||
return self._models.execute_kw(
|
||||
self.db, self._uid, self.password,
|
||||
model, method, list(args), kwargs
|
||||
)
|
||||
|
||||
async def test_connection(self) -> Dict:
|
||||
"""Test connection to Odoo."""
|
||||
if not self.enabled:
|
||||
return {
|
||||
"connected": False,
|
||||
"error": "Odoo integration not enabled",
|
||||
"configured": bool(self.url)
|
||||
}
|
||||
|
||||
try:
|
||||
if self._connect():
|
||||
version = self._common.version()
|
||||
return {
|
||||
"connected": True,
|
||||
"version": version.get("server_version"),
|
||||
"uid": self._uid
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"connected": False,
|
||||
"error": "Authentication failed"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"connected": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
async def sync_products(self, limit: int = 100) -> Dict:
|
||||
"""
|
||||
Sync products from Odoo to local database.
|
||||
|
||||
Returns:
|
||||
Dict with sync statistics
|
||||
"""
|
||||
db = self._get_db()
|
||||
log = OdooSyncLog(
|
||||
sync_type="products",
|
||||
direction="import",
|
||||
status="started"
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
|
||||
try:
|
||||
if not self._connect():
|
||||
log.mark_failed("Cannot connect to Odoo")
|
||||
db.commit()
|
||||
return {"success": False, "error": "Connection failed"}
|
||||
|
||||
# Fetch products from Odoo
|
||||
product_ids = self._execute(
|
||||
"product.template", "search",
|
||||
[["sale_ok", "=", True], ["active", "=", True]],
|
||||
limit=limit
|
||||
)
|
||||
|
||||
products_data = self._execute(
|
||||
"product.template", "read",
|
||||
product_ids,
|
||||
fields=["id", "name", "description_sale", "list_price", "categ_id",
|
||||
"image_1920", "qty_available", "default_code"]
|
||||
)
|
||||
|
||||
created = 0
|
||||
updated = 0
|
||||
failed = 0
|
||||
|
||||
for odoo_product in products_data:
|
||||
try:
|
||||
# Check if product exists locally
|
||||
local_product = db.query(Product).filter(
|
||||
Product.odoo_product_id == odoo_product["id"]
|
||||
).first()
|
||||
|
||||
product_data = {
|
||||
"name": odoo_product["name"],
|
||||
"description": odoo_product.get("description_sale") or "",
|
||||
"price": odoo_product.get("list_price", 0),
|
||||
"category": odoo_product.get("categ_id", [0, "general"])[1] if odoo_product.get("categ_id") else "general",
|
||||
"stock": int(odoo_product.get("qty_available", 0)),
|
||||
"is_available": odoo_product.get("qty_available", 0) > 0,
|
||||
"odoo_product_id": odoo_product["id"],
|
||||
"odoo_last_synced": datetime.utcnow()
|
||||
}
|
||||
|
||||
if local_product:
|
||||
# Update existing
|
||||
for key, value in product_data.items():
|
||||
setattr(local_product, key, value)
|
||||
updated += 1
|
||||
else:
|
||||
# Create new
|
||||
local_product = Product(**product_data)
|
||||
db.add(local_product)
|
||||
created += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing product {odoo_product.get('id')}: {e}")
|
||||
failed += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
log.records_processed = len(products_data)
|
||||
log.records_created = created
|
||||
log.records_updated = updated
|
||||
log.records_failed = failed
|
||||
log.mark_completed()
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"processed": len(products_data),
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
"failed": failed
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in sync_products: {e}")
|
||||
log.mark_failed(str(e))
|
||||
db.commit()
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
async def sync_services(self, limit: int = 100) -> Dict:
|
||||
"""
|
||||
Sync services from Odoo to local database.
|
||||
Services in Odoo are typically products with type='service'.
|
||||
|
||||
Returns:
|
||||
Dict with sync statistics
|
||||
"""
|
||||
db = self._get_db()
|
||||
log = OdooSyncLog(
|
||||
sync_type="services",
|
||||
direction="import",
|
||||
status="started"
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
|
||||
try:
|
||||
if not self._connect():
|
||||
log.mark_failed("Cannot connect to Odoo")
|
||||
db.commit()
|
||||
return {"success": False, "error": "Connection failed"}
|
||||
|
||||
# Fetch service-type products from Odoo
|
||||
service_ids = self._execute(
|
||||
"product.template", "search",
|
||||
[["type", "=", "service"], ["sale_ok", "=", True], ["active", "=", True]],
|
||||
limit=limit
|
||||
)
|
||||
|
||||
services_data = self._execute(
|
||||
"product.template", "read",
|
||||
service_ids,
|
||||
fields=["id", "name", "description_sale", "list_price", "categ_id"]
|
||||
)
|
||||
|
||||
created = 0
|
||||
updated = 0
|
||||
failed = 0
|
||||
|
||||
for odoo_service in services_data:
|
||||
try:
|
||||
# Check if service exists locally
|
||||
local_service = db.query(Service).filter(
|
||||
Service.odoo_service_id == odoo_service["id"]
|
||||
).first()
|
||||
|
||||
service_data = {
|
||||
"name": odoo_service["name"],
|
||||
"description": odoo_service.get("description_sale") or "",
|
||||
"category": odoo_service.get("categ_id", [0, "general"])[1] if odoo_service.get("categ_id") else "general",
|
||||
"price_range": f"${odoo_service.get('list_price', 0):,.0f} MXN" if odoo_service.get("list_price") else None,
|
||||
"odoo_service_id": odoo_service["id"],
|
||||
"odoo_last_synced": datetime.utcnow()
|
||||
}
|
||||
|
||||
if local_service:
|
||||
# Update existing
|
||||
for key, value in service_data.items():
|
||||
setattr(local_service, key, value)
|
||||
updated += 1
|
||||
else:
|
||||
# Create new
|
||||
local_service = Service(**service_data)
|
||||
db.add(local_service)
|
||||
created += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing service {odoo_service.get('id')}: {e}")
|
||||
failed += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
log.records_processed = len(services_data)
|
||||
log.records_created = created
|
||||
log.records_updated = updated
|
||||
log.records_failed = failed
|
||||
log.mark_completed()
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"processed": len(services_data),
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
"failed": failed
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in sync_services: {e}")
|
||||
log.mark_failed(str(e))
|
||||
db.commit()
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
async def create_lead(self, lead: Lead) -> Dict:
|
||||
"""
|
||||
Create a lead in Odoo CRM.
|
||||
|
||||
Args:
|
||||
lead: Local lead object
|
||||
|
||||
Returns:
|
||||
Dict with Odoo lead ID and status
|
||||
"""
|
||||
try:
|
||||
if not self._connect():
|
||||
return {"success": False, "error": "Cannot connect to Odoo"}
|
||||
|
||||
# Prepare lead data for Odoo
|
||||
odoo_lead_data = {
|
||||
"name": lead.interest or f"Lead from {lead.platform}",
|
||||
"contact_name": lead.name or lead.username,
|
||||
"email_from": lead.email,
|
||||
"phone": lead.phone,
|
||||
"partner_name": lead.company,
|
||||
"description": f"""
|
||||
Source: {lead.platform}
|
||||
Username: @{lead.username}
|
||||
Profile: {lead.profile_url}
|
||||
|
||||
Original Content:
|
||||
{lead.source_content}
|
||||
|
||||
Notes:
|
||||
{lead.notes or 'No notes'}
|
||||
""".strip(),
|
||||
"type": "lead",
|
||||
"priority": "2" if lead.priority == "high" else "1" if lead.priority == "medium" else "0",
|
||||
}
|
||||
|
||||
# Create lead in Odoo
|
||||
odoo_lead_id = self._execute(
|
||||
"crm.lead", "create",
|
||||
[odoo_lead_data]
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"odoo_lead_id": odoo_lead_id
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating Odoo lead: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def export_leads_to_odoo(self) -> Dict:
|
||||
"""
|
||||
Export unsynced leads to Odoo CRM.
|
||||
|
||||
Returns:
|
||||
Dict with export statistics
|
||||
"""
|
||||
db = self._get_db()
|
||||
log = OdooSyncLog(
|
||||
sync_type="leads",
|
||||
direction="export",
|
||||
status="started"
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
|
||||
try:
|
||||
if not self._connect():
|
||||
log.mark_failed("Cannot connect to Odoo")
|
||||
db.commit()
|
||||
return {"success": False, "error": "Connection failed"}
|
||||
|
||||
# Get unsynced leads
|
||||
unsynced_leads = db.query(Lead).filter(
|
||||
Lead.synced_to_odoo == False
|
||||
).all()
|
||||
|
||||
created = 0
|
||||
failed = 0
|
||||
errors = []
|
||||
|
||||
for lead in unsynced_leads:
|
||||
try:
|
||||
result = await self.create_lead(lead)
|
||||
|
||||
if result["success"]:
|
||||
lead.odoo_lead_id = result["odoo_lead_id"]
|
||||
lead.synced_to_odoo = True
|
||||
lead.odoo_synced_at = datetime.utcnow()
|
||||
created += 1
|
||||
else:
|
||||
failed += 1
|
||||
errors.append({
|
||||
"lead_id": lead.id,
|
||||
"error": result.get("error")
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error exporting lead {lead.id}: {e}")
|
||||
failed += 1
|
||||
errors.append({
|
||||
"lead_id": lead.id,
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
db.commit()
|
||||
|
||||
log.records_processed = len(unsynced_leads)
|
||||
log.records_created = created
|
||||
log.records_failed = failed
|
||||
if errors:
|
||||
log.error_details = errors
|
||||
log.mark_completed()
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"processed": len(unsynced_leads),
|
||||
"created": created,
|
||||
"failed": failed,
|
||||
"errors": errors if errors else None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in export_leads_to_odoo: {e}")
|
||||
log.mark_failed(str(e))
|
||||
db.commit()
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
async def get_sales_summary(self, days: int = 30) -> Dict:
|
||||
"""
|
||||
Get sales summary from Odoo.
|
||||
|
||||
Args:
|
||||
days: Number of days to look back
|
||||
|
||||
Returns:
|
||||
Dict with sales statistics
|
||||
"""
|
||||
try:
|
||||
if not self._connect():
|
||||
return {"success": False, "error": "Cannot connect to Odoo"}
|
||||
|
||||
from datetime import timedelta
|
||||
start_date = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
|
||||
# Get confirmed sales orders
|
||||
order_ids = self._execute(
|
||||
"sale.order", "search",
|
||||
[["state", "in", ["sale", "done"]], ["date_order", ">=", start_date]]
|
||||
)
|
||||
|
||||
orders_data = self._execute(
|
||||
"sale.order", "read",
|
||||
order_ids,
|
||||
fields=["id", "name", "amount_total", "date_order", "partner_id", "state"]
|
||||
)
|
||||
|
||||
total_revenue = sum(order.get("amount_total", 0) for order in orders_data)
|
||||
total_orders = len(orders_data)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"period_days": days,
|
||||
"total_orders": total_orders,
|
||||
"total_revenue": total_revenue,
|
||||
"avg_order_value": total_revenue / total_orders if total_orders > 0 else 0,
|
||||
"orders": orders_data[:10] # Return latest 10
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting sales summary: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def get_sync_logs(self, limit: int = 20) -> List[Dict]:
|
||||
"""Get recent sync logs."""
|
||||
db = self._get_db()
|
||||
try:
|
||||
logs = db.query(OdooSyncLog).order_by(
|
||||
OdooSyncLog.started_at.desc()
|
||||
).limit(limit).all()
|
||||
|
||||
return [log.to_dict() for log in logs]
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# Global instance
|
||||
odoo_service = OdooService()
|
||||
Reference in New Issue
Block a user