feat(tradein/lead): POST /api/v1/trade-in/lead — persist contact leads (#2376) (#2390)
All checks were successful
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / test (push) Successful in 1m36s
Deploy Trade-In / build-backend (push) Successful in 54s
Deploy Trade-In / deploy (push) Successful in 48s

This commit is contained in:
lekss361 2026-07-04 07:12:22 +00:00
parent 293184ebc6
commit 2538fc02e2
5 changed files with 253 additions and 1 deletions

View file

@ -0,0 +1,91 @@
"""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
from typing import Annotated, Any, Literal
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
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}$"
class TradeInLeadInput(BaseModel):
phone: str = Field(min_length=5, max_length=32, pattern=_PHONE_PATTERN)
estimate_id: UUID | None = None
consent: Literal[True]
source: Literal["result", "landing"] = "result"
@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")
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",
row["id"],
payload.estimate_id,
payload.source,
)
return {
"id": row["id"],
"created_at": row["created_at"].isoformat(),
"status": "received",
}

View file

@ -24,7 +24,7 @@ from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from sentry_sdk.integrations.starlette import StarletteIntegration
from app.api.v1 import admin, brand, buildings, geocode, me, search, trade_in
from app.api.v1 import admin, brand, buildings, geocode, lead, me, search, trade_in
from app.core.auth import get_role
from app.core.config import settings
from app.core.db import SessionLocal
@ -197,6 +197,7 @@ app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"])
app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"])
app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
app.include_router(search.router, prefix="/api/v1", tags=["search"])
app.include_router(me.router, prefix="/api/v1", tags=["me"])

View file

@ -0,0 +1,29 @@
-- Migration: trade_in_leads — контактные заявки с результата оценки (issue #2376,
-- sub-issue родителя #1971 "лидогенерация"). Пользователь оставляет телефон на
-- странице результата (или лендинге) с явным согласием на обработку персональных
-- данных. Уведомление (Telegram/email) сюда НЕ входит — нет существующей
-- SMTP/Telegram интеграции в коде (подтверждено), только persist + log;
-- notified_at зарезервирован под будущую доставку.
BEGIN;
CREATE TABLE IF NOT EXISTS trade_in_leads (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
estimate_id uuid REFERENCES trade_in_estimates(id) ON DELETE SET NULL,
phone text NOT NULL,
consent boolean NOT NULL,
source text, -- 'result', 'landing'
user_agent text,
created_at timestamptz NOT NULL DEFAULT now(),
notified_at timestamptz, -- когда/если ушло уведомление (пока не используется)
CONSTRAINT trade_in_leads_consent_required CHECK (consent IS TRUE)
);
CREATE INDEX IF NOT EXISTS trade_in_leads_created_idx
ON trade_in_leads (created_at DESC);
CREATE INDEX IF NOT EXISTS trade_in_leads_estimate_idx
ON trade_in_leads (estimate_id);
COMMENT ON TABLE trade_in_leads IS '#2376 лид с контактом с результата оценки trade-in; notified_at зарезервирован под будущую Telegram/email-интеграцию (не реализована).';
COMMIT;

View file

@ -175,3 +175,4 @@
168_fdw_osm_poi_ekb.sql
169_osm_poi_ekb_local.sql
170_scrape_schedules_seed_osm_poi_ekb_refresh.sql
172_trade_in_leads.sql

View file

@ -0,0 +1,130 @@
"""Offline-тесты POST /api/v1/trade-in/lead (#2376, sub-issue родителя #1971).
Покрытие (db мокается, NO live network/DB):
- happy path: consent=True + валидный телефон -> 200, INSERT + commit
- consent=False -> 422 (Literal[True] guard)
- невалидный формат телефона -> 422
- estimate_id, которого нет в trade_in_estimates -> 404
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from datetime import UTC, datetime
from typing import Any
from unittest.mock import MagicMock
from uuid import uuid4
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
@pytest.fixture
def db() -> MagicMock:
return MagicMock()
@pytest.fixture
def client(db: MagicMock) -> TestClient:
from app.api.v1 import lead as lead_module
from app.core.db import get_db
app = FastAPI()
app.include_router(lead_module.router, prefix="/api/v1/trade-in")
def fake_db() -> Any:
yield db
app.dependency_overrides[get_db] = fake_db
return TestClient(app)
def _insert_result(lead_id: str) -> MagicMock:
result = MagicMock()
result.mappings.return_value.one.return_value = {
"id": lead_id,
"created_at": datetime(2026, 7, 4, 12, 0, tzinfo=UTC),
}
return result
def test_lead_happy_path(client: TestClient, db: MagicMock) -> None:
lead_id = str(uuid4())
db.execute.return_value = _insert_result(lead_id)
r = client.post(
"/api/v1/trade-in/lead",
json={"phone": "+7 (912) 345-67-89", "consent": True},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["id"] == lead_id
assert body["status"] == "received"
assert db.commit.called
params = db.execute.call_args.args[1]
assert params["phone"] == "+7 (912) 345-67-89"
assert params["consent"] is True
assert params["source"] == "result"
assert params["estimate_id"] is None
def test_lead_consent_false_422(client: TestClient, db: MagicMock) -> None:
r = client.post(
"/api/v1/trade-in/lead",
json={"phone": "+79123456789", "consent": False},
)
assert r.status_code == 422
assert not db.execute.called
def test_lead_consent_missing_422(client: TestClient) -> None:
r = client.post("/api/v1/trade-in/lead", json={"phone": "+79123456789"})
assert r.status_code == 422
def test_lead_invalid_phone_422(client: TestClient) -> None:
r = client.post(
"/api/v1/trade-in/lead",
json={"phone": "not-a-phone!!", "consent": True},
)
assert r.status_code == 422
def test_lead_with_unknown_estimate_id_404(client: TestClient, db: MagicMock) -> None:
result = MagicMock()
result.fetchone.return_value = None
db.execute.return_value = result
r = client.post(
"/api/v1/trade-in/lead",
json={"phone": "+79123456789", "consent": True, "estimate_id": str(uuid4())},
)
assert r.status_code == 404
assert not db.commit.called
def test_lead_with_known_estimate_id_200(client: TestClient, db: MagicMock) -> None:
lead_id = str(uuid4())
exists_result = MagicMock()
exists_result.fetchone.return_value = (1,)
db.execute.side_effect = [exists_result, _insert_result(lead_id)]
estimate_id = str(uuid4())
r = client.post(
"/api/v1/trade-in/lead",
json={
"phone": "+79123456789",
"consent": True,
"estimate_id": estimate_id,
"source": "landing",
},
)
assert r.status_code == 200, r.text
params = db.execute.call_args.args[1]
assert params["estimate_id"] == estimate_id
assert params["source"] == "landing"