Initial commit: AutonetSellCar platform with deployment system
- Frontend: Next.js 14 with TypeScript - Backend: FastAPI with SQLAlchemy - Agent: Carmodoo sync agent - Deployment: Docker Compose based staging/production setup - Scripts: Automated deployment with rollback support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
334
backend/app/api/visitor.py
Normal file
334
backend/app/api/visitor.py
Normal file
@@ -0,0 +1,334 @@
|
||||
"""
|
||||
Visitor Analytics API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Request, BackgroundTasks
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_, desc
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..database import get_db
|
||||
from ..models.visitor import VisitorLog, VisitorDailyStats
|
||||
from ..models import User
|
||||
from ..services.visitor_service import log_visit, aggregate_daily_stats
|
||||
from .auth import get_current_admin_user, get_current_user_optional
|
||||
|
||||
router = APIRouter(prefix="/visitor", tags=["Visitor Analytics"])
|
||||
|
||||
|
||||
# Pydantic schemas
|
||||
class VisitLogRequest(BaseModel):
|
||||
page_path: str
|
||||
page_title: Optional[str] = None
|
||||
referrer: Optional[str] = None
|
||||
session_id: Optional[str] = None
|
||||
utm_source: Optional[str] = None
|
||||
utm_medium: Optional[str] = None
|
||||
utm_campaign: Optional[str] = None
|
||||
|
||||
|
||||
class VisitorStatsResponse(BaseModel):
|
||||
total_visits: int
|
||||
unique_visitors: int
|
||||
device_breakdown: dict
|
||||
browser_breakdown: dict
|
||||
country_breakdown: dict
|
||||
|
||||
|
||||
class ChartData(BaseModel):
|
||||
labels: List[str]
|
||||
values: List[int]
|
||||
|
||||
|
||||
class TopPage(BaseModel):
|
||||
path: str
|
||||
views: int
|
||||
title: Optional[str] = None
|
||||
|
||||
|
||||
class TopReferrer(BaseModel):
|
||||
domain: str
|
||||
visits: int
|
||||
|
||||
|
||||
# Background task wrapper for async log_visit
|
||||
async def _log_visit_background(
|
||||
db: Session,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
page_path: str,
|
||||
page_title: Optional[str],
|
||||
referrer: Optional[str],
|
||||
session_id: Optional[str],
|
||||
user_id: Optional[int],
|
||||
utm_source: Optional[str],
|
||||
utm_medium: Optional[str],
|
||||
utm_campaign: Optional[str],
|
||||
):
|
||||
"""Background wrapper for log_visit"""
|
||||
try:
|
||||
await log_visit(
|
||||
db, ip, user_agent, page_path, page_title,
|
||||
referrer, session_id, user_id,
|
||||
utm_source, utm_medium, utm_campaign
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[Visitor] Log visit failed: {e}")
|
||||
|
||||
|
||||
# Public endpoint for logging visits
|
||||
@router.post("/log")
|
||||
async def log_page_visit(
|
||||
visit_data: VisitLogRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||
):
|
||||
"""
|
||||
Log a page visit (called from frontend)
|
||||
"""
|
||||
# Get client IP (handle proxies)
|
||||
forwarded_for = request.headers.get("X-Forwarded-For")
|
||||
if forwarded_for:
|
||||
ip = forwarded_for.split(",")[0].strip()
|
||||
else:
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
|
||||
user_agent = request.headers.get("User-Agent", "")
|
||||
user_id = current_user.id if current_user else None
|
||||
|
||||
# Log visit directly (async)
|
||||
try:
|
||||
await log_visit(
|
||||
db,
|
||||
ip,
|
||||
user_agent,
|
||||
visit_data.page_path,
|
||||
visit_data.page_title,
|
||||
visit_data.referrer,
|
||||
visit_data.session_id,
|
||||
user_id,
|
||||
visit_data.utm_source,
|
||||
visit_data.utm_medium,
|
||||
visit_data.utm_campaign,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[Visitor] Log visit failed: {e}")
|
||||
|
||||
return {"status": "logged"}
|
||||
|
||||
|
||||
# Admin endpoints
|
||||
@router.get("/admin/overview", response_model=VisitorStatsResponse)
|
||||
def get_visitor_overview(
|
||||
days: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get visitor statistics overview for last N days"""
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
# Total visits
|
||||
total_visits = db.query(func.count(VisitorLog.id)).filter(
|
||||
VisitorLog.visited_at >= start_date
|
||||
).scalar() or 0
|
||||
|
||||
# Unique visitors
|
||||
unique_visitors = db.query(func.count(func.distinct(VisitorLog.visitor_hash))).filter(
|
||||
VisitorLog.visited_at >= start_date
|
||||
).scalar() or 0
|
||||
|
||||
# Device breakdown
|
||||
device_query = db.query(
|
||||
VisitorLog.device_type,
|
||||
func.count(VisitorLog.id)
|
||||
).filter(
|
||||
VisitorLog.visited_at >= start_date
|
||||
).group_by(VisitorLog.device_type).all()
|
||||
|
||||
device_breakdown = {d[0] or "unknown": d[1] for d in device_query}
|
||||
|
||||
# Browser breakdown
|
||||
browser_query = db.query(
|
||||
VisitorLog.browser,
|
||||
func.count(VisitorLog.id)
|
||||
).filter(
|
||||
VisitorLog.visited_at >= start_date
|
||||
).group_by(VisitorLog.browser).all()
|
||||
|
||||
browser_breakdown = {b[0] or "unknown": b[1] for b in browser_query}
|
||||
|
||||
# Country breakdown
|
||||
country_query = db.query(
|
||||
VisitorLog.country_code,
|
||||
func.count(VisitorLog.id)
|
||||
).filter(
|
||||
VisitorLog.visited_at >= start_date
|
||||
).group_by(VisitorLog.country_code).all()
|
||||
|
||||
country_breakdown = {c[0] or "unknown": c[1] for c in country_query}
|
||||
|
||||
return VisitorStatsResponse(
|
||||
total_visits=total_visits,
|
||||
unique_visitors=unique_visitors,
|
||||
device_breakdown=device_breakdown,
|
||||
browser_breakdown=browser_breakdown,
|
||||
country_breakdown=country_breakdown,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/chart/visits", response_model=ChartData)
|
||||
def get_visits_chart(
|
||||
days: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get daily visits chart data"""
|
||||
today = datetime.utcnow().date()
|
||||
|
||||
labels = []
|
||||
values = []
|
||||
|
||||
for i in range(days - 1, -1, -1):
|
||||
date = today - timedelta(days=i)
|
||||
date_str = date.strftime("%Y-%m-%d")
|
||||
|
||||
count = db.query(func.count(VisitorLog.id)).filter(
|
||||
func.date(VisitorLog.visited_at) == date_str
|
||||
).scalar() or 0
|
||||
|
||||
labels.append(date.strftime("%m/%d"))
|
||||
values.append(count)
|
||||
|
||||
return ChartData(labels=labels, values=values)
|
||||
|
||||
|
||||
@router.get("/admin/chart/unique-visitors", response_model=ChartData)
|
||||
def get_unique_visitors_chart(
|
||||
days: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get daily unique visitors chart data"""
|
||||
today = datetime.utcnow().date()
|
||||
|
||||
labels = []
|
||||
values = []
|
||||
|
||||
for i in range(days - 1, -1, -1):
|
||||
date = today - timedelta(days=i)
|
||||
date_str = date.strftime("%Y-%m-%d")
|
||||
|
||||
count = db.query(func.count(func.distinct(VisitorLog.visitor_hash))).filter(
|
||||
func.date(VisitorLog.visited_at) == date_str
|
||||
).scalar() or 0
|
||||
|
||||
labels.append(date.strftime("%m/%d"))
|
||||
values.append(count)
|
||||
|
||||
return ChartData(labels=labels, values=values)
|
||||
|
||||
|
||||
@router.get("/admin/top-pages", response_model=List[TopPage])
|
||||
def get_top_pages(
|
||||
days: int = 30,
|
||||
limit: int = 20,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get top visited pages"""
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
pages = db.query(
|
||||
VisitorLog.page_path,
|
||||
VisitorLog.page_title,
|
||||
func.count(VisitorLog.id).label("views")
|
||||
).filter(
|
||||
VisitorLog.visited_at >= start_date
|
||||
).group_by(
|
||||
VisitorLog.page_path, VisitorLog.page_title
|
||||
).order_by(
|
||||
desc("views")
|
||||
).limit(limit).all()
|
||||
|
||||
return [
|
||||
TopPage(path=p[0], title=p[1], views=p[2])
|
||||
for p in pages
|
||||
]
|
||||
|
||||
|
||||
@router.get("/admin/top-referrers", response_model=List[TopReferrer])
|
||||
def get_top_referrers(
|
||||
days: int = 30,
|
||||
limit: int = 10,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get top referrer sources"""
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
referrers = db.query(
|
||||
VisitorLog.referrer_domain,
|
||||
func.count(VisitorLog.id).label("visits")
|
||||
).filter(
|
||||
and_(
|
||||
VisitorLog.visited_at >= start_date,
|
||||
VisitorLog.referrer_domain.isnot(None),
|
||||
VisitorLog.referrer_domain != ""
|
||||
)
|
||||
).group_by(
|
||||
VisitorLog.referrer_domain
|
||||
).order_by(
|
||||
desc("visits")
|
||||
).limit(limit).all()
|
||||
|
||||
return [
|
||||
TopReferrer(domain=r[0], visits=r[1])
|
||||
for r in referrers
|
||||
]
|
||||
|
||||
|
||||
@router.get("/admin/realtime")
|
||||
def get_realtime_visitors(
|
||||
minutes: int = 5,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get visitors in the last N minutes (real-time)"""
|
||||
start_time = datetime.utcnow() - timedelta(minutes=minutes)
|
||||
|
||||
active_count = db.query(func.count(func.distinct(VisitorLog.visitor_hash))).filter(
|
||||
VisitorLog.visited_at >= start_time
|
||||
).scalar() or 0
|
||||
|
||||
# Recent pages
|
||||
recent_pages = db.query(
|
||||
VisitorLog.page_path,
|
||||
func.count(VisitorLog.id).label("views")
|
||||
).filter(
|
||||
VisitorLog.visited_at >= start_time
|
||||
).group_by(
|
||||
VisitorLog.page_path
|
||||
).order_by(
|
||||
desc("views")
|
||||
).limit(5).all()
|
||||
|
||||
return {
|
||||
"active_visitors": active_count,
|
||||
"minutes": minutes,
|
||||
"recent_pages": [{"path": p[0], "views": p[1]} for p in recent_pages],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/admin/aggregate/{date_str}")
|
||||
def trigger_aggregation(
|
||||
date_str: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Manually trigger aggregation for a specific date (YYYY-MM-DD)"""
|
||||
result = aggregate_daily_stats(db, date_str)
|
||||
if result:
|
||||
return {"status": "success", "date": date_str}
|
||||
return {"status": "no_data", "date": date_str}
|
||||
Reference in New Issue
Block a user