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
Три дефекта, каждый блокировал легальный публичный запуск. 1. Адрес физлица сохранялся в базу ДО любого согласия: согласие фиксировалось только на форме заявки, то есть ПОСЛЕ записи адреса. Для пилота с договором терпимо, для человека с улицы — нет. Проверка согласия поставлена первой строкой расчёта, до геокодирования и до обоих мест записи адреса. Хранение — колонками на самой оценке, 1:1 с уже работающим прецедентом для заявок (миграция 182): IP клиента, версия политики, дословный снимок текста. Отдельная таблица событий не заводилась: согласие даётся ровно на создание этой строки, и когда строка удаляется по сроку, исчезновение доказательства вместе с данными логично. Enforcement НЕ выводится из пустого created_by — первая версия так и делала и сломала 92 несвязанных теста оценщика, которые зовут расчёт без имени пользователя, проверяя ценовую логику. Вместо этого явный флаг, который выставляет единственный боевой вызывающий. B2B-поток не тронут: поле согласия опционально, иначе сломались бы пилоты, чей фронт его не шлёт. 2. Срок жизни оценки применялся только как фильтр при чтении — физического удаления не было ни в одной фоновой задаче, данные жили вечно вопреки декларированному сроку. Заведена задача удаления пачками с ограничением на прогон и коммитом после каждой пачки, идемпотентная. В расписании она ВЫКЛЮЧЕНА: это первая автоматическая задача, удаляющая персональные данные, и первый прогон должен быть под наблюдением. 3. Пути «удалите мои данные» не было. Добавлен сервис удаления и админская ручка. Ключи: имя пользователя, идентификатор оценки, телефон, чат в телеграме. Честно зафиксировано в коде: аноним без ссылки на оценку, без оставленного телефона и без обращения в поддержку неидентифицируем — удалить его данные без дополнительной идентификации нельзя. Отдельно: удаление чистит только копию в базе, зеркало переписки в телеграм-топике не удаляется ничем в кодовой базе, нужен ручной шаг. 4. Соответствие текста согласия на фронте и снимка на бэке держалось на комментарии. Теперь есть тест, который ловит расхождение. Сроки хранения вынесены в настройки. Значение для заявок предложено инженерно (типичный отраслевой диапазон), юридически обоснованный срок — за юристом, и это записано в коде. Тесты: 2775 passed.
175 lines
8 KiB
Python
175 lines
8 KiB
Python
"""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"]
|