gendesign/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py
bot-backend 5626d9e720
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m11s
feat(mera/b2c): правовая рамка — согласие до сохранения, удаление по сроку и по запросу (этап 4 из 8)
Три дефекта, каждый блокировал легальный публичный запуск.

1. Адрес физлица сохранялся в базу ДО любого согласия: согласие фиксировалось
   только на форме заявки, то есть ПОСЛЕ записи адреса. Для пилота с договором
   терпимо, для человека с улицы — нет. Проверка согласия поставлена первой
   строкой расчёта, до геокодирования и до обоих мест записи адреса.

   Хранение — колонками на самой оценке, 1:1 с уже работающим прецедентом для
   заявок (миграция 182): IP клиента, версия политики, дословный снимок текста.
   Отдельная таблица событий не заводилась: согласие даётся ровно на создание
   этой строки, и когда строка удаляется по сроку, исчезновение доказательства
   вместе с данными логично.

   Enforcement НЕ выводится из пустого created_by — первая версия так и делала
   и сломала 92 несвязанных теста оценщика, которые зовут расчёт без имени
   пользователя, проверяя ценовую логику. Вместо этого явный флаг, который
   выставляет единственный боевой вызывающий. B2B-поток не тронут: поле
   согласия опционально, иначе сломались бы пилоты, чей фронт его не шлёт.

2. Срок жизни оценки применялся только как фильтр при чтении — физического
   удаления не было ни в одной фоновой задаче, данные жили вечно вопреки
   декларированному сроку. Заведена задача удаления пачками с ограничением на
   прогон и коммитом после каждой пачки, идемпотентная. В расписании она
   ВЫКЛЮЧЕНА: это первая автоматическая задача, удаляющая персональные данные,
   и первый прогон должен быть под наблюдением.

3. Пути «удалите мои данные» не было. Добавлен сервис удаления и админская
   ручка. Ключи: имя пользователя, идентификатор оценки, телефон, чат в
   телеграме.

   Честно зафиксировано в коде: аноним без ссылки на оценку, без оставленного
   телефона и без обращения в поддержку неидентифицируем — удалить его данные
   без дополнительной идентификации нельзя. Отдельно: удаление чистит только
   копию в базе, зеркало переписки в телеграм-топике не удаляется ничем в
   кодовой базе, нужен ручной шаг.

4. Соответствие текста согласия на фронте и снимка на бэке держалось на
   комментарии. Теперь есть тест, который ловит расхождение.

Сроки хранения вынесены в настройки. Значение для заявок предложено инженерно
(типичный отраслевой диапазон), юридически обоснованный срок — за юристом, и
это записано в коде.

Тесты: 2775 passed.
2026-07-28 15:24:21 +03:00

164 lines
6.2 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.

