Spaces:
Sleeping
Sleeping
| 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 | |
| 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} | |
| 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 | |
| } | |
| } | |
| 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 | |
| } | |
| } | |
| 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 | |
| } | |
| } | |