feat: add full project - backend, frontend, docker, specs and configs
This commit is contained in:
parent
b77c7d5a01
commit
e6cb06255b
24489 changed files with 61341 additions and 36 deletions
173
backend/app/routes/analytics.py
Normal file
173
backend/app/routes/analytics.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue