import hashlib import os import re from datetime import datetime, timedelta, timezone from flask import Blueprint, request, jsonify from sqlalchemy import func, text from app.extensions import db from app.models.page_view import PageView from app.models.property import Property from app.utils.auth import require_admin analytics_bp = Blueprint("analytics", __name__) # ─── Helpers ───────────────────────────────────────────────────────────────── _PROPERTY_PATH_RE = re.compile( r"^/api/v1/properties/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})", re.IGNORECASE, ) _SKIP_PREFIXES = ("/api/v1/admin", "/api/v1/auth", "/static") def _should_track(path: str, method: str) -> bool: if method != "GET": return False for prefix in _SKIP_PREFIXES: if path.startswith(prefix): return False return True def _ip_hash(ip: str) -> str: salt = os.environ.get("IP_SALT", "default-salt") return hashlib.sha256(f"{ip}{salt}".encode()).hexdigest() def record_page_view(path: str, ip: str, user_agent: str) -> None: """Insert a page_view row. Silently swallows errors to never break requests.""" try: match = _PROPERTY_PATH_RE.match(path) property_id = match.group(1) if match else None pv = PageView( path=path, property_id=property_id, ip_hash=_ip_hash(ip), user_agent=(user_agent or "")[:512], ) db.session.add(pv) db.session.commit() except Exception: db.session.rollback() # ─── Admin endpoints ────────────────────────────────────────────────────────── def _period_start(days: int) -> datetime: return datetime.now(timezone.utc) - timedelta(days=days) @analytics_bp.get("/summary") @require_admin def analytics_summary(): """Cards: total today / this week / this month + daily series.""" days = int(request.args.get("days", 30)) if days not in (7, 30, 90): days = 30 now = datetime.now(timezone.utc) start_day = now.replace(hour=0, minute=0, second=0, microsecond=0) start_week = start_day - timedelta(days=now.weekday()) start_month = start_day.replace(day=1) start_period = _period_start(days) def count_since(dt: datetime) -> int: return ( db.session.query(func.count(PageView.id)) .filter(PageView.accessed_at >= dt) .scalar() or 0 ) # daily series for sparkline/chart daily = ( db.session.query( func.date_trunc("day", PageView.accessed_at).label("day"), func.count(PageView.id).label("views"), ) .filter(PageView.accessed_at >= start_period) .group_by(text("day")) .order_by(text("day")) .all() ) series = [ {"date": row.day.strftime("%Y-%m-%d"), "views": row.views} for row in daily ] return jsonify( { "today": count_since(start_day), "this_week": count_since(start_week), "this_month": count_since(start_month), "period_days": days, "period_total": count_since(start_period), "series": series, } ) @analytics_bp.get("/top-pages") @require_admin def analytics_top_pages(): """Top 10 most-viewed paths in the given period.""" days = int(request.args.get("days", 30)) if days not in (7, 30, 90): days = 30 rows = ( db.session.query(PageView.path, func.count(PageView.id).label("views")) .filter(PageView.accessed_at >= _period_start(days)) .group_by(PageView.path) .order_by(func.count(PageView.id).desc()) .limit(10) .all() ) return jsonify([{"path": r.path, "views": r.views} for r in rows]) @analytics_bp.get("/top-properties") @require_admin def analytics_top_properties(): """Top 10 most-viewed properties in the given period.""" days = int(request.args.get("days", 30)) if days not in (7, 30, 90): days = 30 rows = ( db.session.query(PageView.property_id, func.count(PageView.id).label("views")) .filter( PageView.accessed_at >= _period_start(days), PageView.property_id.isnot(None), ) .group_by(PageView.property_id) .order_by(func.count(PageView.id).desc()) .limit(10) .all() ) result = [] for row in rows: prop = db.session.get(Property, row.property_id) if prop: photos = prop.photos or [] result.append( { "property_id": row.property_id, "views": row.views, "title": prop.title, "cover": photos[0] if photos else None, "city": prop.city.name if prop.city else None, "neighborhood": ( prop.neighborhood.name if prop.neighborhood else None ), } ) return jsonify(result)