Release Candidate: Production Release with Multi-Tenant & Onboarding Enhancements
This commit is contained in:
@@ -6,17 +6,46 @@ Provides endpoints for retrieving analytics data from connected platforms.
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services.analytics import PlatformAnalyticsService
|
||||
from middleware.auth_middleware import get_current_user
|
||||
from services.llm_providers.main_text_generation import llm_text_gen
|
||||
|
||||
router = APIRouter(prefix="/api/analytics", tags=["Platform Analytics"])
|
||||
|
||||
# Initialize analytics service
|
||||
analytics_service = PlatformAnalyticsService()
|
||||
|
||||
@router.post("/cache/clear")
|
||||
async def clear_analytics_cache(
|
||||
platform: Optional[str] = Query(None, description="Specific platform to clear (e.g., 'bing', 'gsc')"),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Clear analytics cache for the current user.
|
||||
If 'platform' is provided, clears only that platform's cache; otherwise clears all and connection status.
|
||||
"""
|
||||
try:
|
||||
user_id = current_user.get('id')
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=400, detail="User ID not found")
|
||||
|
||||
if platform:
|
||||
analytics_service.invalidate_platform_cache(user_id, platform)
|
||||
else:
|
||||
analytics_service.invalidate_platform_cache(user_id)
|
||||
|
||||
# Always refresh connection status cache as well
|
||||
analytics_service.invalidate_connection_cache(user_id)
|
||||
|
||||
return { "success": True, "message": "Analytics cache cleared", "platform": platform or "all" }
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clear analytics cache: {e}")
|
||||
return { "success": False, "error": str(e) }
|
||||
|
||||
|
||||
class AnalyticsRequest(BaseModel):
|
||||
"""Request model for analytics data"""
|
||||
@@ -65,7 +94,9 @@ async def get_platform_connection_status(current_user: dict = Depends(get_curren
|
||||
|
||||
@router.get("/data")
|
||||
async def get_analytics_data(
|
||||
platforms: Optional[str] = Query(None, description="Comma-separated list of platforms (gsc,wix,wordpress)"),
|
||||
platforms: Optional[str] = Query(None, description="Comma-separated list of platforms (gsc,bing,wix,wordpress)"),
|
||||
start_date: Optional[str] = Query(None, description="Start date (YYYY-MM-DD)"),
|
||||
end_date: Optional[str] = Query(None, description="End date (YYYY-MM-DD)"),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
) -> AnalyticsResponse:
|
||||
"""
|
||||
@@ -88,15 +119,31 @@ async def get_analytics_data(
|
||||
if platforms:
|
||||
platform_list = [p.strip() for p in platforms.split(',') if p.strip()]
|
||||
|
||||
logger.info(f"Getting analytics data for user: {user_id}, platforms: {platform_list}")
|
||||
logger.info(f"Getting analytics data for user: {user_id}, platforms: {platform_list}, start_date: {start_date}, end_date: {end_date}")
|
||||
|
||||
# Get analytics data
|
||||
analytics_data = await analytics_service.get_comprehensive_analytics(user_id, platform_list)
|
||||
|
||||
# Generate summary
|
||||
analytics_data = await analytics_service.get_comprehensive_analytics(user_id, platform_list, start_date=start_date, end_date=end_date)
|
||||
summary = analytics_service.get_analytics_summary(analytics_data)
|
||||
|
||||
# Convert AnalyticsData objects to dictionaries
|
||||
logger.warning(
|
||||
"Analytics summary for user {user}: total_clicks={clicks}, total_impressions={impr}, overall_ctr={ctr}, platforms={platforms}",
|
||||
user=user_id,
|
||||
clicks=summary.get("total_clicks"),
|
||||
impr=summary.get("total_impressions"),
|
||||
ctr=summary.get("overall_ctr"),
|
||||
platforms=list(analytics_data.keys()),
|
||||
)
|
||||
for platform_name, data in analytics_data.items():
|
||||
try:
|
||||
logger.warning(
|
||||
"Analytics platform snapshot {platform}: status={status}, total_clicks={clicks}, total_impressions={impr}",
|
||||
platform=platform_name,
|
||||
status=data.status,
|
||||
clicks=data.get_total_clicks(),
|
||||
impr=data.get_total_impressions(),
|
||||
)
|
||||
except Exception as log_err:
|
||||
logger.warning(f"Failed to log platform snapshot for {platform_name}: {log_err}")
|
||||
|
||||
data_dict = {}
|
||||
for platform, data in analytics_data.items():
|
||||
data_dict[platform] = {
|
||||
@@ -148,7 +195,14 @@ async def get_analytics_data_post(
|
||||
logger.info(f"Getting analytics data for user: {user_id}, platforms: {request.platforms}")
|
||||
|
||||
# Get analytics data
|
||||
analytics_data = await analytics_service.get_comprehensive_analytics(user_id, request.platforms)
|
||||
# Extract optional dates
|
||||
start_date = None
|
||||
end_date = None
|
||||
if request.date_range and isinstance(request.date_range, dict):
|
||||
start_date = request.date_range.get('start')
|
||||
end_date = request.date_range.get('end')
|
||||
|
||||
analytics_data = await analytics_service.get_comprehensive_analytics(user_id, request.platforms, start_date=start_date, end_date=end_date)
|
||||
|
||||
# Generate summary
|
||||
summary = analytics_service.get_analytics_summary(analytics_data)
|
||||
@@ -250,12 +304,196 @@ async def get_analytics_summary(current_user: dict = Depends(get_current_user))
|
||||
"platforms_connected": summary['connected_platforms'],
|
||||
"platforms_total": summary['total_platforms']
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get analytics summary: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/ai-insights")
|
||||
async def get_ai_insights(
|
||||
start_date: Optional[str] = Query(None),
|
||||
end_date: Optional[str] = Query(None),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
user_id = current_user.get('id')
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=400, detail="User ID not found")
|
||||
sd = start_date
|
||||
ed = end_date
|
||||
if not sd or not ed:
|
||||
today = datetime.utcnow().date()
|
||||
ed = today.isoformat()
|
||||
sd = (today - timedelta(days=29)).isoformat()
|
||||
analytics = await analytics_service.get_comprehensive_analytics(user_id, ['gsc'], start_date=sd, end_date=ed)
|
||||
gsc = analytics.get('gsc')
|
||||
if not gsc or gsc.status != 'success':
|
||||
return {"success": False, "error": gsc.error_message if gsc else "GSC data unavailable"}
|
||||
metrics = gsc.metrics or {}
|
||||
tq = metrics.get('top_queries') or []
|
||||
tp = metrics.get('top_pages') or []
|
||||
cannib = metrics.get('cannibalization') or []
|
||||
sdt = datetime.strptime(sd, "%Y-%m-%d").date()
|
||||
edt = datetime.strptime(ed, "%Y-%m-%d").date()
|
||||
window_days = max((edt - sdt).days + 1, 1)
|
||||
def thr_impr():
|
||||
if window_days <= 7:
|
||||
return 100
|
||||
if window_days <= 30:
|
||||
return 500
|
||||
return 1500
|
||||
def thr_clicks():
|
||||
if window_days <= 7:
|
||||
return 10
|
||||
if window_days <= 30:
|
||||
return 30
|
||||
return 60
|
||||
low_ctr_queries = []
|
||||
for r in tq:
|
||||
imp = float(r.get('impressions', 0) or 0)
|
||||
ctr = float(r.get('ctr', 0) or 0)
|
||||
if imp >= thr_impr() and ctr <= 2.5:
|
||||
low_ctr_queries.append({
|
||||
"query": r.get('query'),
|
||||
"impressions": int(round(imp)),
|
||||
"ctr": round(ctr, 2),
|
||||
"clicks": int(round(float(r.get('clicks', 0) or 0))),
|
||||
"position": round(float(r.get('position', 0) or 0), 2) if 'position' in r else None
|
||||
})
|
||||
striking_distance = []
|
||||
for r in tq:
|
||||
pos = float(r.get('position', 0) or 0)
|
||||
imp = float(r.get('impressions', 0) or 0)
|
||||
if 8.0 <= pos <= 20.0 and imp >= (80 if window_days <= 7 else (300 if window_days <= 30 else 1000)):
|
||||
striking_distance.append({
|
||||
"query": r.get('query'),
|
||||
"impressions": int(round(imp)),
|
||||
"position": round(pos, 2),
|
||||
"clicks": int(round(float(r.get('clicks', 0) or 0)))
|
||||
})
|
||||
low_ctr_pages = []
|
||||
for p in tp:
|
||||
imp = float(p.get('impressions', 0) or 0)
|
||||
ctr = float(p.get('ctr', 0) or 0)
|
||||
if imp >= thr_impr() and ctr <= 2.0:
|
||||
low_ctr_pages.append({
|
||||
"page": p.get('page'),
|
||||
"impressions": int(round(imp)),
|
||||
"ctr": round(ctr, 2),
|
||||
"clicks": int(round(float(p.get('clicks', 0) or 0)))
|
||||
})
|
||||
serp_feature_loss = []
|
||||
for r in tq:
|
||||
pos = float(r.get('position', 0) or 0)
|
||||
imp = float(r.get('impressions', 0) or 0)
|
||||
ctr = float(r.get('ctr', 0) or 0)
|
||||
if pos > 0 and pos <= 5.0 and imp >= thr_impr() and ctr <= 2.0:
|
||||
serp_feature_loss.append({
|
||||
"query": r.get('query'),
|
||||
"impressions": int(round(imp)),
|
||||
"position": round(pos, 2),
|
||||
"ctr": round(ctr, 2),
|
||||
"clicks": int(round(float(r.get('clicks', 0) or 0)))
|
||||
})
|
||||
def build_map(rows):
|
||||
m = {}
|
||||
for r in rows:
|
||||
k = r.get('query')
|
||||
if not k:
|
||||
continue
|
||||
m[k] = {
|
||||
"clicks": float(r.get('clicks', 0) or 0),
|
||||
"impressions": float(r.get('impressions', 0) or 0)
|
||||
}
|
||||
return m
|
||||
prev_end = (sdt - timedelta(days=1)).isoformat()
|
||||
prev_start = (sdt - timedelta(days=window_days)).isoformat()
|
||||
prev_analytics = await analytics_service.get_comprehensive_analytics(user_id, ['gsc'], start_date=prev_start, end_date=prev_end)
|
||||
prev_gsc = prev_analytics.get('gsc')
|
||||
prev_tq = prev_gsc.metrics.get('top_queries') if prev_gsc and prev_gsc.metrics else []
|
||||
curr_map = build_map(tq)
|
||||
prev_map = build_map(prev_tq)
|
||||
declining_queries = []
|
||||
for q, v in curr_map.items():
|
||||
pv = prev_map.get(q) or {"clicks": 0.0, "impressions": 0.0}
|
||||
dc = int(round(v["clicks"] - pv["clicks"]))
|
||||
di = int(round(v["impressions"] - pv["impressions"]))
|
||||
if dc < 0 or di < 0:
|
||||
if abs(dc) >= 5 or abs(di) >= thr_impr() * 0.2:
|
||||
declining_queries.append({
|
||||
"query": q,
|
||||
"delta_clicks": dc,
|
||||
"delta_impressions": di,
|
||||
"prev_clicks": int(round(pv["clicks"])),
|
||||
"prev_impressions": int(round(pv["impressions"]))
|
||||
})
|
||||
low_ctr_queries = sorted(low_ctr_queries, key=lambda x: (-x["impressions"], x["ctr"]))[:10]
|
||||
striking_distance = sorted(striking_distance, key=lambda x: -x["impressions"])[:10]
|
||||
low_ctr_pages = sorted(low_ctr_pages, key=lambda x: (-x["impressions"], x["ctr"]))[:10]
|
||||
cannib_list = cannib[:10]
|
||||
serp_feature_loss = sorted(serp_feature_loss, key=lambda x: -x["impressions"])[:10]
|
||||
payload = {
|
||||
"context": {
|
||||
"site_url": None,
|
||||
"date_range": {"start": sd, "end": ed},
|
||||
"window_days": window_days
|
||||
},
|
||||
"signals": {
|
||||
"low_ctr_queries": low_ctr_queries,
|
||||
"striking_distance": striking_distance,
|
||||
"declining_queries": declining_queries[:10],
|
||||
"low_ctr_pages": low_ctr_pages,
|
||||
"cannibalization": cannib_list,
|
||||
"serp_feature_loss": serp_feature_loss
|
||||
},
|
||||
"limits": {
|
||||
"max_items_per_signal": 10,
|
||||
"language": "en",
|
||||
"tone": "simple"
|
||||
}
|
||||
}
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"quick_summary": {"type": "string"},
|
||||
"prioritized_findings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"severity": {"type": "string"},
|
||||
"audience_note": {"type": "string"},
|
||||
"evidence": {"type": "string"},
|
||||
"why_it_matters": {"type": "string"},
|
||||
"actions": {"type": "array", "items": {"type": "string"}},
|
||||
"effort": {"type": "string"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"playbooks": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title_meta_fixes": {"type": "array", "items": {"type": "object"}},
|
||||
"consolidation": {"type": "array", "items": {"type": "object"}},
|
||||
"refreshes": {"type": "array", "items": {"type": "object"}},
|
||||
"internal_linking": {"type": "array", "items": {"type": "object"}}
|
||||
}
|
||||
},
|
||||
"metrics": {"type": "object"}
|
||||
}
|
||||
}
|
||||
system_prompt = "You are an SEO assistant for non-technical creators. Use simple language and concrete actions. Only use provided numbers. Return a single JSON object matching the schema."
|
||||
prompt = "Analyze the following GSC-derived signals and produce prioritized findings and playbooks.\n\n" + str(payload)
|
||||
ai = llm_text_gen(prompt=prompt, json_struct=schema, system_prompt=system_prompt, user_id=user_id)
|
||||
return {"success": True, "insights": ai}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"AI insights failed: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
@router.get("/cache/test")
|
||||
async def test_cache_endpoint(current_user: dict = Depends(get_current_user)) -> Dict[str, Any]:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user