All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m9s
Follow-up к прошлому фиксу (regexp_replace \D): чистое удаление форматирования не закрывало разрыв, который сам ревьюер привёл в примере -- "+7 999 123-45-67" и "89991234567" после digit-stripping дают РАЗНЫЕ строки (79991234567 vs 89991234567, différent на первой цифре) -- классическая для РФ путаница 8/+7 trunk-префикса. _ru_phone_norm_sql(expr) добавляет второй шаг: если после digit-stripping получилось РОВНО 11 цифр с ведущей '8' -- заменить её на '7'. Точное тождество для российской нумерации, не эвристика (обсуждали: усечение до "последних 10 цифр" риск-скориальнее -- склеивает номера разных стран, удаление чужих данных хуже неудаления своих). Оба вызова (_PHONE_COLUMN_NORM_SQL / _PHONE_PARAM_NORM_SQL) строят SQL-структуру из статичных фрагментов (имя колонки / CAST(:phone AS text)) -- ни один телефон не попадает в текст запроса напрямую. Живая проверка (throwaway Postgres 16 в docker): лид "89991234567" находится и удаляется по запросу "+7 999 123-45-67" -- ровно кейс из ревью. Встроенный counterfactual в самом тесте доказывает, что чистый digit-strip (прошлая версия фикса) для этой пары находит 0 строк. Negative control: номер, отличающийся одной значащей цифрой, НЕ удаляется (защита от ложного совпадения = удаления чужих данных).
240 lines
13 KiB
Python
240 lines
13 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.
|
||
Matched by CANONICAL RU DIGITS on both sides (see
|
||
`_ru_phone_norm_sql` below), not an exact string: lead.py stores
|
||
`payload.phone` exactly as typed (no E.164 normalization, by
|
||
design), so "+7 999 123-45-67", "8 (999) 123-45-67" and
|
||
"89991234567" must all find the same row. Covers ONLY the
|
||
RU 8-vs-7 trunk-prefix case (exact digit-count identity, no
|
||
heuristic truncation) -- see the helper's docstring for why.
|
||
* `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 _ru_phone_norm_sql(expr: str) -> str:
|
||
"""SQL-фрагмент: нормализация телефона к каноническому РФ-виду (11 цифр,
|
||
ведущая '7'), для сравнения "разного форматирования одного и того же номера"
|
||
(deep-review 2026-08-06, MEDIUM + follow-up).
|
||
|
||
Два шага: 1) убрать всё, кроме цифр; 2) если получилось РОВНО 11 цифр с
|
||
ведущей '8' -- заменить её на '7'. Это ТОЧНОЕ тождество для российской
|
||
нумерации (8 и +7 -- один и тот же trunk-префикс), не эвристика: длина
|
||
проверяется явно (=11), заменяется РОВНО одна ведущая цифра. Специально
|
||
НЕ "последние 10 цифр" -- усечение убрало бы риск ложных совпадений
|
||
неточно: оно склеивает номера РАЗНЫХ стран с теми же 10 хвостовыми
|
||
цифрами, а удаление ЧУЖИХ данных по erasure-запросу хуже, чем
|
||
неудаление своих. Номера другой длины/страны просто не совпадут ни на
|
||
этом шаге, ни дальше -- безопасный отказ, не false positive.
|
||
|
||
`expr` -- ВСЕГДА статичный SQL-фрагмент (имя колонки или
|
||
`CAST(:bind AS type)`), НИКОГДА значение параметра: эта функция строит
|
||
структуру запроса из литералов, вызывающих её мест ровно два (см.
|
||
_PHONE_COLUMN_NORM_SQL / _PHONE_PARAM_NORM_SQL ниже) -- ни один телефон
|
||
не попадает в текст SQL напрямую, только через bind-параметр `:phone`.
|
||
"""
|
||
stripped = f"regexp_replace({expr}, '\\D', '', 'g')"
|
||
return (
|
||
f"(CASE WHEN length({stripped}) = 11 AND left({stripped}, 1) = '8' "
|
||
f"THEN '7' || substring({stripped} FROM 2) ELSE {stripped} END)"
|
||
)
|
||
|
||
|
||
# Предвычисленные один раз -- обе стороны сравнения телефона в erase_person_data
|
||
# (колонка trade_in_leads.phone / входной CAST(:phone AS text)).
|
||
_PHONE_COLUMN_NORM_SQL = _ru_phone_norm_sql("phone")
|
||
_PHONE_PARAM_NORM_SQL = _ru_phone_norm_sql("CAST(:phone AS text)")
|
||
|
||
|
||
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 к оценке вовсе).
|
||
#
|
||
# ⚠️ Телефон сравнивается по КАНОНИЧЕСКОМУ РФ-виду, не литералом
|
||
# (deep-review 2026-08-06, MEDIUM + follow-up). app/api/v1/lead.py
|
||
# сохраняет payload.phone КАК ПРИСЛАЛИ (намеренно -- полная
|
||
# E.164-нормализация вне scope MVP, см. lead.py::_PHONE_PATTERN),
|
||
# т.е. одна и та же строка может лежать в БД как "+7 999 123-45-67"
|
||
# ИЛИ "89991234567" ИЛИ "8 (999) 123-45-67". Точное `phone = :phone`
|
||
# находит строку только если запрашивающий пришлёт БУКВАЛЬНО ТОТ ЖЕ
|
||
# формат, каким когда-то ввёл номер -- почти никогда так. Раньше это
|
||
# молча удаляло 0 строк и всё равно возвращало 200 "данные удалены":
|
||
# для 152-ФЗ ложное подтверждение удаления хуже честной ошибки.
|
||
# _PHONE_COLUMN_NORM_SQL / _PHONE_PARAM_NORM_SQL (см. _ru_phone_norm_sql
|
||
# выше) снимают форматирование С ОБЕИХ сторон И схлопывают ведущую
|
||
# '8' в '7' при 11 цифрах -- покрывает РОВНО RU 8-vs-7 trunk-префикс,
|
||
# без усечения до "последних 10 цифр" (риск ложного совпадения с
|
||
# номером другой страны -- см. докстринг helper'а). Номера иных
|
||
# форматов/длин сравниваются как есть (просто не совпадут). Параметр --
|
||
# CAST(:phone AS text), НЕ конкатенация значения (psycopg v3 / SQL
|
||
# injection convention, .claude/rules/backend.md); сам SQL-текст
|
||
# собран из СТАТИЧНЫХ фрагментов (_PHONE_*_NORM_SQL), в которых нет
|
||
# ни одного значения параметра.
|
||
ids_param = [str(i) for i in all_estimate_ids]
|
||
result = db.execute(
|
||
text(
|
||
f"""
|
||
DELETE FROM trade_in_leads
|
||
WHERE estimate_id = ANY(CAST(:ids AS uuid[]))
|
||
OR (
|
||
CAST(:phone AS text) IS NOT NULL
|
||
AND {_PHONE_COLUMN_NORM_SQL} = {_PHONE_PARAM_NORM_SQL}
|
||
)
|
||
"""
|
||
),
|
||
{"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"]
|