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
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:
parent
293184ebc6
commit
2538fc02e2
5 changed files with 253 additions and 1 deletions
91
tradein-mvp/backend/app/api/v1/lead.py
Normal file
91
tradein-mvp/backend/app/api/v1/lead.py
Normal 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",
|
||||
}
|
||||
|
|
@ -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"])
|
||||
|
|
|
|||
29
tradein-mvp/backend/data/sql/172_trade_in_leads.sql
Normal file
29
tradein-mvp/backend/data/sql/172_trade_in_leads.sql
Normal 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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
130
tradein-mvp/backend/tests/test_trade_in_lead.py
Normal file
130
tradein-mvp/backend/tests/test_trade_in_lead.py
Normal 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"
|
||||
Loading…
Add table
Reference in a new issue