VortexCommerceBackend / app /api /analytics.py
hisham-cmd's picture
Full Sync: Explicit Commit Operations (Pure & Optimized)
b2be963 verified
Raw
History Blame Contribute Delete
13.2 kB
from fastapi import APIRouter, Depends, HTTPException, status, Request, Query, Body
from sqlalchemy.orm import Session
from sqlalchemy import func, desc
from typing import Optional, List, Dict, Any
from datetime import datetime, timedelta, timezone
import json
from app.db.base import get_db
from app.models.analytics import VisitorLog, AnalyticsEvent
from app.models.user import User, UserRole
from app.api.auth import get_current_user, get_optional_current_user
from app.services.metadata_service import parse_user_agent, get_geoip_info
router = APIRouter(prefix="/analytics", tags=["Analytics"])
def check_admin(user_id: int, db: Session):
user = db.query(User).filter(User.id == user_id).first()
if not user or user.role != UserRole.ADMIN:
raise HTTPException(status_code=403, detail="Not authorized")
return user
@router.post("/track")
async def track_event(
request: Request,
payload: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
current_user_id: Optional[int] = Depends(get_optional_current_user)
):
"""
Track a visitor event. Creates or updates a visitor log and logs the event.
Payload: { session_id, event_type, page_url, page_title, event_data, client_metadata }
"""
# 0. Skip recording for Admin users to keep analytics clean
if current_user_id:
user = db.query(User).filter(User.id == current_user_id).first()
if user and user.role == UserRole.ADMIN:
return {"isSuccess": True, "message": "Admin event skipped"}
print(f">>> [ANALYTICS] Received tracking event: {payload.get('event_type')} for {payload.get('page_url')}")
session_id = payload.get("session_id")
event_type = payload.get("event_type", "PAGE_VIEW")
# 1. Identify or Create Visitor
visitor = None
if session_id:
visitor = db.query(VisitorLog).filter(VisitorLog.session_id == session_id).first()
# Fallback to IP if no session_id (e.g. tracking disabled or first load)
ip_address = request.client.host if request.client else None
user_agent = request.headers.get("User-Agent")
if not visitor:
ua_info = parse_user_agent(user_agent)
location_data_raw = await get_geoip_info(ip_address)
location_data = json.loads(location_data_raw) if location_data_raw else None
visitor = VisitorLog(
session_id=session_id,
user_id=current_user_id,
ip_address=ip_address,
user_agent=user_agent,
browser=ua_info["browser"],
os=ua_info["os"],
device_type=ua_info["device_type"],
location_data=location_data,
client_metadata=payload.get("client_metadata")
)
db.add(visitor)
db.flush() # Get visitor.id
else:
# Update last seen and potentially user_id if they just logged in
visitor.last_seen = datetime.now(timezone.utc)
if current_user_id and not visitor.user_id:
visitor.user_id = current_user_id
# Update client metadata if provided
if payload.get("client_metadata"):
visitor.client_metadata = payload.get("client_metadata")
# 2. Log Event
event = AnalyticsEvent(
visitor_id=visitor.id,
event_type=event_type,
page_url=payload.get("page_url"),
page_title=payload.get("page_title"),
event_data=payload.get("event_data")
)
db.add(event)
try:
db.commit()
except Exception as e:
db.rollback()
# Log error or silence it to not break frontend
print(f">>> [ANALYTICS] Insert error: {e}")
return {"isSuccess": False}
return {"isSuccess": True}
@router.get("/admin/stats")
async def get_analytics_stats(
days: int = Query(7, ge=1, le=90),
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
category_id: Optional[int] = Query(None),
current_user_id: int = Depends(get_current_user),
db: Session = Depends(get_db)
):
check_admin(current_user_id, db)
# 0. Date Range Logic
now = datetime.now(timezone.utc)
if start_date and end_date:
try:
since = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
until = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
# Calculate effective days for trends
days = (until - since).days + 1
if days < 1: days = 1
if days > 90: days = 90
except:
since = now - timedelta(days=days)
until = now
else:
since = now - timedelta(days=days)
until = now
# 1. Total unique visitors (in period)
total_visitors = db.query(func.count(VisitorLog.id)).filter(VisitorLog.last_seen >= since, VisitorLog.last_seen <= until).scalar() or 0
# 2. Total events (in period)
total_events = db.query(func.count(AnalyticsEvent.id)).filter(AnalyticsEvent.created_at >= since, AnalyticsEvent.created_at <= until).scalar() or 0
# 3. Active Now (last 5 minutes)
active_since = now - timedelta(minutes=5)
active_now = db.query(func.count(VisitorLog.id)).filter(VisitorLog.last_seen >= active_since).scalar() or 0
# 4. Device breakdown
devices = db.query(
VisitorLog.device_type,
func.count(VisitorLog.id)
).filter(VisitorLog.last_seen >= since, VisitorLog.last_seen <= until).group_by(VisitorLog.device_type).all()
raw_devices = {d[0]: d[1] for d in devices}
device_stats = {
"desktop": raw_devices.get("Desktop", 0),
"mobile": raw_devices.get("Mobile", 0),
"tablet": raw_devices.get("Tablet", 0)
}
# 5. Daily Trends
trends = []
# We'll calculate for each day in the range
for i in range(days - 1, -1, -1):
day_start = (now - timedelta(days=i)).replace(hour=0, minute=0, second=0, microsecond=0)
day_end = day_start + timedelta(days=1)
day_visitors = db.query(func.count(VisitorLog.id)).filter(
VisitorLog.last_seen >= day_start,
VisitorLog.last_seen < day_end
).scalar() or 0
day_events = db.query(func.count(AnalyticsEvent.id)).filter(
AnalyticsEvent.created_at >= day_start,
AnalyticsEvent.created_at < day_end
).scalar() or 0
trends.append({
"date": day_start.strftime("%Y-%m-%d"),
"visitors": day_visitors,
"events": day_events
})
# 6. Top pages
top_pages = db.query(
AnalyticsEvent.page_url,
func.count(AnalyticsEvent.id)
).filter(
AnalyticsEvent.created_at >= since,
AnalyticsEvent.created_at <= until,
AnalyticsEvent.event_type == "PAGE_VIEW"
).group_by(AnalyticsEvent.page_url).order_by(desc(func.count(AnalyticsEvent.id))).limit(10).all()
pages_data = [{"url": p[0], "views": p[1]} for p in top_pages]
# 7. Top Viewed Products
# Logic: Join with Product to allow category filtering
top_prod_query = db.query(
AnalyticsEvent.page_url,
AnalyticsEvent.page_title,
func.count(AnalyticsEvent.id)
).filter(
AnalyticsEvent.created_at >= since,
AnalyticsEvent.created_at <= until,
AnalyticsEvent.page_url.like("%/products/%")
)
if category_id:
# Get category and subcategories
subcats = db.query(Category.id).filter(or_(Category.id == category_id, Category.parent_id == category_id)).all()
cat_ids = [c[0] for c in subcats]
# We need to extract product ID from URL to join with Product table
# URL pattern is usually .../products/{id} or .../products/{slug}
# For simplicity, we join using LIKE since SQLite/others might not have robust regex support
top_prod_query = top_prod_query.join(
Product,
or_(
AnalyticsEvent.page_url.like("%/products/" + cast(Product.id, String)),
AnalyticsEvent.page_url.like("%/products/" + Product.slug)
)
).filter(Product.category_id.in_(cat_ids))
top_products = top_prod_query.group_by(
AnalyticsEvent.page_url, AnalyticsEvent.page_title
).order_by(desc(func.count(AnalyticsEvent.id))).limit(10).all()
products_data = [{"url": p[0], "name": p[1], "views": p[2]} for p in top_products]
# 8. Top Searches
top_search_query = db.query(AnalyticsEvent.event_data).filter(
AnalyticsEvent.created_at >= since,
AnalyticsEvent.created_at <= until,
AnalyticsEvent.event_type == "SEARCH"
)
events_with_data = top_search_query.all()
search_counts = {}
for (event_data,) in events_with_data:
if isinstance(event_data, dict) and "query" in event_data:
q = event_data["query"].strip().lower()
if q:
search_counts[q] = search_counts.get(q, 0) + 1
elif isinstance(event_data, str): # Handle string-encoded JSON if necessary
try:
data = json.loads(event_data)
q = data.get("query", "").strip().lower()
if q:
search_counts[q] = search_counts.get(q, 0) + 1
except:
pass
searches_data = sorted(
[{"query": q, "count": c} for q, c in search_counts.items()],
key=lambda x: x["count"],
reverse=True
)[:10]
return {
"isSuccess": True,
"value": {
"total_visitors": total_visitors,
"total_events": total_events,
"active_now": active_now,
"device_stats": device_stats,
"trends": trends,
"top_pages": pages_data,
"top_products": products_data,
"top_searches": searches_data
}
}
@router.get("/admin/visitors")
async def get_recent_visitors(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
current_user_id: int = Depends(get_current_user),
db: Session = Depends(get_db)
):
check_admin(current_user_id, db)
query = db.query(VisitorLog).order_by(desc(VisitorLog.last_seen))
total = query.count()
visitors = query.offset((page - 1) * page_size).limit(page_size).all()
results = []
for v in visitors:
# Get event count
event_count = db.query(func.count(AnalyticsEvent.id)).filter(AnalyticsEvent.visitor_id == v.id).scalar() or 0
results.append({
"id": v.id,
"ip": v.ip_address,
"browser": v.browser,
"os": v.os,
"device": v.device_type,
"location": v.location_data,
"last_seen": v.last_seen.isoformat(),
"event_count": event_count,
"user": {"name": v.user.name, "email": v.user.email} if v.user else None
})
return {
"isSuccess": True,
"value": {
"visitors": results,
"total": total,
"page": page,
"page_size": page_size
}
}
@router.get("/admin/events")
async def get_analytics_events(
page: int = Query(1, ge=1),
page_size: int = Query(30, ge=1, le=100),
event_type: Optional[str] = Query(None),
visitor_id: Optional[int] = Query(None),
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
current_user_id: int = Depends(get_current_user),
db: Session = Depends(get_db)
):
check_admin(current_user_id, db)
query = db.query(AnalyticsEvent).order_by(desc(AnalyticsEvent.created_at))
# Date filtering
if start_date:
try:
since = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
query = query.filter(AnalyticsEvent.created_at >= since)
except: pass
if end_date:
try:
until = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
query = query.filter(AnalyticsEvent.created_at <= until)
except: pass
if event_type:
query = query.filter(AnalyticsEvent.event_type == event_type)
if visitor_id:
query = query.filter(AnalyticsEvent.visitor_id == visitor_id)
total = query.count()
events = query.offset((page - 1) * page_size).limit(page_size).all()
results = []
for e in events:
results.append({
"id": e.id,
"event_type": e.event_type,
"page_url": e.page_url,
"page_title": e.page_title,
"event_data": e.event_data,
"created_at": e.created_at.isoformat(),
"visitor_id": e.visitor_id,
"visitor_ip": e.visitor.ip_address if e.visitor else None,
"visitor_location": e.visitor.location_data if e.visitor else None,
"user": {"name": e.visitor.user.name, "email": e.visitor.user.email} if e.visitor and e.visitor.user else None
})
return {
"isSuccess": True,
"value": {
"events": results,
"total": total,
"page": page,
"page_size": page_size
}
}