sass-imobiliaria/backend/app/routes/analytics.py

173 lines
5.2 KiB
Python

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)