gendesign/backend/app/api/v1/admin_leads.py
lekss361 82217b7edd refactor(security): убрать X-Admin-Token из 5 admin файлов (43 endpoints)
Caddy basic_auth (PR #426) даёт network-level защиту для всего /admin/*.
Двойная auth избыточна для pilot — app-level AdminTokenAuth dep удалён.

Что изменено:
- admin_scrape.py (24 endpoints), admin_cadastre.py (5), admin_jobs.py (3),
  admin_leads.py (6), admin_weight_profiles.py (5): удалён import AdminTokenAuth
  и параметр `_: AdminTokenAuth,` из каждой функции-handler'а
- backend/.env.example: SCRAPE_ADMIN_TOKEN помечен DEPRECATED с инструкцией rollback

Что НЕ менялось:
- core/deps.py: verify_admin_token + AdminTokenAuth остаются (dead code, rollback safety)
- frontend: заголовок X-Admin-Token продолжает слаться, backend игнорирует
- .env.runtime / settings: SCRAPE_ADMIN_TOKEN env var сохранён
2026-05-23 13:29:58 +03:00

231 lines
8.2 KiB
Python

"""Admin endpoint for browsing PRINZIP CRM leads.
GET /api/v1/admin/leads — list with filters + pagination
GET /api/v1/admin/leads/stats — KPI summary (total/conversion/revenue)
Auth: gendsgn.ru-wide Caddy basic_auth gate (PR #426). App-level X-Admin-Token
header removed 2026-05-23 — двойная auth избыточна для pilot.
PII compliance: only phone_last4 and email_hash exposed; raw phone/email
were never persisted (см. data/sql/52_import_prinzip_crm.py).
"""
from __future__ import annotations
from typing import Annotated, Any, Literal
from fastapi import APIRouter, Depends, Query
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.services import analytics_queries as q
router = APIRouter()
@router.get("/")
def list_leads(
db: Annotated[Session, Depends(get_db)],
status: Annotated[str | None, Query()] = None,
source: Annotated[str | None, Query()] = None,
converted: Annotated[bool | None, Query()] = None,
obj_id: Annotated[int | None, Query()] = None,
search: Annotated[str | None, Query(min_length=2, max_length=64)] = None,
date_from: Annotated[str | None, Query(description="YYYY-MM-DD")] = None,
date_to: Annotated[str | None, Query(description="YYYY-MM-DD")] = None,
sort: Annotated[Literal["created_desc", "created_asc"], Query()] = "created_desc",
limit: Annotated[int, Query(ge=1, le=200)] = 50,
offset: Annotated[int, Query(ge=0)] = 0,
) -> dict[str, Any]:
where: list[str] = []
params: dict[str, Any] = {"lim": limit, "off": offset}
if status:
where.append("l.status = :status")
params["status"] = status
if source:
where.append("l.source = :source")
params["source"] = source
if converted is not None:
where.append("l.converted = :converted")
params["converted"] = converted
if obj_id is not None:
where.append("l.obj_interest = :obj_id")
params["obj_id"] = obj_id
if date_from:
where.append("l.created_at >= :date_from")
params["date_from"] = date_from
if date_to:
where.append("l.created_at < (CAST(:date_to AS date) + INTERVAL '1 day')")
params["date_to"] = date_to
if search:
where.append("(l.name ILIKE :search OR l.phone_last4 = :exact_search)")
params["search"] = f"%{search}%"
params["exact_search"] = search
where_sql = "WHERE " + " AND ".join(where) if where else ""
order_sql = "ORDER BY l.created_at DESC" if sort == "created_desc" else "ORDER BY l.created_at"
rows = (
db.execute(
text(
f"""
SELECT l.lead_id, l.created_at, l.source, l.channel, l.name,
l.phone_last4, l.email_hash, l.obj_interest, l.rooms,
l.budget_min, l.budget_max, l.status, l.converted, l.deal_id,
o.comm_name AS obj_name,
d.deal_price, d.closed_at AS deal_closed_at
FROM prinzip_leads l
LEFT JOIN domrf_kn_objects o
ON o.obj_id = l.obj_interest
AND o.snapshot_date = (SELECT MAX(snapshot_date) FROM domrf_kn_objects)
LEFT JOIN prinzip_deals d ON d.deal_id = l.deal_id
{where_sql}
{order_sql}
LIMIT :lim OFFSET :off
"""
),
params,
)
.mappings()
.all()
)
total = db.execute(
text(f"SELECT COUNT(*) FROM prinzip_leads l {where_sql}"),
{k: v for k, v in params.items() if k not in ("lim", "off")},
).scalar_one()
return {
"total": int(total or 0),
"limit": limit,
"offset": offset,
"rows": [
{
"lead_id": r["lead_id"],
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
"source": r["source"],
"channel": r["channel"],
"name": r["name"],
"phone_last4": r["phone_last4"],
"email_hash": r["email_hash"],
"obj_interest": r["obj_interest"],
"obj_name": r["obj_name"],
"rooms": r["rooms"],
"budget_min": float(r["budget_min"]) if r["budget_min"] is not None else None,
"budget_max": float(r["budget_max"]) if r["budget_max"] is not None else None,
"status": r["status"],
"converted": r["converted"],
"deal_id": r["deal_id"],
"deal_price": float(r["deal_price"]) if r["deal_price"] is not None else None,
"deal_closed_at": (
r["deal_closed_at"].isoformat() if r["deal_closed_at"] else None
),
}
for r in rows
],
}
@router.get("/stats")
def leads_stats(
db: Annotated[Session, Depends(get_db)],
months: Annotated[int, Query(ge=1, le=120)] = 12,
) -> dict[str, Any]:
"""KPI summary за последние N месяцев."""
row = (
db.execute(
text(
"""
WITH window_leads AS (
SELECT *
FROM prinzip_leads
WHERE created_at >= NOW() - (:m || ' months')::interval
)
SELECT
(SELECT COUNT(*) FROM prinzip_leads) AS leads_total,
COUNT(*) FILTER (WHERE TRUE) AS leads_window,
COUNT(*) FILTER (WHERE converted) AS converted_window,
ROUND(
100.0 * COUNT(*) FILTER (WHERE converted) / NULLIF(COUNT(*), 0),
2
) AS conv_pct_window,
(SELECT COUNT(DISTINCT source) FROM prinzip_leads) AS sources_total,
(SELECT SUM(deal_price) FROM prinzip_deals) AS revenue_total,
(SELECT COUNT(*) FROM prinzip_deals) AS deals_total
FROM window_leads
"""
),
{"m": months},
)
.mappings()
.first()
)
if not row:
return {
"leads_total": 0,
"leads_window": 0,
"converted_window": 0,
"conv_pct_window": None,
"sources_total": 0,
"revenue_total": None,
"deals_total": 0,
}
return {
"leads_total": row["leads_total"] or 0,
"leads_window": row["leads_window"] or 0,
"converted_window": row["converted_window"] or 0,
"conv_pct_window": (
float(row["conv_pct_window"]) if row["conv_pct_window"] is not None else None
),
"sources_total": row["sources_total"] or 0,
"revenue_total": (
float(row["revenue_total"]) if row["revenue_total"] is not None else None
),
"deals_total": row["deals_total"] or 0,
"window_months": months,
}
@router.get("/funnel/monthly")
def funnel_monthly(
db: Annotated[Session, Depends(get_db)],
months: Annotated[int, Query(ge=1, le=120)] = 24,
) -> list[dict[str, Any]]:
"""Воронка по месяцам: leads → engaged → converted (по source)."""
return q.prinzip_funnel_monthly(db, months=months)
@router.get("/funnel/by-source")
def funnel_by_source(
db: Annotated[Session, Depends(get_db)],
months: Annotated[int, Query(ge=1, le=120)] = 12,
) -> list[dict[str, Any]]:
"""Splitting по source: кто конвертит лучше за последние N месяцев."""
return q.prinzip_funnel_by_source(db, months=months)
@router.get("/funnel/by-object")
def funnel_by_object(
db: Annotated[Session, Depends(get_db)],
) -> list[dict[str, Any]]:
"""Воронка для каждого ЖК PRINZIP: leads / deals / revenue."""
return q.prinzip_funnel_by_object(db)
@router.get("/sources")
def list_sources(
db: Annotated[Session, Depends(get_db)],
) -> list[str]:
"""Distinct list of source values for filter dropdown."""
rows = db.execute(
text(
"""
SELECT source, COUNT(*) AS n
FROM prinzip_leads
WHERE source IS NOT NULL AND source <> ''
GROUP BY source
ORDER BY n DESC
LIMIT 50
"""
)
).all()
return [r[0] for r in rows]