gendesign/tradein-mvp/backend/app/api/v1/privacy_admin.py
bot-backend c251c02f1e
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
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 5m24s
Отмена по бюджету больше не оставляет сироту в сессии запроса (#3449)
`asyncio.to_thread` отменить нельзя: по истечении бюджета (`_with_budget` =
`asyncio.wait_for`, у геокодера 12 с) снимается только ожидание со стороны
loop'а — поток продолжает работать с ТОЙ ЖЕ `Session`, что и весь запрос.
Вызывающий тем временем идёт дальше: следующий источник, `_fetch_anchor_comps`,
`_persist_estimate_and_commit`. Два потока в одной `Session` дают «another
operation is in progress» / InvalidRequestError на СЛЕДУЮЩЕМ шаге. У источников
эту ошибку глушит `except` вокруг вызова, у персиста оценки не глушит никто —
500 и потерянная оценка клиента.

`app/core/db.py: run_db_thread` — ТОЛЬКО защита от сироты: `ensure_future` +
`shield`, на отмене дождаться потока (`asyncio.wait`), прочитать
`step.exception()` (иначе asyncio печатает «Task exception was never
retrieved» без контекста) и пробросить отмену. Commit/rollback туда НЕ вынесены:
посреди геокодинга commit зафиксировал бы частичное состояние оценки.
`estimator._db_step` переписан поверх и добавляет свои commit/rollback сам —
его поведение не меняется, гейт tests/test_3408_db_step_cancel_orphan.py
остаётся зелёным.

Заменено 34 вызова, работающих по сессии запроса: 12 в geocoder.py (кэш-чтение
и записи, геопортал, кадастр, houses, reverse, suggest), 19 в estimator.py
(в т.ч. `_backfill_house_fias`, `_save_yandex_history_items`,
`_fetch_anchor_comps`, `_price_from_inputs` с db-резолверами, персист оценки,
`_fetch_price_trend`, `_is_premium_building`), 2 в api/v1/geocode.py, 1 в
api/v1/privacy_admin.py. Не тронуты вызовы со СВОЕЙ сессией:
`user_events.schedule_event` (внутри `record_event` свой `SessionLocal`) и
`sber_index` (сессия задачи планировщика, отменять её некому).

Гейт по значению — tests/test_3449_geocoder_cancel_orphan.py: отмена по бюджету
во время шага БД геокодера, следом ГОЛЫЙ `to_thread(db.execute, ...)` (образец
персиста); проверяется, что он не вошёл в сессию, пока сирота ещё в ней.
На исходном коде тест краснеет: conflicts == 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:20:44 +05:00

78 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 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, run_db_thread
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 run_db_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}