gendesign/tradein-mvp/backend/app/api/v1/lead.py
bot-backend 0583fb0efd
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 58s
fix(trade-in): усилить валидацию /lead и почистить контракт (#2376)
- [MED] phone: маска r"^[+]?[\d\s().-]{5,32}$" делала цифры опциональными
  ("(()) -- .." проходило) — добавлен field_validator, требующий 10-15
  реальных цифр (re.sub(r"\D","")), формат-толерантность сохранена.
- [MED] source: убран мёртвый литерал "landing" из Literal — воронка
  недостижима (rbac_guard закрывает /lead 401 без X-Authenticated-User,
  публичного лендинг-роута нет), контракт больше не обещает невозможное.
- [LOW] 152-ФЗ: захват client_ip (X-Forwarded-For -> peer) и
  _CONSENT_POLICY_VERSION в audit-лог; live-схема trade_in_leads не имеет
  колонок под IP/policy/consent-snapshot -> TODO на follow-up миграцию
  (out of scope), durable proof-of-consent gap задокументирован.

Тесты: +digit-free/too-few-digits phone 422, +source=landing 422,
estimate-тест переведён на source=result. 9/9 pass, ruff clean.
2026-07-12 22:19:20 +03:00

131 lines
6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Trade-in lead capture endpoint (issue #2376, sub-issue родителя #1971).
POST /api/v1/trade-in/lead — контактная заявка с результата оценки:
телефон + явное согласие на обработку персональных данных. Persist в
trade_in_leads. Notification (Telegram/email) — вне scope: нет существующей
SMTP/Telegram интеграции в коде (подтверждено при разборе issue), только
persist + log; `notified_at` в таблице зарезервирован под будущую доставку.
"""
from __future__ import annotations
import logging
import re
from typing import Annotated, Any, Literal
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import get_db
logger = logging.getLogger(__name__)
router = APIRouter()
# Простая маска телефона (RU/международная): опциональный "+", цифры/пробелы/
# скобки/дефисы/точки, 5-32 символа. Полная нормализация в E.164 — вне scope MVP,
# см. #2376 DoD ("простая regex, не EmailStr-подобное").
_PHONE_PATTERN = r"^[+]?[\d\s().-]{5,32}$"
# Маска выше делает цифры ОПЦИОНАЛЬНЫМИ: "(()) -- .." её проходит (0 цифр).
# Поэтому дополнительно требуем правдоподобное число реальных цифр. RU-мобильный =
# 11 цифр; берём лениентный диапазон 10-15 (нац. номер без/с кодом страны).
_PHONE_MIN_DIGITS = 10
_PHONE_MAX_DIGITS = 15
# Версия политики обработки ПДн (152-ФЗ), под которую собрано согласие. Пока пишется
# только в audit-лог: в trade_in_leads НЕТ колонок под policy_version / client_ip /
# снимок текста согласия (проверено live: \d trade_in_leads, 2026-07-12). Полноценный
# proof-of-consent требует follow-up миграции — см. TODO у INSERT ниже.
_CONSENT_POLICY_VERSION = "2026-07"
class TradeInLeadInput(BaseModel):
phone: str = Field(min_length=5, max_length=32, pattern=_PHONE_PATTERN)
estimate_id: UUID | None = None
consent: Literal[True]
# "landing"-воронка недостижима: /api/v1/trade-in/lead закрыт rbac_guard
# (main.py — путь не в _PUBLIC_PATHS => 401 без X-Authenticated-User), а
# публичного лендинг-роута нет. Убрали мёртвый литерал, чтобы контракт не
# обещал невозможную воронку (#2376). Вернуть, если появится public-роут.
source: Literal["result"] = "result"
@field_validator("phone")
@classmethod
def _phone_has_enough_digits(cls, value: str) -> str:
digits = len(re.sub(r"\D", "", value))
if not (_PHONE_MIN_DIGITS <= digits <= _PHONE_MAX_DIGITS):
raise ValueError(f"phone must contain {_PHONE_MIN_DIGITS}-{_PHONE_MAX_DIGITS} digits")
return value
@router.post("/lead")
async def create_trade_in_lead(
payload: TradeInLeadInput,
request: Request,
db: Annotated[Session, Depends(get_db)],
) -> dict[str, Any]:
"""Сохраняет лид (телефон + согласие) в trade_in_leads."""
if payload.estimate_id is not None:
exists = db.execute(
text("SELECT 1 FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
{"id": str(payload.estimate_id)},
).fetchone()
if exists is None:
raise HTTPException(status_code=404, detail="estimate not found")
user_agent = request.headers.get("user-agent")
# 152-ФЗ audit trail: реальный клиентский IP из X-Forwarded-For (его ставит
# фронтящий Caddy), fallback — прямой peer. Пишем только в лог: durable-колонки
# под IP в trade_in_leads нет (см. TODO у INSERT).
client_ip = request.headers.get("x-forwarded-for")
if client_ip:
client_ip = client_ip.split(",")[0].strip()
elif request.client is not None:
client_ip = request.client.host
# TODO(152-ФЗ proof-of-consent): trade_in_leads хранит согласие как голый boolean
# + created_at + user_agent. Для полноценного доказательства согласия нужна
# follow-up миграция с колонками client_ip (inet), consent_policy_version (text),
# consent_text_snapshot (text) — тогда client_ip/_CONSENT_POLICY_VERSION пойдут в
# строку, а не только в лог. Live-схема на 2026-07-12 таких колонок НЕ имеет
# (out of scope этого PR).
row = (
db.execute(
text(
"""
INSERT INTO trade_in_leads (estimate_id, phone, consent, source, user_agent)
VALUES (CAST(:estimate_id AS uuid), :phone, :consent, :source, :user_agent)
RETURNING CAST(id AS text), created_at
"""
),
{
"estimate_id": str(payload.estimate_id) if payload.estimate_id else None,
"phone": payload.phone,
"consent": payload.consent,
"source": payload.source,
"user_agent": user_agent,
},
)
.mappings()
.one()
)
db.commit()
logger.info(
"trade_in_lead saved id=%s estimate_id=%s source=%s ip=%s policy=%s",
row["id"],
payload.estimate_id,
payload.source,
client_ip,
_CONSENT_POLICY_VERSION,
)
return {
"id": row["id"],
"created_at": row["created_at"].isoformat(),
"status": "received",
}