- Fix text selection menu not showing: wire contentRef via inputRef on multiline TextField - Fix blog title not truncating: add min-w-0 for flex item overflow - Fix outline generation 500: escape curly braces in f-string prompt template - Fix content generation 'NoneType not callable': replace SessionLocal() with get_session_for_user(), add db param to MediumBlogGenerator, fix signature mismatch in database_task_manager - Fix writing assistant suggest 500: add auth + user_id to API endpoint and service, replace sync requests with httpx.AsyncClient - Fix hallucination detector 404: explicitly include router in main.py and app.py - Fix missing error_data in task failure responses - Hide CopilotKit web inspector button - Remove hardcoded fallback suggestions from SmartTypingAssist - Fix stale closure refs in SmartTypingAssist handleTypingChange - Add two-column editor layout, stats bar, section hover menu - Various subscription, billing, and research module improvements
262 lines
12 KiB
Python
262 lines
12 KiB
Python
"""
|
|
Dashboard endpoints for comprehensive usage monitoring.
|
|
"""
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy.orm import Session
|
|
from typing import Dict, Any
|
|
from datetime import datetime
|
|
from loguru import logger
|
|
import sqlite3
|
|
|
|
from services.database import get_db
|
|
from services.subscription import UsageTrackingService, PricingService
|
|
from services.subscription.schema_utils import ensure_subscription_plan_columns, ensure_usage_summaries_columns
|
|
from models.subscription_models import UsageAlert, UserSubscription
|
|
from middleware.auth_middleware import get_current_user
|
|
from ..dependencies import verify_user_access
|
|
from ..cache import get_cached_dashboard, set_cached_dashboard
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.get("/dashboard/{user_id}")
|
|
async def get_dashboard_data(
|
|
user_id: str,
|
|
billing_period: str = None,
|
|
db: Session = Depends(get_db),
|
|
current_user: Dict[str, Any] = Depends(get_current_user)
|
|
) -> Dict[str, Any]:
|
|
"""Get comprehensive dashboard data for usage monitoring.
|
|
Returns all-time total + current period usage by default.
|
|
When billing_period is specified, returns that period's data only."""
|
|
|
|
verify_user_access(user_id, current_user)
|
|
|
|
try:
|
|
ensure_subscription_plan_columns(db)
|
|
ensure_usage_summaries_columns(db)
|
|
|
|
# Check cache first (only for default view, skip when a specific period is requested)
|
|
cached_data = get_cached_dashboard(user_id)
|
|
if cached_data and not billing_period:
|
|
return cached_data
|
|
|
|
usage_service = UsageTrackingService(db)
|
|
pricing_service = PricingService(db)
|
|
|
|
# When a specific billing_period is requested, show only that period's data
|
|
# Otherwise show all-time total + current period usage
|
|
if billing_period:
|
|
period_usage = usage_service.get_usage_for_period(user_id, billing_period)
|
|
total_usage = period_usage
|
|
current_period_usage = period_usage
|
|
else:
|
|
total_usage = usage_service.get_user_usage_stats(user_id, None)
|
|
current_period_usage = usage_service.get_current_period_usage(user_id)
|
|
|
|
# Get usage trends (last 6 months)
|
|
trends = usage_service.get_usage_trends(user_id, 6)
|
|
|
|
# Get user limits
|
|
limits = pricing_service.get_user_limits(user_id)
|
|
|
|
# Get unread alerts
|
|
alerts_query = db.query(UsageAlert).filter(
|
|
UsageAlert.user_id == user_id,
|
|
UsageAlert.is_read == False
|
|
)
|
|
if billing_period:
|
|
alerts_query = alerts_query.filter(UsageAlert.billing_period == billing_period)
|
|
|
|
alerts = alerts_query.order_by(UsageAlert.created_at.desc()).limit(5).all()
|
|
|
|
alerts_data = [
|
|
{
|
|
"id": alert.id,
|
|
"type": alert.alert_type,
|
|
"title": alert.title,
|
|
"message": alert.message,
|
|
"severity": alert.severity,
|
|
"created_at": alert.created_at.isoformat()
|
|
}
|
|
for alert in alerts
|
|
]
|
|
|
|
# Calculate cost projections (only relevant for current month)
|
|
current_cost = total_usage.get('total_cost', 0)
|
|
days_in_period = 30
|
|
current_day = datetime.now().day
|
|
|
|
# Determine if viewing current period based on subscription, not calendar
|
|
subscription = db.query(UserSubscription).filter(
|
|
UserSubscription.user_id == user_id,
|
|
UserSubscription.is_active == True
|
|
).first()
|
|
|
|
# Use subscription's billing period or fallback to calendar
|
|
if subscription and subscription.current_period_start:
|
|
sub_period = subscription.current_period_start.strftime("%Y-%m")
|
|
calendar_period = datetime.now().strftime("%Y-%m")
|
|
|
|
# Check if we have data for subscription period or calendar period
|
|
from models.subscription_models import UsageSummary
|
|
sub_data_exists = db.query(UsageSummary).filter(
|
|
UsageSummary.user_id == user_id,
|
|
UsageSummary.billing_period == sub_period
|
|
).first()
|
|
|
|
# Determine which period to use for "current"
|
|
if sub_data_exists:
|
|
effective_period = sub_period
|
|
else:
|
|
# Check calendar period for backward compatibility
|
|
cal_data_exists = db.query(UsageSummary).filter(
|
|
UsageSummary.user_id == user_id,
|
|
UsageSummary.billing_period == calendar_period
|
|
).first()
|
|
effective_period = calendar_period if cal_data_exists else sub_period
|
|
|
|
is_current_period = not billing_period or billing_period == effective_period
|
|
else:
|
|
is_current_period = not billing_period or billing_period == datetime.now().strftime("%Y-%m")
|
|
|
|
if is_current_period:
|
|
projected_cost = (current_cost / current_day) * days_in_period if current_day > 0 else 0
|
|
else:
|
|
projected_cost = current_cost # For past months, projected is actual
|
|
|
|
response_payload = {
|
|
"success": True,
|
|
"data": {
|
|
"total_usage": total_usage,
|
|
"current_period_usage": current_period_usage,
|
|
"trends": trends,
|
|
"limits": limits,
|
|
"alerts": alerts_data,
|
|
"projections": {
|
|
"projected_monthly_cost": round(projected_cost, 2),
|
|
"cost_limit": limits.get('limits', {}).get('monthly_cost', 0) if limits else 0,
|
|
"projected_usage_percentage": (projected_cost / max(limits.get('limits', {}).get('monthly_cost', 1), 1)) * 100 if limits else 0
|
|
},
|
|
"summary": {
|
|
"total_api_calls_this_month": total_usage.get('total_calls', 0),
|
|
"total_cost_this_month": total_usage.get('total_cost', 0),
|
|
"usage_status": total_usage.get('usage_status', 'active'),
|
|
"unread_alerts": len(alerts_data)
|
|
}
|
|
}
|
|
}
|
|
|
|
# Cache the response only for default view
|
|
if not billing_period:
|
|
set_cached_dashboard(user_id, response_payload)
|
|
|
|
return response_payload
|
|
|
|
except (sqlite3.OperationalError, Exception) as e:
|
|
error_str = str(e).lower()
|
|
if 'no such column' in error_str and ('exa_calls' in error_str or 'exa_cost' in error_str or 'video_calls' in error_str or 'video_cost' in error_str or 'image_edit_calls' in error_str or 'image_edit_cost' in error_str or 'audio_calls' in error_str or 'audio_cost' in error_str):
|
|
logger.warning("Missing column detected in dashboard query, attempting schema fix...")
|
|
try:
|
|
import services.subscription.schema_utils as schema_utils
|
|
schema_utils._checked_usage_summaries_columns = False
|
|
schema_utils._checked_subscription_plan_columns = False
|
|
# Use the already imported functions from top of file
|
|
ensure_usage_summaries_columns(db)
|
|
ensure_subscription_plan_columns(db)
|
|
db.expire_all()
|
|
|
|
# Retry the query
|
|
usage_service = UsageTrackingService(db)
|
|
pricing_service = PricingService(db)
|
|
|
|
if billing_period:
|
|
period_usage = usage_service.get_usage_for_period(user_id, billing_period)
|
|
total_usage = period_usage
|
|
current_period_usage = period_usage
|
|
else:
|
|
total_usage = usage_service.get_user_usage_stats(user_id, None)
|
|
current_period_usage = usage_service.get_current_period_usage(user_id)
|
|
trends = usage_service.get_usage_trends(user_id, 6)
|
|
limits = pricing_service.get_user_limits(user_id)
|
|
|
|
alerts = db.query(UsageAlert).filter(
|
|
UsageAlert.user_id == user_id,
|
|
UsageAlert.is_read == False
|
|
).order_by(UsageAlert.created_at.desc()).limit(5).all()
|
|
|
|
alerts_data = [
|
|
{
|
|
"id": alert.id,
|
|
"type": alert.alert_type,
|
|
"title": alert.title,
|
|
"message": alert.message,
|
|
"severity": alert.severity,
|
|
"created_at": alert.created_at.isoformat()
|
|
}
|
|
for alert in alerts
|
|
]
|
|
|
|
current_cost = total_usage.get('total_cost', 0)
|
|
days_in_period = 30
|
|
current_day = datetime.now().day
|
|
projected_cost = (current_cost / current_day) * days_in_period if current_day > 0 else 0
|
|
|
|
response_payload = {
|
|
"success": True,
|
|
"data": {
|
|
"total_usage": total_usage,
|
|
"current_period_usage": current_period_usage,
|
|
"trends": trends,
|
|
"limits": limits,
|
|
"alerts": alerts_data,
|
|
"projections": {
|
|
"projected_monthly_cost": round(projected_cost, 2),
|
|
"cost_limit": limits.get('limits', {}).get('monthly_cost', 0) if limits else 0,
|
|
"projected_usage_percentage": (projected_cost / max(limits.get('limits', {}).get('monthly_cost', 1), 1)) * 100 if limits else 0
|
|
},
|
|
"summary": {
|
|
"total_api_calls_this_month": total_usage.get('total_calls', 0),
|
|
"total_cost_this_month": total_usage.get('total_cost', 0),
|
|
"usage_status": total_usage.get('usage_status', 'active'),
|
|
"unread_alerts": len(alerts_data)
|
|
}
|
|
}
|
|
}
|
|
|
|
# Cache the response after successful retry (only for default view)
|
|
if not billing_period:
|
|
set_cached_dashboard(user_id, response_payload)
|
|
return response_payload
|
|
except Exception as retry_err:
|
|
logger.error(f"Schema fix and retry failed: {retry_err}")
|
|
return {
|
|
"success": False,
|
|
"error": str(retry_err),
|
|
"data": {
|
|
"total_usage": {"total_calls": 0, "total_cost": 0, "usage_status": "error", "provider_breakdown": {}},
|
|
"current_period_usage": {"total_calls": 0, "total_cost": 0, "usage_status": "error", "provider_breakdown": {}, "usage_percentages": {}},
|
|
"trends": [],
|
|
"limits": {"limits": {"monthly_cost": 0}},
|
|
"alerts": [],
|
|
"projections": {"projected_monthly_cost": 0, "cost_limit": 0, "projected_usage_percentage": 0},
|
|
"summary": {"total_api_calls_this_month": 0, "total_cost_this_month": 0, "usage_status": "error", "unread_alerts": 0}
|
|
}
|
|
}
|
|
|
|
logger.error(f"Error getting dashboard data: {e}")
|
|
return {
|
|
"success": False,
|
|
"error": str(e),
|
|
"data": {
|
|
"total_usage": {"total_calls": 0, "total_cost": 0, "usage_status": "error", "provider_breakdown": {}},
|
|
"current_period_usage": {"total_calls": 0, "total_cost": 0, "usage_status": "error", "provider_breakdown": {}, "usage_percentages": {}},
|
|
"trends": [],
|
|
"limits": {"limits": {"monthly_cost": 0}},
|
|
"alerts": [],
|
|
"projections": {"projected_monthly_cost": 0, "cost_limit": 0, "projected_usage_percentage": 0},
|
|
"summary": {"total_api_calls_this_month": 0, "total_cost_this_month": 0, "usage_status": "error", "unread_alerts": 0}
|
|
}
|
|
}
|