feat: add full project - backend, frontend, docker, specs and configs

This commit is contained in:
MatheusAlves96 2026-04-20 23:59:45 -03:00
parent b77c7d5a01
commit e6cb06255b
24489 changed files with 61341 additions and 36 deletions

View 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)