gendesign/tradein-mvp/backend/app/api/v1/privacy_admin.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

79 lines
3.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.

"""Admin right-to-erasure endpoint (152-ФЗ) — ЭТАП 4 B2C launch, part C.
Auth не нужен в этом файле — вся ветка `/api/v1/admin/*` уже гейтится
`rbac_guard` middleware в app/main.py (`_ADMIN_API_RE`, role != admin → 403),
тем же паттерном, что app/api/v1/audit.py.
Мутационный (DELETE), поэтому осторожно: это НЕ self-service для конечного
пользователя. Оператор поддержки/admin вызывает это ПОСЛЕ того, как убедился
(вне этого API — телефон/estimate-ссылка/переписка), что запрос на удаление
реально пришёл от владельца данных, а не от третьего лица, знающего чей-то
номер телефона. Идентификация анонима — см. app/services/data_erasure.py
module docstring (честно про то, что не всегда разрешимо).
"""
from __future__ import annotations
import asyncio
import logging
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.core.db import get_db
logger = logging.getLogger(__name__)
router = APIRouter()
class DataErasureRequest(BaseModel):
"""Хотя бы одно поле обязательно — см. erase_person_data ValueError guard."""
username: str | None = Field(default=None, max_length=200)
estimate_ids: list[UUID] | None = None
phone: str | None = Field(default=None, max_length=32)
tg_chat_id: int | None = None
@router.post("/privacy/erase")
async def erase_person_data_endpoint(
payload: DataErasureRequest,
db: Annotated[Session, Depends(get_db)],
) -> dict[str, dict[str, int]]:
"""Физически удалить данные человека по одному или нескольким идентификаторам.
Идентификаторы (хотя бы один):
- username — B2B-пилот: удаляет ВСЕ его оценки (created_by=username, CASCADE
подчищает фото/IMV-оценки), связанные лиды, веб-чат поддержки.
- estimate_ids — конкретные оценки по UUID (анонимный путь: человек прислал
ссылку/PDF со своим estimate_id) + лиды, привязанные к ним.
- phone — лиды с этим номером телефона (независимо от привязки к оценке).
- tg_chat_id — Telegram-поддержка (@MERAsupport_bot), включая переписку В
ЭТОЙ БД. НЕ удаляет зеркало в Telegram-топике (см.
app/services/data_erasure.py — ВАЖНЫЙ ФАКТ, честно, не скрываем).
422 если ни один идентификатор не передан (не даём случайно вызвать
"удали всё" пустым телом).
"""
if not any([payload.username, payload.estimate_ids, payload.phone, payload.tg_chat_id]):
raise HTTPException(
status_code=422,
detail="at least one identifier required: username / estimate_ids / phone / tg_chat_id",
)
from app.services.data_erasure import erase_person_data
counters = await asyncio.to_thread(
erase_person_data,
db,
username=payload.username,
estimate_ids=payload.estimate_ids,
phone=payload.phone,
tg_chat_id=payload.tg_chat_id,
)
logger.info("admin privacy erase requested -> %s", counters)
return {"deleted": counters}