Release Candidate: Production Release with Multi-Tenant & Onboarding Enhancements

This commit is contained in:
ajaysi
2026-02-28 20:06:26 +05:30
parent 08a1f4a1d8
commit 4828274cbf
162 changed files with 19489 additions and 4300 deletions

View File

@@ -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]:
"""