173 lines
5.2 KiB
Python
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)
|