gendesign/tradein-mvp/backend/app/services/data_erasure.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

175 lines
8 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.

"""Right-to-erasure mechanism (152-ФЗ) — ЭТАП 4 B2C launch, part C.
WHY:
trade_in has no self-service "delete my data" endpoint at all. Both B2B
pilots (identified by `created_by` username) and future anonymous B2C
users need SOME way to have their personal data physically removed on
request, not just after their retention TTL expires
(app/tasks/purge_expired_trade_in_data.py handles the TTL path, this
module handles the on-demand path).
WHO CAN BE IDENTIFIED, HONESTLY:
- B2B pilot (has a `username`): trivially -- `created_by = username` scopes
every estimate they created; leads/support threads follow from there.
- Anonymous person: has NO username. This function can ONLY act on
identifiers the requester can actually supply:
* `estimate_ids` -- if they still have the link/PDF from their estimate
(the UUID in the URL/QR-code IS their proof of "this is mine").
* `phone` -- if they left a contact-request lead with that phone.
* `tg_chat_id` -- if they messaged @MERAsupport_bot directly (their own
Telegram chat id -- not guessable/spoofable by a third party the way
a name or IP would be).
If an anonymous person has NONE of these (e.g. they only remember the
street address, or ran an estimate but never saved anything and didn't
log support contact) -- THIS IS HONESTLY UNRESOLVABLE without additional
identification. There is no username, no stable session, nothing in the
DB schema today that lets a support operator find "the one estimate this
specific stranger made three days ago" among many. Do not paper over
this: an operator facing that case must say so, not silently pick "the
closest match".
⚠️ TELEGRAM CAVEAT (152-ФЗ, honestly, do not omit):
Every tg_support_messages row was, at send time, ALSO mirrored by the bot
into the support-group Telegram topic (see app/services/tgbot/bridge.py,
186_tg_support.sql). Deleting `tg_support_users` here only removes the
copy IN THIS DATABASE. The mirrored copy lives in the Telegram supergroup,
outside this function's reach, and is NOT deleted by anything in this
codebase. A complete erasure across the whole chain requires a SEPARATE
manual step (Telegram Bot API `deleteMessage` per `topic_message_id` in
the supergroup) that is out of scope here. Do not cite this function's
return value as proof of full erasure of the Telegram-side copy.
WHAT ELSE IS *NOT* TOUCHED (known gap, flagged, not silently dropped):
`user_events` (184_user_events.sql) logs `estimate_request` events with a
JSONB payload that includes `address`/`area_m2`/`rooms` and is keyed by
`username` (empty string for anonymous callers today) + `ip_address`, with
NO FK to trade_in_estimates (decoupled/append-only by explicit design --
see that migration's comment). This function does NOT purge user_events:
it is an audit/analytics log, not an estimate/lead/support record, and
deciding whether "audit trail" is a legitimate 152-ФЗ retention basis that
overrides an erasure request is a legal call, not an engineering one. Flag
it to whoever handles the request; do not assume it is already covered.
"""
from __future__ import annotations
import logging
from collections.abc import Sequence
from uuid import UUID
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
def erase_person_data(
db: Session,
*,
username: str | None = None,
estimate_ids: Sequence[UUID] | None = None,
phone: str | None = None,
tg_chat_id: int | None = None,
) -> dict[str, int]:
"""Physically delete a person's data across trade_in tables.
At least one identifier is required (raises ValueError otherwise -- callers
MUST pass an explicit identifier, never "erase everything" by omission).
Order of operations matters: leads are captured/deleted BEFORE estimates,
because trade_in_leads.estimate_id is ON DELETE SET NULL (172) -- once the
estimate row is gone, the join key to find "leads that came from this
person's estimate" is gone too.
Returns per-table deleted-row counters. Callers own committing the ambient
Session lifecycle in whatever way their layer does (this function DOES
commit itself, mirroring app/tasks/*.py conventions, since this is a
one-shot admin operation, not a request-scoped unit of work shared with
other writes).
"""
if not any([username, estimate_ids, phone, tg_chat_id]):
raise ValueError(
"erase_person_data requires at least one identifier: "
"username / estimate_ids / phone / tg_chat_id"
)
counters: dict[str, int] = {
"trade_in_estimates_deleted": 0,
"trade_in_leads_deleted": 0,
"web_support_deleted": 0,
"tg_support_deleted": 0,
}
# 1. Собрать ПОЛНЫЙ набор estimate_id ДО удаления оценок: явные estimate_ids
# (анонимный путь -- человек прислал ссылку/PDF) + все id с
# created_by=username (B2B-путь). Нужно захватить это СЕЙЧАС -- после
# DELETE FROM trade_in_estimates связанные trade_in_leads.estimate_id
# уйдут в NULL (ON DELETE SET NULL, 172), join станет невозможен.
all_estimate_ids: set[UUID] = set(estimate_ids or [])
if username:
owned = (
db.execute(
text("SELECT id FROM trade_in_estimates WHERE created_by = :username"),
{"username": username},
)
.scalars()
.all()
)
all_estimate_ids.update(owned)
# 2. Лиды -- пока estimate_id ещё живой FK (см. п.1), плюс отдельно по
# телефону (лид мог быть оставлен без attach к оценке вовсе).
ids_param = [str(i) for i in all_estimate_ids]
result = db.execute(
text(
"""
DELETE FROM trade_in_leads
WHERE estimate_id = ANY(CAST(:ids AS uuid[]))
OR phone = :phone
"""
),
{"ids": ids_param, "phone": phone},
)
counters["trade_in_leads_deleted"] = result.rowcount or 0
# 3. Оценки (CASCADE подчищает estimate_photos + avito_imv_evaluations).
if all_estimate_ids:
result = db.execute(
text("DELETE FROM trade_in_estimates WHERE id = ANY(CAST(:ids AS uuid[]))"),
{"ids": ids_param},
)
counters["trade_in_estimates_deleted"] = result.rowcount or 0
# 4. Веб-чат поддержки -- ключ username (сайт закрыт Caddy basic_auth, у
# анонима username нет и быть не может, см. 187_web_support_chat.sql).
if username:
result = db.execute(
text("DELETE FROM web_support_threads WHERE username = :username"),
{"username": username},
)
counters["web_support_deleted"] = result.rowcount or 0
# 5. Telegram-поддержка -- ключ chat_id, ЕДИНСТВЕННЫЙ путь, реально
# доступный анониму без username (см. module docstring). ⚠️ Чистит
# ТОЛЬКО эту БД -- Telegram-топик со своей копией переписки НЕ
# затрагивается, см. ВАЖНЫЙ ФАКТ в docstring выше.
if tg_chat_id is not None:
result = db.execute(
text("DELETE FROM tg_support_users WHERE chat_id = CAST(:chat_id AS bigint)"),
{"chat_id": tg_chat_id},
)
counters["tg_support_deleted"] = result.rowcount or 0
db.commit()
logger.info(
"erase_person_data: username=%r estimate_ids=%d phone=%s tg_chat_id=%s -> %s",
username,
len(all_estimate_ids),
"<redacted>" if phone else None,
tg_chat_id,
counters,
)
return counters
__all__: list[str] = ["erase_person_data"]