"""Physically delete expired personal data — ЭТАП 4 B2C retention enforcement (152-ФЗ).
WHY:
trade_in_estimates.expires_at (и, начиная с migration 193, 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)
outlived its declared lifetime indefinitely, contradicting the retention policy
shown to the user.
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
for why). Same architecture as app/tasks/deactivate_stale_avito.py (sync, DB-only,
invoked via run_in_executor from the async kit handler).
- trade_in_estimates: ON DELETE CASCADE already cleans up estimate_photos
(007_estimate_photos.sql) and avito_imv_evaluations (018_avito_imv_evaluations.sql)
for each deleted estimate.
- trade_in_leads: ON DELETE SET NULL on trade_in_leads.estimate_id (172_trade_in_leads.sql)
means a lead created from a now-purged estimate SURVIVES with estimate_id nulled --
it has its OWN retention clock (trade_in_leads.expires_at) and its own PII (phone),
purged independently below.
BATCHING (не единый DELETE по всей таблице):
Each table is drained in batches of `batch_size` rows (default
settings.trade_in_purge_batch_size), each batch its OWN statement + its OWN commit
(bounds lock/transaction duration on a backlog). A run stops draining a table once a
batch returns fewer rows than batch_size (caught up) OR after `max_batches` iterations
(safety cap on total run duration -- any remaining backlog drains over subsequent
nightly runs, not one giant transaction). Idempotent: rows already deleted simply
don't match `expires_at < NOW()` on the next run; a mid-run failure leaves earlier
committed batches deleted (correct, not rolled back) and mark_failed records the
partial counters reached so far.
"""
from __future__ import annotations
import logging
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.services import scrape_runs as runs_mod
logger = logging.getLogger(__name__)
# Safety cap on batches per table per run -- bounds a single scheduled run's total
# duration even if the backlog is much larger than batch_size * max_batches; the
# remainder simply drains on the next nightly run (idempotent, no data loss risk).
_DEFAULT_MAX_BATCHES = 20
_DELETE_EXPIRED_ESTIMATES_SQL = text(
"""
DELETE FROM trade_in_estimates
WHERE id IN (
SELECT id FROM trade_in_estimates
WHERE expires_at < NOW()
ORDER BY expires_at
LIMIT CAST(:batch_size AS int)
)
"""
)
_DELETE_EXPIRED_LEADS_SQL = text(
"""
DELETE FROM trade_in_leads
WHERE id IN (
SELECT id FROM trade_in_leads
WHERE expires_at < NOW()
ORDER BY expires_at
LIMIT CAST(:batch_size AS int)
)
"""
)
def _drain_expired(
db: Session,
stmt: Any,
*,
batch_size: int,
max_batches: int,
label: str,
counters: dict[str, int],
counter_key: str,
) -> None:
"""Run `stmt` (one bounded DELETE batch) repeatedly until caught up or capped.
Commits after EVERY batch -- keeps each individual transaction/lock short even
when the backlog is large. Updates `counters[counter_key]` INCREMENTALLY (not
just once at the end) so that a mid-run exception on a LATER batch still leaves
an accurate count of what was actually deleted-and-committed by earlier batches
-- those rows are gone for real (commit already happened) whether or not this
function ever returns normally.
"""
for batch_num in range(1, max_batches + 1):
result = db.execute(stmt, {"batch_size": batch_size})
deleted = result.rowcount or 0
db.commit()
counters[counter_key] += deleted
logger.info(
"purge_expired_trade_in_data: %s batch=%d deleted=%d (running_total=%d)",
label,
batch_num,
deleted,
counters[counter_key],
)
if deleted < batch_size:
break # caught up -- fewer expired rows left than one batch
def purge_expired_trade_in_data(
db: Session,
run_id: int,
*,
batch_size: int | None = None,
max_batches: int | None = None,
) -> dict[str, int]:
"""Delete expired rows from trade_in_estimates + trade_in_leads, in bounded batches.
Sync (invoked via run_in_executor from the kit-scheduler handler, same pattern as
deactivate_stale_listings). Finalises the scrape_runs row (mark_done / mark_failed).
Returns {"estimates_deleted": N, "leads_deleted": M}.
"""
batch_size = batch_size or settings.trade_in_purge_batch_size
max_batches = max_batches or _DEFAULT_MAX_BATCHES
counters: dict[str, int] = {"estimates_deleted": 0, "leads_deleted": 0}
try:
_drain_expired(
db,
_DELETE_EXPIRED_ESTIMATES_SQL,
batch_size=batch_size,
max_batches=max_batches,
label="trade_in_estimates",
counters=counters,
counter_key="estimates_deleted",
)
_drain_expired(
db,
_DELETE_EXPIRED_LEADS_SQL,
batch_size=batch_size,
max_batches=max_batches,
label="trade_in_leads",
counters=counters,
counter_key="leads_deleted",
)
runs_mod.mark_done(db, run_id, counters)
logger.info(
"purge_expired_trade_in_data run_id=%d done: estimates_deleted=%d leads_deleted=%d",
run_id,
counters["estimates_deleted"],
counters["leads_deleted"],
)
return counters
except Exception as exc:
logger.exception("purge_expired_trade_in_data run_id=%d failed", run_id)
db.rollback()
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
raise