chore(tradein/privacy): перенумерация миграций и merge main - разблокировка PR (#2547)

192/193 -> 229/230: main занял 192_tradein_users_auth.sql и
193_tradein_users_seed.sql за время простоя PR. 228 зарезервирован
открытым PR #2732 (228_payments.sql) - следующие реально свободные
229/230, порядок consent_proof -> retention сохранён.

Правки ссылок на старые имена/префиксы: docstring-заголовки самих
SQL-файлов, перекрёстная ссылка 229 -> 230 в комментарии-докстринге,
комментарии migration 192/193 в lead.py / config.py / schemas/trade_in.py
/ purge_expired_trade_in_data.py, переменные и имена тестов в
test_estimate_consent_gate.py / test_purge_expired_trade_in_data.py.
(Оставлены нетронутыми ссылки на migration 192/193 в auth_session.py и
test_team_api.py - это про другие, уже существующие на main миграции
192_tradein_users_auth.sql / 193_tradein_users_seed.sql, не про эту
пару.)
This commit is contained in:
bot-backend 2026-08-06 15:56:02 +03:00
parent 6820337da0
commit dccd2d4272
8 changed files with 33 additions and 33 deletions

View file

@ -118,7 +118,7 @@ async def create_trade_in_lead(
# ранее — только audit-лог, #2497 TODO). client_ip может быть None (нет
# X-Forwarded-For и request.client) — колонка nullable, CAST(NULL AS inet) валиден.
#
# ЭТАП 4 B2C: expires_at (migration 193) — раньше лид хранился бессрочно
# ЭТАП 4 B2C: expires_at (migration 230) — раньше лид хранился бессрочно
# (никакого TTL вообще не было, в отличие от trade_in_estimates.expires_at).
# Считаем на insert-time тем же паттерном, что estimator.py делает для
# trade_in_estimates — retention-период вынесен в settings, не хардкод.

View file

@ -824,7 +824,7 @@ class Settings(BaseSettings):
# к задаче. ENV: TRADE_IN_ESTIMATE_RETENTION_HOURS.
trade_in_estimate_retention_hours: int = 24
# trade_in_leads.expires_at TTL (дни от момента создания, migration 193).
# trade_in_leads.expires_at TTL (дни от момента создания, migration 230).
# У trade_in_leads раньше вообще не было срока хранения — лид (телефон +
# согласие) жил в БД бессрочно. 180 дней (6 месяцев) — рабочий default для
# НЕконвертированных маркетинговых лидов (типичный индустриальный диапазон

View file

@ -48,7 +48,7 @@ class TradeInEstimateInput(BaseModel):
has_mortgage: bool | None = None
# client_name / client_phone удалены (PII purge #1969, DROP COLUMN 167).
# ЭТАП 4 B2C launch — anonymous consent-before-save (152-ФЗ, migration 192).
# ЭТАП 4 B2C launch — anonymous consent-before-save (152-ФЗ, migration 229).
# Enforcement (НЕ здесь): app.services.estimator.estimate_quality проверяет
# `created_by is None and not consent -> 422` ДО первого INSERT адреса в
# trade_in_estimates. Здесь поле намеренно `bool | None = None`, а НЕ

View file

@ -1,7 +1,7 @@
"""Physically delete expired personal data — ЭТАП 4 B2C retention enforcement (152-ФЗ).
WHY:
trade_in_estimates.expires_at (и, начиная с migration 193, trade_in_leads.expires_at)
trade_in_estimates.expires_at (и, начиная с migration 230, trade_in_leads.expires_at)
defined a retention window, but neither table had any background job that actually
DELETEd rows once expired -- expires_at was used ONLY as a read-time filter
(GET /estimate/{id}: "AND expires_at > NOW()"). Personal data (address / phone)
@ -11,7 +11,7 @@ WHY:
WHAT:
Batched physical DELETE for both tables, run nightly by the kit-scheduler (see
app.services.product_handlers._job_purge_expired_trade_in_data, scrape_schedules
row seeded by migration 193 -- seeded enabled=false, see that migration's docstring
row seeded by migration 230 -- seeded enabled=false, see that migration's docstring
for why). Same architecture as app/tasks/deactivate_stale_avito.py (sync, DB-only,
invoked via run_in_executor from the async kit handler).

View file

@ -1,4 +1,4 @@
-- 192_trade_in_estimates_consent_proof.sql
-- 229_trade_in_estimates_consent_proof.sql
-- ЭТАП 4 B2C launch — правовая рамка для анонимных пользователей (152-ФЗ).
--
-- WHY:
@ -41,7 +41,7 @@
-- defense-in-depth на случай будущего кода, который забудет про gate).
--
-- Индекс на expires_at — обслуживает будущую retention-задачу
-- purge_expired_trade_in_data (см. migration 193), которая физически
-- purge_expired_trade_in_data (см. migration 230), которая физически
-- удаляет строки, чей expires_at истёк (сегодня expires_at используется
-- ТОЛЬКО как read-time фильтр, см. GET /estimate/{id}: "AND expires_at >
-- NOW()" — без индекса такой batched-DELETE делал бы full scan таблицы

View file

@ -1,4 +1,4 @@
-- 193_trade_in_privacy_retention.sql
-- 230_trade_in_privacy_retention.sql
-- ЭТАП 4 B2C launch — retention enforcement (152-ФЗ): срок хранения ДОЛЖЕН
-- приводить к физическому удалению, а не быть декоративным полем.
--
@ -47,7 +47,7 @@
-- - INSERT ... ON CONFLICT (source) DO NOTHING — безопасный re-run seed'а.
--
-- Dependencies: 172_trade_in_leads.sql (таблица), 052_scrape_schedules.sql
-- (scrape_schedules), 192_trade_in_estimates_consent_proof.sql (соседняя
-- (scrape_schedules), 229_trade_in_estimates_consent_proof.sql (соседняя
-- часть той же ЭТАП 4 инициативы — индекс на trade_in_estimates.expires_at
-- уже создан там).

View file

@ -44,7 +44,7 @@ import pytest
from fastapi import HTTPException
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
_MIGRATION_192 = _SQL_DIR / "192_trade_in_estimates_consent_proof.sql"
_MIGRATION_229 = _SQL_DIR / "229_trade_in_estimates_consent_proof.sql"
def _make_payload(**overrides: Any) -> Any:
@ -288,32 +288,32 @@ def test_api_handler_wires_require_consent_from_auth_header() -> None:
assert "require_consent=x_authenticated_user is None" in src
# ── Migration 192 sanity ────────────────────────────────────────────────────────
# ── Migration 229 sanity ────────────────────────────────────────────────────────
def test_migration_192_exists() -> None:
assert _MIGRATION_192.is_file(), f"missing migration: {_MIGRATION_192}"
def test_migration_229_exists() -> None:
assert _MIGRATION_229.is_file(), f"missing migration: {_MIGRATION_229}"
def test_migration_192_is_transactional() -> None:
sql = _MIGRATION_192.read_text("utf-8")
def test_migration_229_is_transactional() -> None:
sql = _MIGRATION_229.read_text("utf-8")
assert "BEGIN;" in sql
assert "COMMIT;" in sql
def test_migration_192_is_idempotent() -> None:
sql = _MIGRATION_192.read_text("utf-8")
def test_migration_229_is_idempotent() -> None:
sql = _MIGRATION_229.read_text("utf-8")
assert "ADD COLUMN IF NOT EXISTS consent" in sql
assert "ADD COLUMN IF NOT EXISTS client_ip" in sql
assert "CREATE INDEX IF NOT EXISTS" in sql
assert "pg_constraint" in sql # DO-block guard, not bare ADD CONSTRAINT
def test_migration_192_no_psycopg_trap() -> None:
sql = _MIGRATION_192.read_text("utf-8")
def test_migration_229_no_psycopg_trap() -> None:
sql = _MIGRATION_229.read_text("utf-8")
assert not re.search(r":\w+::", sql)
def test_migration_192_check_constraint_allows_null_or_true() -> None:
sql = _MIGRATION_192.read_text("utf-8")
def test_migration_229_check_constraint_allows_null_or_true() -> None:
sql = _MIGRATION_229.read_text("utf-8")
assert "consent IS NULL OR consent IS TRUE" in sql

View file

@ -26,7 +26,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
from app.tasks import purge_expired_trade_in_data as task_mod
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
_MIGRATION_193 = _SQL_DIR / "193_trade_in_privacy_retention.sql"
_MIGRATION_230 = _SQL_DIR / "230_trade_in_privacy_retention.sql"
class _FakeResult:
@ -197,29 +197,29 @@ def test_idempotent_zero_rowcount_is_not_an_error(monkeypatch: pytest.MonkeyPatc
assert marked["kind"] == "done"
# ── migration 193 ────────────────────────────────────────────────────────────
# ── migration 230 ────────────────────────────────────────────────────────────
def test_migration_193_exists() -> None:
assert _MIGRATION_193.is_file(), f"missing migration: {_MIGRATION_193}"
def test_migration_230_exists() -> None:
assert _MIGRATION_230.is_file(), f"missing migration: {_MIGRATION_230}"
def test_migration_193_is_transactional() -> None:
sql = _MIGRATION_193.read_text("utf-8")
def test_migration_230_is_transactional() -> None:
sql = _MIGRATION_230.read_text("utf-8")
assert "BEGIN;" in sql
assert "COMMIT;" in sql
def test_migration_193_backfills_and_sets_not_null() -> None:
sql = _MIGRATION_193.read_text("utf-8")
def test_migration_230_backfills_and_sets_not_null() -> None:
sql = _MIGRATION_230.read_text("utf-8")
assert "ADD COLUMN IF NOT EXISTS expires_at" in sql
assert "WHERE expires_at IS NULL" in sql
assert "SET NOT NULL" in sql
assert "180 days" in sql
def test_migration_193_seeds_purge_schedule_disabled_by_default() -> None:
sql = _MIGRATION_193.read_text("utf-8")
def test_migration_230_seeds_purge_schedule_disabled_by_default() -> None:
sql = _MIGRATION_230.read_text("utf-8")
assert "'purge_expired_trade_in_data'" in sql
assert "ON CONFLICT (source) DO NOTHING" in sql
# Seeded disabled -- first automated PII-DELETE job in trade-in deserves a
@ -227,6 +227,6 @@ def test_migration_193_seeds_purge_schedule_disabled_by_default() -> None:
assert re.search(r"'purge_expired_trade_in_data',\s*\n\s*false,", sql)
def test_migration_193_no_psycopg_trap() -> None:
sql = _MIGRATION_193.read_text("utf-8")
def test_migration_230_no_psycopg_trap() -> None:
sql = _MIGRATION_230.read_text("utf-8")
assert not re.search(r":\w+::", sql)