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.
79 lines
3.8 KiB
Python
79 lines
3.8 KiB
Python
"""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}
|