From 0535fa209a4af8ec6a3d96222b77f1d100388700 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Thu, 6 Aug 2026 18:27:39 +0000 Subject: [PATCH 001/124] =?UTF-8?q?chore(tradein/db):=20=D1=83=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=D0=BA=D0=B0=20=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86,=20=D0=B4?= =?UTF-8?q?=D1=83=D0=B1=D0=BB=D0=B5=D0=B9=20=D0=B8=D0=BD=D0=B4=D0=B5=D0=BA?= =?UTF-8?q?=D1=81=D0=BE=D0=B2=20=D0=B8=20=D0=B7=D0=B2=D1=91=D0=B7=D0=B4?= =?UTF-8?q?=D0=BE=D1=87=D0=BA=D0=B8=20=D0=B2=20v=5Fdata=5Fquality=20(#2746?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Миграции 222 (DROP 2 tmp-таблиц + 5 строгих дублей индексов + v_data_quality с явным списком колонок) и 225 (CREATE INDEX CONCURRENTLY под FK listing_source_snapshots.run_id). Проверено на прод-БД в BEGIN…ROLLBACK и на чистой схеме (полный bootstrap 225 миграций в одноразовом контейнере). --- .../backend/data/sql/222_db_audit_cleanup.sql | 140 ++++++++++++++++++ ...25_listing_source_snapshots_run_id_idx.sql | 42 ++++++ .../backend/data/sql/_manifest_applied.txt | 9 ++ 3 files changed, 191 insertions(+) create mode 100644 tradein-mvp/backend/data/sql/222_db_audit_cleanup.sql create mode 100644 tradein-mvp/backend/data/sql/225_listing_source_snapshots_run_id_idx.sql diff --git a/tradein-mvp/backend/data/sql/222_db_audit_cleanup.sql b/tradein-mvp/backend/data/sql/222_db_audit_cleanup.sql new file mode 100644 index 00000000..35bfb069 --- /dev/null +++ b/tradein-mvp/backend/data/sql/222_db_audit_cleanup.sql @@ -0,0 +1,140 @@ +-- 222_db_audit_cleanup.sql +-- Уборка по итогам ручного аудита схемы tradein (2026-08-06). Три независимых +-- части, порядок между ними не важен (разные объекты, нет пересекающихся +-- зависимостей). Идемпотентно целиком: IF EXISTS везде, CREATE OR REPLACE VIEW. +-- +-- ── A. Временные таблицы разовой чистки 02.07 ───────────────────────────────── +-- tmp_purged_junk_houses_0702 / tmp_purged_junk_links_0702 — снэпшоты записей, +-- вычищенных вручную 2026-07-02. Проверено на проде перед этой миграцией: +-- * обе существуют под этими именами, суммарно 2.9 МБ +-- (tmp_purged_junk_houses_0702 = 1608 kB, tmp_purged_junk_links_0702 = 1272 kB); +-- * pg_constraint: ни один FK НЕ ссылается на них (confrelid пусто); +-- * pg_depend: ни view, ни другой объект их не использует (только сами по себе). +-- Дальше не нужны — были just-in-case снэпшотом на случай отката чистки, месяц +-- прошёл без претензий. +-- +-- ── B. Пять строгих дублей индексов ─────────────────────────────────────────── +-- Строгий дубль = тот же access method + тот же УПОРЯДОЧЕННЫЙ список колонок +-- (включая ASC/DESC/NULLS) + тот же частичный предикат (или оба NULL) + тот же +-- opclass, независимо от UNIQUE-флага и имени. Проверено запросом по +-- pg_index/pg_stat_user_indexes/pg_opclass на проде — найдено РОВНО 5 пар (не 6, +-- см. примечание ниже), в каждой паре оставляем индекс, несущий UNIQUE-constraint +-- (дропнуть его нельзя без дропа constraint'а), дропаем чистый btree-дубль: +-- +-- agents: DROP agents_source_ext_idx +-- (дубль agents_ext_source_ext_agent_id_key, btree (ext_source, ext_agent_id); +-- 2 скана за всё время — планировщик и так предпочитал unique-версию) +-- ekb_geoportal_buildings: DROP ix_ekb_geoportal_buildings_street_house +-- (дубль ekb_geoportal_buildings_street_norm_house_norm_key, +-- btree (street_norm, house_norm); 13 945 сканов, но unique-версия того же +-- определения покрывает те же запросы — 70 702 скана на ней) +-- house_placement_history: DROP hph_source_item_idx +-- (дубль house_placement_history_source_ext_item_id_key, +-- btree (source, ext_item_id); 0 сканов — полностью мёртв) +-- house_reviews: DROP hr_source_ext_idx +-- (дубль house_reviews_source_ext_review_id_key, btree (source, ext_review_id); +-- 15 сканов) +-- sellers: DROP sellers_source_idx +-- (дубль sellers_source_ext_seller_id_key, btree (source, ext_seller_id); +-- 2 скана) +-- +-- ПРИМЕЧАНИЕ (расхождение с ожиданием «шесть»): при систематической проверке +-- (3 независимых метода: нормализованный DDL-текст, сравнение indkey/indoption, +-- сравнение opclass) строгих дублей найдено 5, не 6. Два похожих кандидата +-- ЦЕЛЕНАПРАВЛЕННО исключены — их «дубль» только по списку колонок, а порядок +-- сортировки различается (ASC,ASC у unique-версии против ASC,DESC у второй), +-- то есть это ТОТ ЖЕ класс исключения, что explicitly подтверждённые +-- idx_lss_source_date/listings_snapshots_listing_date_idx (#2607, см. миграцию 225): +-- * offer_price_history: oph_listing_time_idx (listing_id ASC, change_time DESC) +-- против offer_price_history_listing_change_uq (listing_id ASC, change_time ASC) +-- * houses_price_dynamics: hpd_house_dim_idx (..., month_date DESC) +-- против houses_price_dynamics_dim_key (..., month_date ASC) +-- Оба НЕ тронуты. Если «шесть» подразумевали один из них — нужно явное +-- подтверждение, что смешанный порядок сортировки в конкретном запросе не +-- используется (тем же способом, каким для idx_lss_source_date подтверждено +-- обратное). +-- +-- ── C. v_data_quality — явный список колонок вместо SELECT * ───────────────── +-- Сейчас: `WITH active_listings AS (SELECT * FROM listings WHERE is_active = true)`. +-- Postgres разворачивает `*` в CREATE VIEW time в полный список колонок listings +-- (89 на момент миграции) и фиксирует pg_depend на КАЖДУЮ из них — это то самое +-- уже задокументированное в 214/216 предупреждение («View-зависимость: v_data_quality +-- содержит SELECT * FROM listings, что фиксирует column-level зависимость на ВСЕ +-- колонки»), которое обязывало делать DROP VIEW → DROP COLUMN → CREATE VIEW при +-- каждой чистке listings. +-- Фактически используются только 6 колонок active_listings ниже по телу view: +-- id — IN (SELECT active_listings.id FROM active_listings) +-- lat — pct_geocoded +-- cadastral_number — pct_cadastr +-- description — pct_description +-- house_id_fk — JOIN houses h ON h.id = l.house_id_fk (pct_year_built) +-- is_active — WHERE-фильтр самого CTE (создаёт зависимость даже не будучи +-- в SELECT-списке, поэтому перечислен явно для наглядности) +-- Поведение view НЕ меняется — только явный список вместо *. Тело SELECT (16 +-- выходных колонок) скопировано без изменений из 216_dead_code_sweep.sql. +-- COMMENT ON VIEW сохраняется автоматически (CREATE OR REPLACE VIEW не сбрасывает +-- комментарий). +-- +-- Dependencies: 002_core_tables.sql (listings), 214_drop_dead_run_metrics.sql, +-- 216_dead_code_sweep.sql (последний DDL v_data_quality). +-- Идемпотентно: DROP TABLE IF EXISTS / DROP INDEX IF EXISTS / CREATE OR REPLACE VIEW. + +BEGIN; + +-- ── A ────────────────────────────────────────────────────────────────────── +DROP TABLE IF EXISTS tmp_purged_junk_houses_0702, tmp_purged_junk_links_0702; + +-- ── B ────────────────────────────────────────────────────────────────────── +DROP INDEX IF EXISTS agents_source_ext_idx; +DROP INDEX IF EXISTS ix_ekb_geoportal_buildings_street_house; +DROP INDEX IF EXISTS hph_source_item_idx; +DROP INDEX IF EXISTS hr_source_ext_idx; +DROP INDEX IF EXISTS sellers_source_idx; + +-- ── C ────────────────────────────────────────────────────────────────────── +CREATE OR REPLACE VIEW v_data_quality AS +WITH active_listings AS ( + SELECT id, lat, cadastral_number, description, house_id_fk, is_active + FROM listings + WHERE is_active = true +) +SELECT + (SELECT count(*) FROM houses) AS houses_total, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs WHERE hs.house_id = h.id)) AS houses_with_source, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs + WHERE hs.house_id = h.id AND hs.ext_source = 'avito')) AS houses_with_avito, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs + WHERE hs.house_id = h.id AND hs.ext_source LIKE 'cian%')) AS houses_with_cian, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs + WHERE hs.house_id = h.id AND hs.ext_source = 'yandex')) AS houses_with_yandex, + (SELECT count(*) FROM ( + SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 2 + ) sub) AS houses_2plus_sources, + (SELECT count(*) FROM ( + SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 3 + ) sub) AS houses_3plus_sources, + (SELECT count(*) FROM active_listings) AS listings_active, + (SELECT count(*) FROM ( + SELECT listing_id FROM listing_sources + WHERE listing_id IN (SELECT id FROM active_listings) + GROUP BY listing_id HAVING count(*) >= 2 + ) sub) AS listings_dedup_2sources, + (SELECT count(*) FROM active_listings WHERE lat IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_geocoded, + (SELECT count(*) FROM active_listings WHERE cadastral_number IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_cadastr, + (SELECT count(*) FROM active_listings WHERE description IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_description, + (SELECT count(*) FROM active_listings l + JOIN houses h ON h.id = l.house_id_fk + WHERE h.year_built IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_year_built, + NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'avito') AS avito_last_scrape_ago, + NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'cian') AS cian_last_scrape_ago, + NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'yandex') AS yandex_last_scrape_ago; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/225_listing_source_snapshots_run_id_idx.sql b/tradein-mvp/backend/data/sql/225_listing_source_snapshots_run_id_idx.sql new file mode 100644 index 00000000..e783502e --- /dev/null +++ b/tradein-mvp/backend/data/sql/225_listing_source_snapshots_run_id_idx.sql @@ -0,0 +1,42 @@ +-- 225_listing_source_snapshots_run_id_idx.sql +-- Индекс под внешний ключ listing_source_snapshots.run_id → scrape_runs(id) +-- ON DELETE SET NULL. Индекса на run_id нет (проверено \d listing_source_snapshots +-- на проде): есть только listing_source_snapshots_pkey (listing_source_id, +-- snapshot_date), idx_lss_source_date (listing_source_id, snapshot_date DESC), +-- idx_lss_snapshot_date (snapshot_date DESC) — ни один не начинается с run_id. +-- Таблица — 2 872 080 строк (reltuples), 696 MB (pg_total_relation_size). +-- Без индекса каждый DELETE из scrape_runs делает Seq Scan по 2.87М строк, чтобы +-- обнулить run_id у зависимых снэпшотов (ON DELETE SET NULL). +-- +-- ── Почему CONCURRENTLY и почему в файле нет BEGIN/COMMIT ──────────────────── +-- CREATE INDEX CONCURRENTLY не может выполняться внутри блока транзакции +-- (Postgres: "CREATE INDEX CONCURRENTLY cannot run inside a transaction block"). +-- На 2.87М строк / 696 MB обычный CREATE INDEX держит ACCESS EXCLUSIVE lock на +-- время сборки (секунды-десятки секунд под нагрузкой) — на боевой таблице, +-- которую пишет активный скрейпинг, это неприемлемо; нужен CONCURRENTLY. +-- +-- Более ранние миграции с похожей потребностью (117, 120, 134, 137) сознательно +-- ОТКАЗАЛИСЬ от CONCURRENTLY с комментарием «deploy migration runner wraps each +-- file in an explicit transaction (BEGIN/COMMIT)». Перепроверено перед этой +-- миграцией: .forgejo/workflows/deploy-tradein.yml, шаг применения миграций +-- (`for sql_file in ...; psql -v ON_ERROR_STOP=on < "$sql_file"`) НЕ добавляет +-- собственный BEGIN/COMMIT и не передаёт `-1`/`--single-transaction` — транзакция +-- в тех файлах возникала ТОЛЬКО из-за их же собственных BEGIN;...COMMIT; внутри +-- файла, не из-за механизма деплоя. Эмпирическое подтверждение: в data/sql уже +-- есть применённые на проде миграции без BEGIN/COMMIT вовсе (003_seed_deals.sql, +-- 005_geocode_tracking.sql, 218_scrape_runs_ban_kind.sql, +-- 223_scrape_runs_time_columns_meaning.sql) — psql выполняет их операторы с +-- autocommit по одному, деплой не падает. Поэтому здесь BEGIN/COMMIT сознательно +-- ОПУЩЕН: файл — это один самостоятельный CREATE INDEX CONCURRENTLY, выполняемый +-- psql в autocommit-режиме. +-- +-- Идемпотентно: IF NOT EXISTS. (Единственный неидемпотентный случай — если +-- предыдущая попытка CONCURRENTLY была прервана и оставила INVALID индекс с тем +-- же именем; тогда IF NOT EXISTS молча НЕ пересоздаст его валидным, и потребуется +-- ручной `DROP INDEX CONCURRENTLY idx_lss_run_id;` перед повтором — это штатное +-- поведение CONCURRENTLY, не специфика этого файла.) +-- Dependencies: 079_listing_source_history.sql (создала таблицу и оба FK). +-- Deploy order: standalone, независим от 222_db_audit_cleanup.sql. + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_lss_run_id + ON listing_source_snapshots (run_id); diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 7d71c6d7..52a76097 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -230,4 +230,13 @@ # Тем самым снято отложенное условие из прошлой редакции: 187/188 (веб-чат # поддержки, #2532/#2533) откладывались до подтверждения, что они осели на # проде в финальном виде. Они в _schema_migrations — условие выполнено. +# +# 217-232 сюда намеренно не дописаны этой миграцией (222/225): в момент +# правки они уже слиты в main и применены на проде (см. _schema_migrations), +# но их авторы не дописали имена в тот же PR — это чужой пробел, не наш; +# self-maintenance-контракт (см. докстринг test_migrations_manifest.py) +# требует дописывать только СВОЙ файл в СВОЁМ PR, что и сделано ниже для +# 222/225 по прецеденту 233_payments.sql. +222_db_audit_cleanup.sql +225_listing_source_snapshots_run_id_idx.sql 233_payments.sql From ac870b0c582a291d22a7233c588db876f748673a Mon Sep 17 00:00:00 2001 From: lekss361 Date: Thu, 6 Aug 2026 18:47:35 +0000 Subject: [PATCH 002/124] =?UTF-8?q?fix(ptica):=20=D1=81=D0=BA=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=20=D0=9F=D0=94=D0=BD=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=BE=D0=B9=20=D0=B2?= =?UTF-8?q?=20=D0=BC=D0=BE=D0=BD=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D0=BD=D0=B3?= =?UTF-8?q?=20+=20=D1=87=D0=B5=D1=81=D1=82=D0=BD=D0=B0=D1=8F=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D1=8C=20=D0=9D=D0=94=D0=A1=20?= =?UTF-8?q?=D0=B2=20=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=B0=D1=85=20(#2457)?= =?UTF-8?q?=20(#2749)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PII scrub wired to BOTH channels (before_send AND before_send_transaction) in app/main.py and app/workers/celery_app.py. Before: Celery had no before_send at all, and before_send_transaction was URL-only while glitchtip_traces_sample_rate defaults to 0.05 - the Starlette integration puts request.data on transaction scope exactly as on error scope, so lead bodies leaked through the transaction channel. Keys: full MERA set (client_name/client_phone/client_email/phone/email/name) plus company/message from PilotRequestInput. VAT label: 'NDS (parking)' -> 'NDS (parking + commercial)' in DOCX/HTML exporters - financial.py computes VAT over parking AND non-residential. --- backend/app/main.py | 11 +- backend/app/observability/sentry_scrub.py | 107 ++++++- .../services/exporters/full_report_docx.py | 2 +- .../services/exporters/full_report_html.py | 2 +- backend/app/workers/celery_app.py | 10 +- backend/tests/test_sentry_init.py | 261 +++++++++++++++++- 6 files changed, 381 insertions(+), 12 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index ee3969ec..7162bfa0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -48,7 +48,7 @@ from app.core import auth_db from app.core.audit_middleware import audit_log_middleware from app.core.auth import get_role from app.core.config import settings -from app.observability.sentry_scrub import scrub_sensitive_query +from app.observability.sentry_scrub import scrub_event from app.services.auth_session import resolve_session_token logger = logging.getLogger(__name__) @@ -75,6 +75,11 @@ if not any(getattr(_h, "_gd_app_stream", False) for _h in _app_logger.handlers): # (middleware, маршруты) видели активный client с самого старта процесса. # GlitchTip не поддерживает profiling — profiles_sample_rate=0.0. if settings.glitchtip_dsn: + # before_send И before_send_transaction — ОБА на scrub_event (#2457-review): + # Starlette-интеграция кладёт request.data на transaction-scope так же, как + # на error-scope, поэтому голый scrub_sensitive_query (только URL) на + # before_send_transaction оставлял бы PII-канал открытым при любом + # glitchtip_traces_sample_rate > 0 (см. sentry_scrub.py module docstring). sentry_sdk.init( dsn=settings.glitchtip_dsn, environment=settings.environment, @@ -82,8 +87,8 @@ if settings.glitchtip_dsn: traces_sample_rate=settings.glitchtip_traces_sample_rate, profiles_sample_rate=0.0, send_default_pii=False, - before_send=scrub_sensitive_query, - before_send_transaction=scrub_sensitive_query, + before_send=scrub_event, + before_send_transaction=scrub_event, integrations=[ StarletteIntegration(), FastApiIntegration(), diff --git a/backend/app/observability/sentry_scrub.py b/backend/app/observability/sentry_scrub.py index af936c31..d40911ae 100644 --- a/backend/app/observability/sentry_scrub.py +++ b/backend/app/observability/sentry_scrub.py @@ -1,17 +1,63 @@ -"""Хук before_send_transaction для GlitchTip/Sentry SDK. +"""Хуки before_send / before_send_transaction для GlitchTip/Sentry SDK. -Redact-ит api keys / tokens из URL-spans перед отправкой — чтобы -секреты (apiKey=..., api_key=..., token=...) не утекали в GlitchTip -через HttpxIntegration performance-spans. +`scrub_sensitive_query` — redact-ит api keys / tokens из URL-spans перед +отправкой — чтобы секреты (apiKey=..., api_key=..., token=...) не утекали в +GlitchTip через HttpxIntegration performance-spans. + +`scrub_pii_event` — redact-ит consumer-PII (client_name / client_phone / +client_email / phone / email / name / company / message) из events перед +отправкой. `send_default_pii=False` в sentry_sdk.init (проверено на +sentry-sdk 2.58) НЕ покрывает эти поля — это user-data, попадающий в +request.data / extra / contexts (pilot-заявки — `PilotRequestInput` в +`app/api/v1/pilot.py` несёт все 6 полей включая свободный текст `company`/ +`message`, куда чаще всего прилетают телефоны/имена/адреса; чат — свободный +вопрос в `app/schemas/chat.py`), а не PII-заголовки/cookies, которые режет +сам флаг. Портировано из trade-in (`tradein-mvp/backend/app/observability/ +sentry_scrub.py`, #396) — тот же набор ключей (client_name/client_phone/ +client_email — Птица их не использует сегодня, но одинаковый механизм на +оба продукта проще сопровождать), плюс `company`/`message`, специфичные для +`PilotRequestInput` (#2457-review). + +`scrub_event` — composed-хендлер (PII-scrub + URL-secret redact), которым +надо вешать ОБА канала — `before_send` И `before_send_transaction`. +Starlette-интеграция кладёт тело запроса в `request_info["data"]` на +transaction-scope точно так же, как на error-scope (scope-обработчики для +transactions НЕ пропускаются — пропуск бывает только на availability-чеках). +Если повесить PII-scrub только на `before_send`, а `before_send_transaction` +оставить на голом `scrub_sensitive_query` — PII продолжит течь через +transaction-канал при любом `glitchtip_traces_sample_rate > 0` (#2457-review, +воспроизведено: pilot-заявка с реальными данными → ~1/20 попадает в +транзакцию с полным телом). """ from __future__ import annotations +import logging import re from typing import Any from sentry_sdk.types import Event +logger = logging.getLogger(__name__) + +_REDACTED = "[REDACTED]" +# Ключи consumer-PII (нижний регистр; сверка case-insensitive). Набор МЕРЫ +# (client_name/client_phone/client_email/phone/email/name, #396) + company/ +# message — специфичные для PilotRequestInput (app/api/v1/pilot.py) поля +# свободного текста (#2457-review). +_PII_KEYS = frozenset( + { + "client_name", + "client_phone", + "client_email", + "phone", + "email", + "name", + "company", + "message", + } +) + _SENSITIVE_PARAM_RE = re.compile( r"((?:api[_-]?[Kk]ey|token|access[_-]?token|secret)=)([^&\s]+)", re.IGNORECASE, @@ -47,3 +93,56 @@ def scrub_sensitive_query(event: Event, _hint: dict[str, Any]) -> Event | None: request["url"] = _redact(request["url"]) return event + + +def _scrub(obj: Any) -> None: + """Рекурсивно заменить значения PII-ключей в dict на [REDACTED] (in-place).""" + if isinstance(obj, dict): + for key, value in obj.items(): + if isinstance(key, str) and key.lower() in _PII_KEYS: + obj[key] = _REDACTED + else: + _scrub(value) + elif isinstance(obj, list): + for item in obj: + _scrub(item) + + +def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None: + """Redact consumer-PII (см. `_PII_KEYS`) из event (error ИЛИ transaction) + перед отправкой в GlitchTip. + + Обходит `request.data` / `extra` / `contexts` рекурсивно (dict/list), + заменяет значения PII-ключей на [REDACTED] in-place. Возвращает event + (не None) — иначе SDK дропнет отчёт целиком. + """ + if not isinstance(event, dict): + return event + request = event.get("request") + if isinstance(request, dict): + _scrub(request.get("data")) + _scrub(event.get("extra")) + _scrub(event.get("contexts")) + return event + + +def scrub_event(event: Event, hint: dict[str, Any]) -> Event | None: + """Composed `before_send` / `before_send_transaction` handler: PII-scrub + + URL query-secret redact. Вешать ОДИНАКОВО на оба канала — см. module + docstring (#2457-review): transaction-scope несёт `request.data` точно так + же, как error-scope. + + try/except — предохранитель: sentry_sdk оборачивает вызов `before_send` в + `capture_internal_exceptions`, который при исключении внутри хендлера + ТОЛЬКО логирует и ДРОПАЕТ event целиком (SDK никогда не узнает, что + редактор упал, — event просто не уйдёт). Наблюдаемость важнее полноты + покрытия редактора: лучше отправить событие в состоянии "сколько успели + отредактировать до сбоя", чем не отправить вообще и молча остаться без + сигнала в мониторинге. + """ + try: + scrub_pii_event(event, hint) + scrub_sensitive_query(event, hint) + except Exception: + logger.exception("sentry_scrub.scrub_event: handler failed, sending event as-is") + return event diff --git a/backend/app/services/exporters/full_report_docx.py b/backend/app/services/exporters/full_report_docx.py index d85d24ca..6d6f139e 100644 --- a/backend/app/services/exporters/full_report_docx.py +++ b/backend/app/services/exporters/full_report_docx.py @@ -869,7 +869,7 @@ def _build_financial_cascade(doc: _DocxDocument, financial: dict[str, Any]) -> N ["Земля", _fmt_money_signed(financial.get("land_rub"))], ["Итого затраты", _fmt_money_signed(financial.get("cost_rub"))], ["Валовая маржа", _fmt_money_signed(financial.get("gross_margin_rub"))], - ["НДС (паркинг)", _fmt_money_signed(financial.get("vat_rub"))], + ["НДС (паркинг + коммерция)", _fmt_money_signed(financial.get("vat_rub"))], ["Прибыль до налога", _fmt_money_signed(financial.get("profit_before_tax_rub"))], ["Налог на прибыль", _fmt_money_signed(financial.get("profit_tax_rub"))], ["Чистая прибыль", _fmt_money_signed(financial.get("net_profit_rub"))], diff --git a/backend/app/services/exporters/full_report_html.py b/backend/app/services/exporters/full_report_html.py index c71725af..6087e4a4 100644 --- a/backend/app/services/exporters/full_report_html.py +++ b/backend/app/services/exporters/full_report_html.py @@ -1338,7 +1338,7 @@ def _build_financial_cascade(financial: dict[str, Any]) -> str: ["Земля", _fmt_money_signed(financial.get("land_rub"))], ["Итого затраты", _fmt_money_signed(financial.get("cost_rub"))], ["Валовая маржа", _fmt_money_signed(financial.get("gross_margin_rub"))], - ["НДС (паркинг)", _fmt_money_signed(financial.get("vat_rub"))], + ["НДС (паркинг + коммерция)", _fmt_money_signed(financial.get("vat_rub"))], ["Прибыль до налога", _fmt_money_signed(financial.get("profit_before_tax_rub"))], ["Налог на прибыль", _fmt_money_signed(financial.get("profit_tax_rub"))], ["Чистая прибыль", _fmt_money_signed(financial.get("net_profit_rub"))], diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index 1268bfe6..e52b8f5b 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -15,7 +15,7 @@ from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration from app.core.config import settings -from app.observability.sentry_scrub import scrub_sensitive_query +from app.observability.sentry_scrub import scrub_event logger = logging.getLogger(__name__) @@ -23,6 +23,11 @@ logger = logging.getLogger(__name__) # чтобы события из тасков попадали в GlitchTip. SDK безопасен для двойного # вызова — повторный sentry_sdk.init() в одном процессе заменяет клиента. if settings.glitchtip_dsn: + # before_send И before_send_transaction — ОБА на scrub_event (#2457-review, + # см. app/main.py и sentry_scrub.py module docstring): до этого фикса worker + # вообще не скрабил error-события (тут before_send не было), а + # before_send_transaction был на голом scrub_sensitive_query (только URL) — + # оба канала пропускали PII. sentry_sdk.init( dsn=settings.glitchtip_dsn, environment=settings.environment, @@ -30,7 +35,8 @@ if settings.glitchtip_dsn: traces_sample_rate=settings.glitchtip_traces_sample_rate, profiles_sample_rate=0.0, send_default_pii=False, - before_send_transaction=scrub_sensitive_query, + before_send=scrub_event, + before_send_transaction=scrub_event, integrations=[ CeleryIntegration(monitor_beat_tasks=True), SqlalchemyIntegration(), diff --git a/backend/tests/test_sentry_init.py b/backend/tests/test_sentry_init.py index 75538a31..73dd80c4 100644 --- a/backend/tests/test_sentry_init.py +++ b/backend/tests/test_sentry_init.py @@ -2,14 +2,21 @@ Проверяем что init-блок в main.py / celery_app.py вызывает sentry_sdk.init() только при непустом GLITCHTIP_DSN, что release-fallback работает корректно, -и что scrub_sensitive_query redact-ит api keys из URL spans. +что scrub_sensitive_query redact-ит api keys из URL spans, что scrub_pii_event +redact-ит consumer-PII (client_name/client_phone/client_email/phone/email/name/ +company/message) из request.data/extra/contexts, и что composed-хендлер +scrub_event реально повешен на ОБА канала (before_send И +before_send_transaction) в main.py/celery_app.py (#2457-review). """ import os +import pathlib from unittest.mock import patch import sentry_sdk +_BACKEND_ROOT = pathlib.Path(__file__).resolve().parents[1] + def test_sdk_imports_without_error() -> None: """Все интеграции импортируются без ModuleNotFoundError.""" @@ -156,3 +163,255 @@ def test_scrub_handles_missing_spans() -> None: event: dict = {"request": {"url": "https://example.com"}} result = scrub_sensitive_query(event, {}) assert result["request"]["url"] == "https://example.com" + + +# ── scrub_pii_event (портировано из tradein-mvp, #396 / аудит-фикс) ──────────── +# +# send_default_pii=False в sentry_sdk.init НЕ закрывает consumer-PII, попадающий +# в request.data / extra / contexts (лиды pilot.py, chat.py) — это user-data, +# а не PII-заголовки/cookies, которые режет сам флаг (проверено на sentry-sdk +# 2.58). scrub_pii_event закрывает этот вектор ключ-based редактором. + + +def test_scrub_pii_redacts_request_data() -> None: + """scrub_pii_event заменяет client_name/phone/email в request.data.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = { + "request": { + "data": { + "client_name": "Иван Иванов", + "phone": "+79991234567", + "email": "ivan@example.com", + "address": "Екатеринбург, ул. Ленина 1", + } + } + } + result = scrub_pii_event(event, {}) + data = result["request"]["data"] + assert data["client_name"] == "[REDACTED]" + assert data["phone"] == "[REDACTED]" + assert data["email"] == "[REDACTED]" + # non-PII поле не трогаем + assert data["address"] == "Екатеринбург, ул. Ленина 1" + + +def test_scrub_pii_redacts_pilot_request_company_and_message() -> None: + """scrub_pii_event заменяет company/message — свободный текст + PilotRequestInput (app/api/v1/pilot.py), куда чаще всего прилетают + телефоны/имена/адреса, а не только фиксированные name/phone/email + (#2457-review).""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = { + "request": { + "data": { + "company": "ООО Ромашка", + "message": "Меня зовут Иван, звоните на +79991234567", + "source": "landing", + } + } + } + result = scrub_pii_event(event, {}) + data = result["request"]["data"] + assert data["company"] == "[REDACTED]" + assert data["message"] == "[REDACTED]" + # non-PII поле не трогаем + assert data["source"] == "landing" + + +def test_scrub_pii_redacts_client_prefixed_keys() -> None: + """Полный набор ключей МЕРЫ (client_name/client_phone/client_email, #396) — + Птица их сегодня не использует, но одинаковый механизм на оба продукта + проще сопровождать (#2457-review).""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = { + "extra": { + "client_name": "Иван", + "client_phone": "+79991234567", + "client_email": "ivan@example.com", + } + } + result = scrub_pii_event(event, {}) + extra = result["extra"] + assert extra["client_name"] == "[REDACTED]" + assert extra["client_phone"] == "[REDACTED]" + assert extra["client_email"] == "[REDACTED]" + + +def test_scrub_pii_redacts_extra() -> None: + """scrub_pii_event заменяет PII-ключи в extra, не трогая остальное.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = { + "extra": { + "phone": "+79990000000", + "email": "x@y.ru", + "name": "Пётр", + "lead_id": 42, + } + } + result = scrub_pii_event(event, {}) + extra = result["extra"] + assert extra["phone"] == "[REDACTED]" + assert extra["email"] == "[REDACTED]" + assert extra["name"] == "[REDACTED]" + assert extra["lead_id"] == 42 + + +def test_scrub_pii_is_case_insensitive() -> None: + """Сверка PII-ключей case-insensitive.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = {"extra": {"Client_Name": "Анна", "PHONE": "+7900"}} + result = scrub_pii_event(event, {}) + assert result["extra"]["Client_Name"] == "[REDACTED]" + assert result["extra"]["PHONE"] == "[REDACTED]" + + +def test_scrub_pii_redacts_nested_contexts() -> None: + """scrub_pii_event проходит вложенные dict в contexts, не трогая non-PII.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = {"contexts": {"trace": {"op": "http"}, "lead": {"email": "z@z.ru"}}} + result = scrub_pii_event(event, {}) + assert result["contexts"]["lead"]["email"] == "[REDACTED]" + assert result["contexts"]["trace"]["op"] == "http" + + +def test_scrub_pii_leaves_non_pii_untouched() -> None: + """scrub_pii_event не трогает event без PII-ключей.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = { + "request": {"data": {"region": "66", "area_sqm": 50}}, + "extra": {"job": "geocode"}, + "level": "error", + } + result = scrub_pii_event(event, {}) + assert result["request"]["data"] == {"region": "66", "area_sqm": 50} + assert result["extra"] == {"job": "geocode"} + assert result["level"] == "error" + + +def test_scrub_pii_handles_missing_sections() -> None: + """scrub_pii_event не падает на пустом event.""" + from app.observability.sentry_scrub import scrub_pii_event + + result = scrub_pii_event({}, {}) + assert result == {} + + +def test_scrub_pii_handles_none_and_non_dict_sections() -> None: + """scrub_pii_event не бросает исключений на None/не-dict секциях.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = {"request": None, "extra": None, "contexts": "not-a-dict"} + result = scrub_pii_event(event, {}) + assert result is event + + +def test_scrub_pii_returns_event_not_none() -> None: + """before_send должен вернуть event (не None) — иначе SDK дропнет отчёт.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = {"request": {"data": {"client_name": "X"}}} + result = scrub_pii_event(event, {}) + assert result is not None + assert result is event + + +# ── scrub_event (composed before_send / before_send_transaction handler) ─────── +# +# scrub_event — ЕДИНЫЙ хендлер, которым в main.py/celery_app.py вешаются ОБА +# канала (before_send И before_send_transaction). До #2457-review composed-хук +# висел только на before_send, а before_send_transaction оставался на голом +# scrub_sensitive_query (только URL) — Starlette-интеграция кладёт request.data +# на transaction-scope так же, как на error-scope, поэтому PII продолжало течь +# через transaction-канал при glitchtip_traces_sample_rate > 0. + + +def test_scrub_event_composes_pii_and_url_secret_scrub() -> None: + """scrub_event применяет PII-scrub (ключ-based) И URL query-string secret + redact (regex) оба разом, не заменяя друг друга — разные классы данных.""" + from app.observability.sentry_scrub import scrub_event + + event: dict = { + "request": { + "data": {"client_name": "Иван"}, + "url": "https://example.com?api_key=supersecret", + } + } + result = scrub_event(event, {}) + assert result is not None + assert result["request"]["data"]["client_name"] == "[REDACTED]" + assert "[REDACTED]" in result["request"]["url"] + assert "supersecret" not in result["request"]["url"] + + +def test_scrub_event_returns_event_not_none() -> None: + """scrub_event всегда возвращает event (не None) — иначе SDK дропнет отчёт.""" + from app.observability.sentry_scrub import scrub_event + + event: dict = {"request": {"data": {"name": "X"}}} + result = scrub_event(event, {}) + assert result is not None + assert result is event + + +def test_scrub_event_survives_scrub_pii_event_exception() -> None: + """try/except в scrub_event — предохранитель: sentry_sdk оборачивает + before_send в capture_internal_exceptions, который при исключении ТОЛЬКО + логирует и ДРОПАЕТ event целиком (SDK никогда не узнает, что редактор упал). + Если scrub_pii_event падает — scrub_event обязан вернуть event, а не + пробросить исключение дальше (#2457-review).""" + from app.observability.sentry_scrub import scrub_event + + event: dict = {"request": {"data": {"client_name": "X"}}} + with patch( + "app.observability.sentry_scrub.scrub_pii_event", + side_effect=RuntimeError("boom"), + ): + result = scrub_event(event, {}) + assert result is not None + assert result is event + + +def test_scrub_event_survives_scrub_sensitive_query_exception() -> None: + """То же самое для второго шага композиции (URL-secret redact).""" + from app.observability.sentry_scrub import scrub_event + + event: dict = {"request": {"data": {"name": "X"}}} + with patch( + "app.observability.sentry_scrub.scrub_sensitive_query", + side_effect=RuntimeError("boom"), + ): + result = scrub_event(event, {}) + assert result is not None + assert result is event + + +# ── wiring: before_send/before_send_transaction реально используют scrub_event ── +# +# Source-grep вместо мока sentry_sdk.init: main.py/celery_app.py вызывают +# sentry_sdk.init() на module-level import, поэтому мок пришлось бы ставить ДО +# импорта app.main — фрагильно и не переиспользуемо между тестами (модуль уже +# закэширован в sys.modules к моменту первого теста). Прямая проверка исходника +# — детерминированный, дешёвый и точный регрессионный гейт на саму строку, +# которую правил review (#2457). + + +def test_main_wires_scrub_event_to_both_channels() -> None: + """app/main.py: before_send И before_send_transaction ОБА на scrub_event.""" + text = (_BACKEND_ROOT / "app" / "main.py").read_text(encoding="utf-8") + assert "before_send=scrub_event" in text + assert "before_send_transaction=scrub_event" in text + + +def test_celery_app_wires_scrub_event_to_both_channels() -> None: + """app/workers/celery_app.py: before_send И before_send_transaction ОБА на + scrub_event (раньше before_send не было вообще).""" + text = (_BACKEND_ROOT / "app" / "workers" / "celery_app.py").read_text(encoding="utf-8") + assert "before_send=scrub_event" in text + assert "before_send_transaction=scrub_event" in text From 5ff06d25b46c70f82fc9370d814417da112ab726 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 21:48:06 +0300 Subject: [PATCH 003/124] =?UTF-8?q?feat(tradein/payments):=20=D0=BE=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D1=87=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=BE=D1=82?= =?UTF-8?q?=D1=87=D1=91=D1=82=20=D1=85=D1=80=D0=B0=D0=BD=D0=B8=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=B3=D0=BE=D0=B4=20=E2=80=94=20retain=5Funtil=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=B5=D0=B4=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D0=B5=D0=BB=D0=B8=20=D0=B2=20=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D1=87=D0=B5=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Мина: purge_expired_trade_in_data (сейчас enabled=false) удаляет строки WHERE expires_at < NOW() AND created_by IS NULL — это ровно популяция будущих платящих физлиц (владелец продаёт отчёт за 150 руб., отчёт должен жить год на нашей стороне, а не 24ч). Первый прогон после запуска продаж безвозвратно снёс бы оплаченное. Делается ДО платёжного кода, которого в этом PR нет: - migration 234: колонка trade_in_estimates.retain_until (NULL = неоплачено, бэкенд-бита-в-бит не меняется) + частичный индекс под purge-предикат. - config.py: trade_in_paid_retention_days=365 (ENV) — единственный источник "12 месяцев" для будущей оферты/экрана/SQL продления. - Единый гейт чтения ESTIMATE_READABLE_SQL + estimate_readable() — раньше SQL-фильтр (404) и Python-проверка (410) в trade_in.py уже разошлись по тексту ответа; текст "estimate expired (24h TTL)" убран (стал бы ложью при годовом хранении). - purge_expired_trade_in_data: retain_until IS NULL (не < NOW() — оплаченное не удаляем в принципе) + NOT EXISTS(payments) как независимая страховка + pre-flight, который считает оплаченных кандидатов и падает в mark_failed ДО первого батча при ненулевом результате. - PDF: "Ссылка доступна до …" только при retain_until IS NOT NULL; "ДЕЙСТВИТЕЛЕН ДО" (expires_at, актуальность расчёта) не тронут. - Фронт: retain_until прокинут в mapper (validUntil остаётся на expires_at). - privacy-страница: убрано устаревшее "механизма удаления нет" (неправда после #2547), добавлен срок 12 месяцев для оплаченных отчётов. Ни строчки платёжного кода. expires_at, trade_in_estimate_retention_hours, _DELETE_EXPIRED_LEADS_SQL не тронуты. --- tradein-mvp/backend/app/api/v1/trade_in.py | 39 +++++- tradein-mvp/backend/app/core/config.py | 11 ++ tradein-mvp/backend/app/schemas/trade_in.py | 4 + .../app/services/exporters/trade_in_pdf.py | 14 ++ .../app/tasks/purge_expired_trade_in_data.py | 76 +++++++++++ .../234_trade_in_estimates_retain_until.sql | 65 +++++++++ .../backend/data/sql/_manifest_applied.txt | 1 + .../backend/tests/test_estimate_idor.py | 123 ++++++++++++++++- .../backend/tests/test_pdf_security.py | 33 +++++ .../tests/test_purge_expired_trade_in_data.py | 128 ++++++++++++++++-- .../src/app/mera-public/privacy/page.tsx | 34 +++-- tradein-mvp/frontend/src/app/v2/page.tsx | 2 +- .../src/components/trade-in/v2/fixtures.ts | 1 + .../src/components/trade-in/v2/mappers.ts | 3 + .../src/components/trade-in/v2/types.ts | 4 + tradein-mvp/frontend/src/types/trade-in.ts | 4 + 16 files changed, 512 insertions(+), 30 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/234_trade_in_estimates_retain_until.sql diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 5335901b..05950b0c 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -52,6 +52,27 @@ logger = logging.getLogger(__name__) router = APIRouter() +# PR-D1: единственное определение «оценка читаема» — раньше SQL-фильтр (404, +# ниже в get_estimate) и Python-проверка (410, в estimate_pdf) уже разошлись +# по коду ответа; третий потребитель (`/r/`, PR-9) разошёлся бы +# неизбежно без унификации. `retain_until > NOW()` при NULL даёт NULL → false +# в SQL — для всех существующих строк (retain_until IS NULL) поведение не +# меняется вообще. Не копировать это выражение по месту — только через +# константу/хелпер ниже. См. `mera-pr-d-spec.md` §1.3 в корне репо. +ESTIMATE_READABLE_SQL = "(expires_at > NOW() OR retain_until > NOW())" + + +def estimate_readable(expires_at: datetime, retain_until: datetime | None) -> bool: + """Python-зеркало ESTIMATE_READABLE_SQL — та же дизъюнкция, без похода в БД. + + tzinfo-нормализация повторяет прежнюю Python-проверку (estimate_pdf) — + `.replace(tzinfo=UTC)`, не переизобретается. + """ + now = datetime.now(tz=UTC) + if expires_at.replace(tzinfo=UTC) > now: + return True + return retain_until is not None and retain_until.replace(tzinfo=UTC) > now + def _assert_estimate_access(created_by: str | None, x_authenticated_user: str | None) -> None: """IDOR guard (#690): только владелец оценки или admin могут её читать. @@ -249,11 +270,11 @@ def get_estimate( """ row = db.execute( text( - """ + f""" SELECT id, median_price, range_low, range_high, median_price_per_m2, confidence, confidence_explanation, n_analogs, analogs, actual_deals, sources_used, data_freshness_minutes, - expires_at, address, lat, lon, + expires_at, retain_until, address, lat, lon, area_m2, rooms, floor, total_floors, year_built, house_type, repair_state, has_balcony, canonical_address, house_cadnum, house_fias_id, @@ -263,7 +284,7 @@ def get_estimate( asking_to_sold_ratio, ratio_basis, created_by, created_at FROM trade_in_estimates WHERE id = CAST(:id AS uuid) - AND expires_at > NOW() + AND {ESTIMATE_READABLE_SQL} """ ), {"id": str(estimate_id)}, @@ -372,6 +393,7 @@ def get_estimate( analogs=analogs, actual_deals=actual_deals, expires_at=row.expires_at, + retain_until=row.retain_until, target_address=row.address, target_lat=row.lat, target_lon=row.lon, @@ -433,7 +455,7 @@ def estimate_pdf( SELECT id, median_price, range_low, range_high, median_price_per_m2, confidence, confidence_explanation, n_analogs, analogs, actual_deals, sources_used, data_freshness_minutes, - expires_at, + expires_at, retain_until, address, lat, lon, area_m2, rooms, floor, total_floors, year_built, house_type, repair_state, has_balcony, canonical_address, house_cadnum, house_fias_id, @@ -453,8 +475,12 @@ def estimate_pdf( _assert_estimate_access(row.created_by, x_authenticated_user) - if row.expires_at.replace(tzinfo=UTC) < datetime.now(tz=UTC): - raise HTTPException(status_code=410, detail="estimate expired (24h TTL)") + # PR-D1: тот же гейт, что в get_estimate (см. ESTIMATE_READABLE_SQL) — раньше + # здесь была независимая Python-проверка expires_at, разошедшаяся с SQL- + # фильтром GET-ручки. "estimate expired (24h TTL)" убрано из текста: при + # годовом retain_until упоминание 24ч в ответе API стало бы ложью. + if not estimate_readable(row.expires_at, row.retain_until): + raise HTTPException(status_code=410, detail="estimate expired") from app.services.estimator import _qc_geo_to_precision @@ -477,6 +503,7 @@ def estimate_pdf( analogs=analogs, actual_deals=actual_deals, expires_at=row.expires_at, + retain_until=row.retain_until, target_address=row.address, target_lat=row.lat, target_lon=row.lon, diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 1702d860..9a9b4ebf 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -836,6 +836,17 @@ class Settings(BaseSettings): # срок — решение DPO/юриста, не инженера). ENV: TRADE_IN_LEAD_RETENTION_DAYS. trade_in_lead_retention_days: int = 180 + # ── PR-D1: платный отчёт живёт год (retain_until, migration 234) ──────── + # trade_in_estimates.retain_until TTL (дни ОТ ОПЛАТЫ) — срок жизни ССЫЛКИ/ + # СТРОКИ для оплаченной оценки, независимый от expires_at (актуальность + # расчёта, 24ч). НЕ трогает expires_at — см. migration 234 докстринг и + # `mera-pr-d-spec.md` §1.1/§1.2 в корне репо. Единственный источник числа + # «12 месяцев»: текст оферты (content.ts), текст экрана S4 и SQL продления + # retain_until при оплате (платёжный код, отдельный PR) обязаны читать его + # отсюда, а не хардкодить — иначе классический исход "в оферте 12 месяцев, + # в конфиге 365 дней, на экране «год»". ENV: TRADE_IN_PAID_RETENTION_DAYS. + trade_in_paid_retention_days: int = 365 + # Батч-размер физического DELETE в purge_expired_trade_in_data (нельзя одним # DELETE по всей таблице — долгая блокировка на большом бэклоге). Задача сама # крутит цикл батчей за один прогон (см. _DEFAULT_MAX_BATCHES в таске) — diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index ad4811d0..c7620ece 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -196,6 +196,10 @@ class AggregatedEstimate(BaseModel): analogs: list[AnalogLot] actual_deals: list[AnalogLot] # реальные продажи last 12 mo expires_at: datetime + # PR-D1: срок жизни ССЫЛКИ/СТРОКИ (оплаченный доступ), НЕ актуальности + # расчёта — тот остаётся expires_at (не путать, см. migration 234). + # NULL = неоплачено (весь текущий трафик, B2B pilots включительно). + retain_until: datetime | None = None # ── Дополнительные метаданные ── target_address: str | None = None # geocoded full address target_lat: float | None = None diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py index 425dda5f..8282f83c 100644 --- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py +++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py @@ -1050,6 +1050,19 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s ) report_num = _report_number(estimate) + # PR-D1: «Ссылка доступна до …» — срок жизни ОПЛАЧЕННОГО доступа + # (retain_until), НЕ путать со «Срок действия данных» (expires_at, + # актуальность расчёта) над ней — эта строка не трогается. Рендерится + # ТОЛЬКО когда retain_until IS NOT NULL (неоплаченные — весь текущий + # трафик — не видят этой строки вообще, поведение бит-в-бит текущее). + retain_until_row = ( + f'Ссылка доступна до' + f'' + f"{_mono(estimate.retain_until.date().strftime('%d.%m.%Y'))}" + if estimate.retain_until is not None + else "" + ) + # Короткий адрес (для cover): берём первую часть до запятой full_address = input_snapshot.get("address", "—") address_short = full_address.split(",")[0:3] @@ -1146,6 +1159,7 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s {_mono(today.strftime("%d.%m.%Y"))} Срок действия данных до {_mono(expires.strftime("%d.%m.%Y"))} + {retain_until_row} Адрес{address} Год постройки {year_label} diff --git a/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py b/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py index 420911ec..5a96989c 100644 --- a/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py +++ b/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py @@ -54,6 +54,17 @@ BATCHING (не единый DELETE по всей таблице): don't match `expires_at < NOW()` on the next run; a mid-run failure leaves earlier committed batches deleted (correct, not rolled back) and mark_failed records the partial counters reached so far. + +PR-D1 (2026-08-06, payments retention -- see `mera-pr-d-spec.md` §1 at repo root): + the `created_by IS NULL` population above is EXACTLY the future paying-customer + population -- the owner sells this report to individuals for money, and a paid + row must outlive the 24h `expires_at` link TTL. Two independent safeguards were + added to `_DELETE_EXPIRED_ESTIMATES_SQL` (retain_until IS NULL + NOT EXISTS + payments) plus a pre-flight count in `purge_expired_trade_in_data` that refuses + to run at all if it finds a paid candidate -- see the SQL constants and + `_preflight_paid_candidates` below for the mechanics. No payment code lives in + this file; `retain_until` is set by the (separate, not-yet-existing) payment + fulfillment code. """ from __future__ import annotations @@ -74,6 +85,21 @@ logger = logging.getLogger(__name__) # remainder simply drains on the next nightly run (idempotent, no data loss risk). _DEFAULT_MAX_BATCHES = 20 +# +# PR-D1 (2026-08-06): два независимые предохранителя добавлены к тому же +# предикату, ПЕРЕД тем как платёжный код появился в проекте (мина уже была +# заряжена, см. `mera-pr-d-spec.md` §1 в корне репо): +# 1. `retain_until IS NULL` — именно IS NULL, НЕ `< NOW()`. Оплаченная +# строка (retain_until IS NOT NULL, migration 234) не удаляется джобой +# В ПРИНЦИПЕ, пока не поднято ослабление отдельным PR не раньше чем +# через год после первой продажи. `retain_until` ставится сервисным +# кодом платёжного контура (ещё не существует в этом PR) на now() + +# settings.trade_in_paid_retention_days. +# 2. `NOT EXISTS (payments)` — независимая страховка на случай, если выдача +# забыла проставить retain_until (баг/гонка/ручной INSERT): строка, +# которой коснулись деньги, переживёт джобу даже без корректного (1). +# `payments` создана migration 233 (payments_estimate_idx — дешёвый терм). +# См. также _preflight_paid_candidates ниже — та же логика ДО первого батча. _DELETE_EXPIRED_ESTIMATES_SQL = text( """ DELETE FROM trade_in_estimates @@ -81,12 +107,31 @@ _DELETE_EXPIRED_ESTIMATES_SQL = text( SELECT id FROM trade_in_estimates WHERE expires_at < NOW() AND created_by IS NULL + AND retain_until IS NULL + AND NOT EXISTS ( + SELECT 1 FROM payments p WHERE p.estimate_id = trade_in_estimates.id + ) ORDER BY expires_at LIMIT CAST(:batch_size AS int) ) """ ) +# PR-D1 pre-flight (см. _preflight_paid_candidates): считает по БАЗОВОМУ +# (пред-PR-D1) предикату purge -- `expires_at < NOW() AND created_by IS NULL`, +# БЕЗ retain_until/NOT EXISTS -- специально ШИРЕ итогового DELETE-предиката +# выше, чтобы поймать именно случай "retain_until не проставлен, а деньги +# были" (а не только штатно защищённые retain_until IS NOT NULL строки, +# которые и так не попали бы под DELETE). +_PREFLIGHT_PAID_CANDIDATES_SQL = text( + """ + SELECT count(*) FROM trade_in_estimates e + WHERE e.expires_at < NOW() + AND e.created_by IS NULL + AND EXISTS (SELECT 1 FROM payments p WHERE p.estimate_id = e.id) + """ +) + _DELETE_EXPIRED_LEADS_SQL = text( """ DELETE FROM trade_in_leads @@ -135,6 +180,19 @@ def _drain_expired( break # caught up -- fewer expired rows left than one batch +def _preflight_paid_candidates(db: Session) -> int: + """PR-D1 safety gate: count purge-candidates (base predicate) that have a payments row. + + Runs BEFORE any DELETE batch. A non-zero result means at least one estimate that + would have matched the OLD (pre-PR-D1) purge predicate was actually touched by + money -- either `retain_until` failed to be set (fulfillment bug/race/manual + INSERT) or something inconsistent happened. Either way this run must not delete + anything; see `purge_expired_trade_in_data` below, which aborts before the first + batch when this returns non-zero. + """ + return db.execute(_PREFLIGHT_PAID_CANDIDATES_SQL).scalar_one() + + def purge_expired_trade_in_data( db: Session, run_id: int, @@ -148,10 +206,28 @@ def purge_expired_trade_in_data( deactivate_stale_listings). Finalises the scrape_runs row (mark_done / mark_failed). Returns {"estimates_deleted": N, "leads_deleted": M}. + + PR-D1 pre-flight (see `_preflight_paid_candidates`): if any purge-candidate + estimate has a `payments` row, the run aborts BEFORE the first DELETE batch -- + zero rows deleted, `mark_failed` records why. This is deliberately checked + outside the `try` below so it can never be caught and silently re-reported as a + generic mid-run failure -- it is a distinct, actionable pre-condition failure. """ batch_size = batch_size or settings.trade_in_purge_batch_size max_batches = max_batches or _DEFAULT_MAX_BATCHES counters: dict[str, int] = {"estimates_deleted": 0, "leads_deleted": 0} + + paid_candidates = _preflight_paid_candidates(db) + if paid_candidates: + error = ( + f"pre-flight abort: {paid_candidates} purge-candidate trade_in_estimates " + "row(s) have a matching payments row (retain_until may be unset) -- " + "refusing to run, zero rows deleted" + ) + logger.error("purge_expired_trade_in_data run_id=%d %s", run_id, error) + runs_mod.mark_failed(db, run_id, error, counters) + raise RuntimeError(error) + try: _drain_expired( db, diff --git a/tradein-mvp/backend/data/sql/234_trade_in_estimates_retain_until.sql b/tradein-mvp/backend/data/sql/234_trade_in_estimates_retain_until.sql new file mode 100644 index 00000000..2d2e3d4f --- /dev/null +++ b/tradein-mvp/backend/data/sql/234_trade_in_estimates_retain_until.sql @@ -0,0 +1,65 @@ +-- 234_trade_in_estimates_retain_until.sql +-- PR-D1 «Ретеншен: оплаченное живёт год, purge его не трогает» — см. +-- `mera-pr-d-spec.md` §1 в корне репо (обоснования там, здесь только SQL). +-- Номер сверен и по `forgejo/main`, и по всем открытым PR-веткам на момент +-- написания (последняя занятая — 233_payments.sql) — см. урок в шапке того +-- же файла про то, как коллизия 228/229/231/232 обнаруживается поздно. +-- +-- ── WHY ────────────────────────────────────────────────────────────────────── +-- purge_expired_trade_in_data (migration 231, seeded enabled=false) удаляет +-- строки `WHERE expires_at < NOW() AND created_by IS NULL` — это ровно +-- популяция будущих платящих физлиц (анонимные B2C-оценки). Владелец продаёт +-- отчёт физлицу за 150 ₽: скачанный файл у клиента бессрочно, но ссылка/строка +-- на нашей стороне обязана жить дольше 24-часового TTL расчёта — иначе первый +-- же прогон purge-джобы после запуска продаж физически и безвозвратно удалит +-- уже оплаченное (PDF нигде не хранится, рендерится на лету). +-- +-- `expires_at` НЕ трогаем ни на йоту: это единая глобальная настройка +-- (`trade_in_estimate_retention_hours`), она же — печатаемая в PDF/UI дата +-- «ДЕЙСТВИТЕЛЕН ДО» (актуальность РАСЧЁТА, а не срок жизни строки), и от неё +-- зависит вычисление даты расчёта во фронте (`mappers.ts` fmtDateShift(-24)). +-- Поднять её до года означало бы: (а) дать год хранения ВСЕМ строкам, включая +-- неоплаченные адреса физлиц — прямое нарушение минимизации по 152-ФЗ; +-- (б) напечатать в PDF клиента, что расчёт актуален год. +-- +-- ── WHAT ───────────────────────────────────────────────────────────────────── +-- Новая, независимая колонка retain_until — срок жизни ДОСТУПА/СТРОКИ: +-- NULL = неоплаченная строка, поведение (чтение/PDF/purge) бит-в-бит текущее. +-- Бэкфилла нет — все 1058 существующих строк остаются NULL, ничего не меняется +-- для уже созданных оценок (весь B2B pilot-трафик в их числе). +-- При оплате (платёжный код — отдельный PR, здесь его нет) сервисный слой +-- проставит retain_until = now() + trade_in_paid_retention_days (config.py). +-- +-- Частичный индекс покрывает predicate purge-джобы (migration 231, +-- `_DELETE_EXPIRED_ESTIMATES_SQL`) уже С УЧЁТОМ нового терма retain_until — +-- заведён вместе с колонкой, а не отдельной миграцией, чтобы purge не начал +-- жить без него хотя бы один деплой. +-- +-- ── IDEMPOTENCY ────────────────────────────────────────────────────────────── +-- ADD COLUMN IF NOT EXISTS + CREATE INDEX IF NOT EXISTS — безопасный re-run. +-- Ничего не удаляет, не бэкфиллит, DDL-only (доли секунды на 1058 строках). +-- +-- Dependencies: 001_trade_in_estimates.sql, 233_payments.sql (индекс исключает +-- строки со строкой в payments опосредованно через predicate purge-джобы, +-- сама таблица payments здесь не читается). +-- Apply after: 233_payments.sql. + +BEGIN; + +ALTER TABLE trade_in_estimates + ADD COLUMN IF NOT EXISTS retain_until timestamptz; + +COMMENT ON COLUMN trade_in_estimates.retain_until IS + 'До какого момента строку НЕЛЬЗЯ удалять и ссылка обязана открываться ' + '(оплаченный доступ). Семантика expires_at не меняется: это дата ' + 'актуальности РАСЧЁТА (24ч), она печатается в PDF. NULL = неоплачено, ' + 'поведение бит-в-бит текущее. Задаётся сервисным кодом платёжного контура ' + '(отдельный PR) на now() + trade_in_paid_retention_days (config.py).'; + +-- Частичный индекс под predicate purge-джобы (app/tasks/purge_expired_trade_in_data.py): +-- WHERE created_by IS NULL AND retain_until IS NULL AND expires_at < NOW(). +CREATE INDEX IF NOT EXISTS trade_in_estimates_purge_idx + ON trade_in_estimates (expires_at) + WHERE created_by IS NULL AND retain_until IS NULL; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 7d71c6d7..27b06836 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -231,3 +231,4 @@ # поддержки, #2532/#2533) откладывались до подтверждения, что они осели на # проде в финальном виде. Они в _schema_migrations — условие выполнено. 233_payments.sql +234_trade_in_estimates_retain_until.sql diff --git a/tradein-mvp/backend/tests/test_estimate_idor.py b/tradein-mvp/backend/tests/test_estimate_idor.py index 40234a3e..1dc6aaf9 100644 --- a/tradein-mvp/backend/tests/test_estimate_idor.py +++ b/tradein-mvp/backend/tests/test_estimate_idor.py @@ -51,8 +51,13 @@ def trade_in_app() -> FastAPI: return application -def _make_estimate_row(created_by: str | None) -> SimpleNamespace: - """A trade_in_estimates row with the full column set the endpoints read.""" +def _make_estimate_row(created_by: str | None, retain_until: object = None) -> SimpleNamespace: + """A trade_in_estimates row with the full column set the endpoints read. + + retain_until defaults to None (PR-D1, migration 234) -- unpaid, matches every + row that existed before that migration; explicit param lets retention-gate + tests (see test_estimate_retention_gate.py) construct a paid row. + """ from datetime import UTC, datetime, timedelta return SimpleNamespace( @@ -69,6 +74,7 @@ def _make_estimate_row(created_by: str | None) -> SimpleNamespace: sources_used=["avito"], data_freshness_minutes=10, expires_at=datetime.now(tz=UTC) + timedelta(hours=12), + retain_until=retain_until, address="ул. Тестовая, 1", lat=56.8, lon=60.6, @@ -597,3 +603,116 @@ def test_get_estimate_imv_benchmark_other_pilot_gets_404(trade_in_app: FastAPI) headers={"X-Authenticated-User": "attacker"}, ) assert resp.status_code == 404 + + +# ── PR-D1: retention gate unification (retain_until, mera-pr-d-spec.md §1.3) ── + + +def test_estimate_readable_sql_uses_disjunction() -> None: + """Single definition — OR retain_until, not a hand-copied expression.""" + from app.api.v1.trade_in import ESTIMATE_READABLE_SQL + + assert "expires_at > NOW()" in ESTIMATE_READABLE_SQL + assert "retain_until > NOW()" in ESTIMATE_READABLE_SQL + assert " OR " in ESTIMATE_READABLE_SQL + + +def test_get_estimate_sql_built_from_shared_constant() -> None: + """GET /estimate/{id} SQL filter is built FROM ESTIMATE_READABLE_SQL, not a + hand-copied literal — regression guard against the two gates drifting apart + again (that's exactly what happened before this PR: 404 here, 410 in /pdf).""" + import inspect + + from app.api.v1.trade_in import get_estimate + + src = inspect.getsource(get_estimate) + assert "ESTIMATE_READABLE_SQL" in src + assert "expires_at > NOW()" not in src, "hand-copied predicate, not the shared constant" + assert "retain_until" in src, "SELECT must also fetch retain_until" + + +def test_estimate_pdf_select_includes_retain_until_column() -> None: + import inspect + + from app.api.v1.trade_in import estimate_pdf + + assert "retain_until" in inspect.getsource(estimate_pdf) + + +@pytest.mark.parametrize( + ("expires_delta_hours", "retain_delta_days", "expected"), + [ + (12, None, True), # not expired, unpaid — current B2B/B2C behaviour, unchanged + (-1, None, False), # expired, unpaid — current behaviour (404/410), unchanged + (-1, 365, True), # expired but PAID — new: readable + (12, 365, True), # not expired AND paid — readable + (-1, -1, False), # expired, and the (hypothetical) retain_until also in the past + ], +) +def test_estimate_readable_truth_table( + expires_delta_hours: int, retain_delta_days: int | None, expected: bool +) -> None: + from datetime import UTC, datetime, timedelta + + from app.api.v1.trade_in import estimate_readable + + expires_at = datetime.now(tz=UTC) + timedelta(hours=expires_delta_hours) + retain_until = ( + datetime.now(tz=UTC) + timedelta(days=retain_delta_days) + if retain_delta_days is not None + else None + ) + assert estimate_readable(expires_at, retain_until) is expected + + +def test_pdf_expired_but_paid_returns_200(trade_in_app: FastAPI) -> None: + """expires_at in the past, retain_until in the future → PDF still downloads + (200). Exactly the scenario PR-D1 exists for: a paid report must outlive + the 24h expires_at link TTL.""" + from datetime import UTC, datetime, timedelta + + row = _make_estimate_row(created_by="kopylov") + row.expires_at = datetime.now(tz=UTC) - timedelta(hours=1) + row.retain_until = datetime.now(tz=UTC) + timedelta(days=300) + db_mock = _make_db_mock(row) + client = _client_with(trade_in_app, db_mock, role="pilot") + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}/pdf", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/pdf" + + +def test_pdf_expired_unpaid_returns_410_without_ttl_text(trade_in_app: FastAPI) -> None: + """expires_at in the past, retain_until NULL (unpaid, unchanged behaviour) → + 410, and the detail text no longer claims a specific '24h TTL' (would be a + lie now that retain_until exists for paid rows).""" + from datetime import UTC, datetime, timedelta + + row = _make_estimate_row(created_by="kopylov") + row.expires_at = datetime.now(tz=UTC) - timedelta(hours=1) + row.retain_until = None + db_mock = _make_db_mock(row) + client = _client_with(trade_in_app, db_mock, role="pilot") + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}/pdf", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 410 + assert resp.json()["detail"] == "estimate expired" + assert "24h" not in resp.json()["detail"] + assert "TTL" not in resp.json()["detail"] + + +def test_get_estimate_response_includes_retain_until_field(trade_in_app: FastAPI) -> None: + """Response schema exposes retain_until (nullable) — schemas/trade_in.py.""" + row = _make_estimate_row(created_by="kopylov") # retain_until defaults to None + db_mock = _make_db_mock(row) + client = _client_with(trade_in_app, db_mock, role="pilot") + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + assert resp.json()["retain_until"] is None diff --git a/tradein-mvp/backend/tests/test_pdf_security.py b/tradein-mvp/backend/tests/test_pdf_security.py index a97b017f..da5722ec 100644 --- a/tradein-mvp/backend/tests/test_pdf_security.py +++ b/tradein-mvp/backend/tests/test_pdf_security.py @@ -445,3 +445,36 @@ def test_brand_not_taken_from_query_param_docstring() -> None: assert ( "brand" not in param_names ), "estimate_pdf should NOT have a 'brand' query param after #7 fix" + + +# ── PR-D1: retain_until (paid retention) — cover row + valid_until unaffected ── + + +def test_cover_no_retain_until_row_when_unpaid() -> None: + """retain_until IS NULL (default, all current traffic) → no 'Ссылка доступна + до' row at all — B2B regression guard, cover renders bit-for-bit as before.""" + est = _estimate() + assert est.retain_until is None + html = mod._build_cover(est, _SNAPSHOT, _GENERIC) + assert "Ссылка доступна до" not in html + + +def test_cover_renders_retain_until_row_when_paid() -> None: + """retain_until IS NOT NULL → 'Ссылка доступна до ' row present, with + its OWN date (not conflated with 'Срок действия данных' / expires_at).""" + retain = datetime(2027, 8, 6, tzinfo=UTC) + est = _estimate(retain_until=retain) + html = mod._build_cover(est, _SNAPSHOT, _GENERIC) + assert "Ссылка доступна до" in html + assert "06.08.2027" in html + + +def test_expires_date_unaffected_by_retain_until() -> None: + """«ДЕЙСТВИТЕЛЕН ДО» (running footer, _expires_date) stays wired to + expires_at regardless of retain_until — it is data-actuality, not the + paid-access retention window, and must not move when a report is paid.""" + expires = datetime.now(UTC) + timedelta(hours=24) + est_unpaid = _estimate(expires_at=expires) + est_paid = _estimate(expires_at=expires, retain_until=expires + timedelta(days=365)) + assert mod._expires_date(est_unpaid) == expires.date() + assert mod._expires_date(est_paid) == expires.date() diff --git a/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py b/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py index 07a85aa9..caa1b207 100644 --- a/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py +++ b/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py @@ -7,9 +7,20 @@ Covers app/tasks/purge_expired_trade_in_data.py: - both tables (trade_in_estimates, trade_in_leads) get drained - failure path: rollback + mark_failed with partial counters, exception re-raised - SQL shape: DELETE (not UPDATE/deactivate), no psycopg `::` cast trap + - PR-D1 (payments retention, mera-pr-d-spec.md §1.4): retain_until IS NULL + + NOT EXISTS(payments) safeguards on the estimates DELETE, plus a pre-flight + that refuses to run at all if it finds a paid purge-candidate. Style mirrors tests/test_deactivate_stale_listings.py (_FakeDB, monkeypatched runs_mod.mark_done/mark_failed). + +PR-D1 note on _FakeDB: purge_expired_trade_in_data now issues ONE extra +db.execute() call BEFORE any DELETE batch — the pre-flight paid-candidates +count (_PREFLIGHT_PAID_CANDIDATES_SQL). _FakeDB special-cases that statement +by identity and answers it from `preflight_count` (default 0 == "no paid +candidates, proceed exactly as before this PR"). Every pre-existing test's +`db.executed` index shifted by +1 to account for this; `db.commits` is +unaffected (the pre-flight is a read, never committed). """ from __future__ import annotations @@ -34,18 +45,30 @@ class _FakeResult: def __init__(self, rowcount: int) -> None: self.rowcount = rowcount + def scalar_one(self) -> int: + """Supports the PR-D1 pre-flight `SELECT count(*) ... .scalar_one()` call.""" + return self.rowcount + class _FakeDB: - """Pops rowcounts in call order -- caller supplies the exact sequence expected.""" + """Pops rowcounts in call order -- caller supplies the exact sequence expected. - def __init__(self, rowcounts: list[int]) -> None: + PR-D1: the pre-flight paid-candidates count is answered separately, from + `preflight_count` (default 0), keyed by statement IDENTITY -- it never + consumes an entry off `rowcounts` (that list is DELETE-batch rowcounts only). + """ + + def __init__(self, rowcounts: list[int], *, preflight_count: int = 0) -> None: self._rowcounts = list(rowcounts) + self.preflight_count = preflight_count self.executed: list[tuple[Any, Any]] = [] self.commits = 0 self.rolled_back = False def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: self.executed.append((stmt, params)) + if stmt is task_mod._PREFLIGHT_PAID_CANDIDATES_SQL: + return _FakeResult(self.preflight_count) return _FakeResult(self._rowcounts.pop(0)) def commit(self) -> None: @@ -83,7 +106,9 @@ def test_stops_when_batch_below_size(monkeypatch: pytest.MonkeyPatch) -> None: db = _FakeDB([3, 0]) out = task_mod.purge_expired_trade_in_data(db, run_id=1, batch_size=10, max_batches=20) # type: ignore[arg-type] assert out == {"estimates_deleted": 3, "leads_deleted": 0} - assert len(db.executed) == 2 + # +1 vs pre-PR-D1: db.executed now also holds the pre-flight paid-candidates + # count (call #1), issued before either DELETE batch. + assert len(db.executed) == 3 assert db.commits == 2 assert marked["counters"] == out @@ -94,7 +119,7 @@ def test_loops_until_below_batch_size(monkeypatch: pytest.MonkeyPatch) -> None: db = _FakeDB([5, 5, 2, 5, 1]) out = task_mod.purge_expired_trade_in_data(db, run_id=2, batch_size=5, max_batches=20) # type: ignore[arg-type] assert out == {"estimates_deleted": 12, "leads_deleted": 6} - assert len(db.executed) == 5 + assert len(db.executed) == 6 # +1: pre-flight call before the 5 DELETE batches assert db.commits == 5, "each batch must commit independently, not one final commit" @@ -106,14 +131,16 @@ def test_respects_max_batches_cap(monkeypatch: pytest.MonkeyPatch) -> None: db = _FakeDB([5, 5, 5, 5, 5, 5]) # exactly max_batches=3 per table, no more out = task_mod.purge_expired_trade_in_data(db, run_id=3, batch_size=5, max_batches=3) # type: ignore[arg-type] assert out == {"estimates_deleted": 15, "leads_deleted": 15} - assert len(db.executed) == 6 # 3 (estimates) + 3 (leads), NOT unbounded + assert len(db.executed) == 7 # pre-flight + 3 (estimates) + 3 (leads), NOT unbounded def test_default_batch_size_and_max_batches_from_settings(monkeypatch: pytest.MonkeyPatch) -> None: _patch_runs(monkeypatch) db = _FakeDB([0, 0]) # first batch already empty on both tables -> stop immediately task_mod.purge_expired_trade_in_data(db, run_id=4) # type: ignore[arg-type] - _stmt, params = db.executed[0] + # db.executed[0] is now the pre-flight call (no batch_size param) -- the + # first DELETE-batch call (with batch_size) shifted to index 1. + _stmt, params = db.executed[1] assert params is not None assert params["batch_size"] == task_mod.settings.trade_in_purge_batch_size @@ -125,8 +152,9 @@ def test_drains_both_tables_in_order(monkeypatch: pytest.MonkeyPatch) -> None: _patch_runs(monkeypatch) db = _FakeDB([0, 0]) task_mod.purge_expired_trade_in_data(db, run_id=5, batch_size=100, max_batches=1) # type: ignore[arg-type] - first_sql = str(getattr(db.executed[0][0], "text", db.executed[0][0])) - second_sql = str(getattr(db.executed[1][0], "text", db.executed[1][0])) + # index 0 is now the pre-flight call; DELETE batches shifted to 1/2. + first_sql = str(getattr(db.executed[1][0], "text", db.executed[1][0])) + second_sql = str(getattr(db.executed[2][0], "text", db.executed[2][0])) assert "trade_in_estimates" in first_sql assert "trade_in_leads" in second_sql @@ -141,6 +169,81 @@ def test_estimates_sql_is_delete_not_update() -> None: assert not re.search(r":\w+::", sql) +# ── PR-D1 (mera-pr-d-spec.md §1.4): two independent purge safeguards ──────── + + +def test_estimates_sql_excludes_retain_until_not_null() -> None: + """Phase 1: exactly `retain_until IS NULL`, never `< NOW()` -- a paid row + (retain_until IS NOT NULL) must never match the DELETE predicate, full stop, + regardless of how far in the past that date eventually sits.""" + sql = task_mod._DELETE_EXPIRED_ESTIMATES_SQL.text + assert "retain_until IS NULL" in sql + assert "retain_until <" not in sql, "phase 1 must not weaken to retain_until < NOW()" + + +def test_estimates_sql_has_not_exists_payments_safeguard() -> None: + """Independent safeguard: a row with ANY payments row survives even if + retain_until failed to be set (fulfillment bug/race/manual INSERT).""" + sql = task_mod._DELETE_EXPIRED_ESTIMATES_SQL.text + assert "NOT EXISTS" in sql + assert "FROM payments p" in sql + assert "p.estimate_id = trade_in_estimates.id" in sql + + +def test_preflight_sql_is_wider_than_delete_predicate() -> None: + """Pre-flight counts by the BASE (pre-PR-D1) predicate -- expires_at < NOW() + AND created_by IS NULL, WITHOUT retain_until/NOT EXISTS -- so it also catches + the case those two terms exist specifically to guard against (retain_until + unset despite a payments row existing).""" + sql = task_mod._PREFLIGHT_PAID_CANDIDATES_SQL.text + assert "expires_at < NOW()" in sql + assert "created_by IS NULL" in sql + assert "retain_until" not in sql + assert "EXISTS (SELECT 1 FROM payments p WHERE p.estimate_id = e.id)" in sql + assert not re.search(r":\w+::", sql) + + +def test_preflight_aborts_before_any_delete_batch(monkeypatch: pytest.MonkeyPatch) -> None: + """Non-zero pre-flight count -> RuntimeError, mark_failed, ZERO DELETE batches + ever issued (only the pre-flight SELECT itself is in db.executed).""" + marked = _patch_runs(monkeypatch) + db = _FakeDB([], preflight_count=2) # rowcounts empty on purpose: must never be popped + with pytest.raises(RuntimeError, match="pre-flight abort"): + task_mod.purge_expired_trade_in_data(db, run_id=42, batch_size=10, max_batches=20) # type: ignore[arg-type] + + assert len(db.executed) == 1, "only the pre-flight SELECT -- no DELETE batch was issued" + assert db.commits == 0 + assert marked["kind"] == "failed" + assert marked["run_id"] == 42 + assert marked["counters"] == {"estimates_deleted": 0, "leads_deleted": 0} + assert "2" in marked["err"] + + +def test_preflight_zero_candidates_proceeds_as_before(monkeypatch: pytest.MonkeyPatch) -> None: + """preflight_count=0 (default) -- the exact pre-PR-D1 behaviour for every + row that exists today (all retain_until IS NULL) -- run proceeds normally.""" + marked = _patch_runs(monkeypatch) + db = _FakeDB([0, 0]) # preflight_count defaults to 0 + out = task_mod.purge_expired_trade_in_data(db, run_id=43, batch_size=10, max_batches=20) # type: ignore[arg-type] + assert out == {"estimates_deleted": 0, "leads_deleted": 0} + assert marked["kind"] == "done" + + +def test_leads_sql_unchanged_by_pr_d1() -> None: + """Snapshot: _DELETE_EXPIRED_LEADS_SQL byte-for-byte unchanged by PR-D1 — + leads have their own retention deadline (migration 231) and are explicitly + out of scope (mera-pr-d-spec.md §1.4: '_DELETE_EXPIRED_LEADS_SQL — оставить + дословно').""" + expected = ( + "\n DELETE FROM trade_in_leads\n WHERE id IN (\n" + " SELECT id FROM trade_in_leads\n" + " WHERE expires_at < NOW()\n" + " ORDER BY expires_at\n" + " LIMIT CAST(:batch_size AS int)\n )\n " + ) + assert task_mod._DELETE_EXPIRED_LEADS_SQL.text == expected + + def test_leads_sql_is_delete_not_update() -> None: sql = task_mod._DELETE_EXPIRED_LEADS_SQL.text assert "DELETE FROM trade_in_leads" in sql @@ -199,13 +302,14 @@ def test_failure_path_rollback_and_mark_failed(monkeypatch: pytest.MonkeyPatch) class _BoomDB(_FakeDB): def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: - # First batch (estimates) succeeds and commits; second call (still - # draining estimates, or first leads call) explodes. - if len(self.executed) >= 1: + # Call #1 (pre-flight, preflight_count=0) and call #2 (first estimates + # batch) succeed and commit; call #3 (still draining estimates, or + # first leads call) explodes. +1 vs pre-PR-D1 to admit the pre-flight. + if len(self.executed) >= 2: raise RuntimeError("db exploded") return super().execute(stmt, params) - db = _BoomDB([5]) # only ONE successful batch before the boom + db = _BoomDB([5]) # only ONE successful DELETE batch before the boom with pytest.raises(RuntimeError, match="db exploded"): task_mod.purge_expired_trade_in_data(db, run_id=6, batch_size=5, max_batches=20) # type: ignore[arg-type] diff --git a/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx b/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx index 82b35a28..86fd3cec 100644 --- a/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx @@ -32,11 +32,21 @@ import { safeUrl } from "@/lib/safeUrl"; * квалификация — не наше дело: заявка привязывается к конкретному расчёту * (`TradeInLeadInput.estimate_id`), то есть телефон связывается с ранее * сохранённым адресом. Финальную формулировку даёт юрист. - * - «мы удалим ваш телефон и заявку». Механизма удаления в бэкенде НЕТ: - * ни `DELETE FROM trade_in_leads/trade_in_estimates` в коде, ни - * retention/erasure-джоба среди `app/tasks/**` (проверено grep'ом); - * `expires_at` применяется только на чтении. Обещать удаление до появления - * процедуры нельзя — это самое дорогое из обещаний. + * - «мы обещаем удалить всё вообще» без оговорок. После #2547 механизм + * удаления в бэкенде ЕСТЬ: `app/services/data_erasure.py` (ручное + * удаление по обращению — по estimate_id/телефону/Telegram chat id) и + * `app/tasks/purge_expired_trade_in_data.py` (автоматическое удаление по + * истечении срока хранения). Прежняя формулировка «механизма нет» стала + * неправдой и здесь больше не пишется. Оговорка, которая остаётся честной: + * копия сообщения в Telegram-группе поддержки этим механизмом не + * стирается (см. докстринг `data_erasure.py`) — сюда её не выносим + * (излишняя техническая деталь для публичной страницы), но это ограничение + * реальное и известное. + * - PR-D1 (`retain_until`, `trade_in_paid_retention_days`): срок «12 месяцев» + * ниже читается из той же настройки, что и оферта/SQL продления — + * см. `mera-pr-d-spec.md` §1.2 в корне репо. Платёжного кода в этом PR + * нет — срок описан на будущее, синхронно с privacy-обязательством #1.7 + * того же дока, а не «потом». * * Раздел «Что делает эта страница» УСЛОВЕН по `PUBLIC_ESTIMATE_ENABLED`: пока * расчёт выключен, адрес действительно не покидает браузер; после включения это @@ -136,10 +146,16 @@ export default function MeraPublicPrivacyPage() { ) : null}

- Автоматической кнопки «удалить мои данные» в сервисе пока нет, и мы не - обещаем то, чего не умеем: порядок и сроки удаления будут описаны в - утверждённой политике обработки, которая появится здесь до открытия - публичного доступа. + Самостоятельной кнопки «удалить мои данные» в интерфейсе пока нет, но + механизм удаления в сервисе есть: обращение в поддержку об удалении мы + разбираем вручную и физически стираем телефон, адрес и расчёт из базы, + а не просто помечаем запись. Помимо запроса, у данных есть собственный + срок хранения, по истечении которого они удаляются без обращения с + вашей стороны. Если результат расчёта оплачен, ссылка на отчёт и + связанные с ним данные хранятся на нашей стороне 12 месяцев с даты + оплаты, после чего удаляются точно так же — на файл, который вы + скачали себе, это не влияет: мы его не отзываем, не изменяем и не + имеем к нему доступа.

Оператор

diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index 1476a7f7..38c7df68 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -123,7 +123,7 @@ const brackets: { key: string; style: CSSProperties }[] = [ // Honest neutral fallbacks for the meta blocks (HeroBar / Footer) before there // is an estimate. Dashes — never the design fixtures (which would read as a fake // real report). -const EMPTY_REPORT: Report = { id: "—", date: "—", validUntil: "—" }; +const EMPTY_REPORT: Report = { id: "—", date: "—", validUntil: "—", retainUntil: null }; const EMPTY_OBJECT: ObjectInfo = { address: "—", city: "", diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts b/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts index 7c66e2ba..6e3de551 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts @@ -30,6 +30,7 @@ export const report: Report = { id: "9b9e2737", date: "20.06.2026", validUntil: "21.06.2026", + retainUntil: null, }; export const object: ObjectInfo = { diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts index 81e9ff94..d8a9e676 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -780,6 +780,9 @@ export function mapReport(e: AggregatedEstimate): Report { ? fmtDate(e.created_at) : fmtDateShift(e.expires_at, -24), // pre-BE-1 fallback: no real created_at validUntil: fmtDate(e.expires_at), + // PR-D1: passthrough only, validUntil above stays on expires_at (this + // field is the paid-access retention date, unrelated to report validity). + retainUntil: e.retain_until ?? null, }; } diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts index 81ca7422..99d961b4 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts @@ -5,6 +5,10 @@ export interface Report { id: string; date: string; validUntil: string; + // PR-D1: raw ISO retain_until passthrough (paid-access link lifetime), null + // when unpaid (current traffic). NOT rendered yet -- no payment UI in this + // PR; validUntil stays wired to expires_at, unrelated to this field. + retainUntil: string | null; } export interface ObjectInfo { diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index 7db71758..ce3282bd 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -172,6 +172,10 @@ export interface AggregatedEstimate { analogs: AnalogLot[]; // top 5-10 actual_deals: AnalogLot[]; // last 12 mo expires_at: string; // ISO datetime + // PR-D1: срок жизни ссылки/строки (оплаченный доступ, backend migration 234), + // НЕ путать с expires_at (актуальность расчёта). null = неоплачено (текущий + // трафик целиком). + retain_until?: string | null; // ISO datetime // ── Метаданные ── target_address: string | null; target_lat: number | null; From 76c3f5c110f329b7e4e60d8480ca39fcefeb8dad Mon Sep 17 00:00:00 2001 From: lekss361 Date: Thu, 6 Aug 2026 18:59:50 +0000 Subject: [PATCH 004/124] =?UTF-8?q?chore(frontend):=20=D1=84=D0=B8=D0=BA?= =?UTF-8?q?=D1=81=D1=82=D1=83=D1=80=D1=8B=20=D0=BC=D0=B0=D0=BA=D0=B5=D1=82?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=D0=BF=D0=BE=20=D1=83=D0=BC=D0=BE=D0=BB?= =?UTF-8?q?=D1=87=D0=B0=D0=BD=D0=B8=D1=8E=20+=20=D1=83=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE=D1=81=D0=B8=D1=80=D0=BE=D1=82?= =?UTF-8?q?=D0=B5=D0=B2=D1=88=D0=B8=D1=85=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=20(#2747)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit МЕРА: у 8 компонентов витрины v2 проп data больше не имеет дефолта из fixtures.ts — при сбое передачи данных компонент обязан упасть на TS-ошибке, а не отрисовать выдуманные числа на платном экране оценки. Цепная правка в SectionOverlay (4 поля стали обязательными в такт с детьми). Птица: удалены 6 осиротевших компонентов (ноль импортов подтверждён репо-wide), подчищены 2 ссылающихся комментария. Проверено ревьюером: tsc --noEmit и next lint реально отработали на 91b460b1 (лог задачи 18031), vitest 32/264 зелёные (лог 18033); storybook в репозитории отсутствует вовсе — «unwired/storybook usage» как обоснование дефолтов никогда не имело потребителя; ui-preview/estimate использует v1-компоненты со своей локальной фикстурой и не задет. --- .../src/components/site-finder/ScoreCard.tsx | 5 - .../analysis/AnalysisBreadcrumb.tsx | 82 ---- .../site-finder/analysis/AnalysisSidebar.tsx | 266 ------------ .../site-finder/analysis/MassingEconomics.tsx | 410 ------------------ .../site-finder/analysis/UserAvatar.tsx | 104 ----- .../ptica/PticaPlaceholderPanel.tsx | 22 - frontend/src/lib/concept-api.ts | 2 +- frontend/src/lib/nspdLinks.ts | 2 +- .../components/trade-in/v2/AnalyticsView.tsx | 14 +- .../src/components/trade-in/v2/CacheView.tsx | 14 +- .../src/components/trade-in/v2/Footer.tsx | 11 +- .../src/components/trade-in/v2/HeroBar.tsx | 11 +- .../components/trade-in/v2/HistoryView.tsx | 17 +- .../components/trade-in/v2/ObjectSummary.tsx | 16 +- .../components/trade-in/v2/ResultPanel.tsx | 23 +- .../components/trade-in/v2/SectionOverlay.tsx | 13 +- .../components/trade-in/v2/SourcesView.tsx | 27 +- 17 files changed, 66 insertions(+), 973 deletions(-) delete mode 100644 frontend/src/components/site-finder/ScoreCard.tsx delete mode 100644 frontend/src/components/site-finder/analysis/AnalysisBreadcrumb.tsx delete mode 100644 frontend/src/components/site-finder/analysis/AnalysisSidebar.tsx delete mode 100644 frontend/src/components/site-finder/analysis/MassingEconomics.tsx delete mode 100644 frontend/src/components/site-finder/analysis/UserAvatar.tsx delete mode 100644 frontend/src/components/site-finder/ptica/PticaPlaceholderPanel.tsx diff --git a/frontend/src/components/site-finder/ScoreCard.tsx b/frontend/src/components/site-finder/ScoreCard.tsx deleted file mode 100644 index 2229b1f1..00000000 --- a/frontend/src/components/site-finder/ScoreCard.tsx +++ /dev/null @@ -1,5 +0,0 @@ -// ScoreCard is superseded by the tabbed dashboard in page.tsx. -// Logic is now split into OverviewTab, EnvironmentTab, LandTab, MarketTab. -// File kept to avoid breaking any external imports; exports an empty stub. - -export {}; diff --git a/frontend/src/components/site-finder/analysis/AnalysisBreadcrumb.tsx b/frontend/src/components/site-finder/analysis/AnalysisBreadcrumb.tsx deleted file mode 100644 index b72e33c9..00000000 --- a/frontend/src/components/site-finder/analysis/AnalysisBreadcrumb.tsx +++ /dev/null @@ -1,82 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { ChevronRight } from "lucide-react"; - -interface AnalysisBreadcrumbProps { - cadNum: string; -} - -export function AnalysisBreadcrumb({ cadNum }: AnalysisBreadcrumbProps) { - return ( - - ); -} diff --git a/frontend/src/components/site-finder/analysis/AnalysisSidebar.tsx b/frontend/src/components/site-finder/analysis/AnalysisSidebar.tsx deleted file mode 100644 index 487f5cd6..00000000 --- a/frontend/src/components/site-finder/analysis/AnalysisSidebar.tsx +++ /dev/null @@ -1,266 +0,0 @@ -"use client"; - -import React, { useEffect, useRef, useState } from "react"; -import { ExternalLink } from "lucide-react"; - -// ── Types ───────────────────────────────────────────────────────────────────── - -interface SubSection { - id: string; - label: string; -} - -interface NavSection { - id: string; - label: string; - sub?: SubSection[]; -} - -// ── Config ──────────────────────────────────────────────────────────────────── - -const NAV_SECTIONS: NavSection[] = [ - { id: "section-1", label: "1. Объект" }, - { id: "section-2", label: "2. Земля и риски" }, - { - id: "section-3", - label: "3. Рынок", - sub: [ - { id: "section-3-1", label: "3.1 Настройки выборки" }, - { id: "section-3-2", label: "3.2 Планировки" }, - { id: "section-3-3", label: "3.3 Остатки и скорость" }, - ], - }, - { id: "section-4", label: "4. Оценка" }, - { id: "section-5", label: "5. Атмосфера" }, - { - id: "section-6", - label: "6. Прогноз", - sub: [ - { id: "section-6-1", label: "6.1 Прогноз по горизонтам" }, - { id: "section-6-2", label: "6.2 Сценарии" }, - { id: "section-6-3", label: "6.3 Уверенность" }, - { id: "section-6-4", label: "6.4 Рекомендация по продукту" }, - { id: "section-6-5", label: "6.5 Прозрачность скоринга" }, - { id: "section-6-6", label: "6.6 Будущее предложение и конкуренты" }, - ], - }, -]; - -// All section IDs in scroll order (for IntersectionObserver) -const ALL_SECTION_IDS: string[] = NAV_SECTIONS.flatMap((s) => - s.sub ? [s.id, ...s.sub.map((sub) => sub.id)] : [s.id], -); - -// ── Component ───────────────────────────────────────────────────────────────── - -export function AnalysisSidebar() { - const [activeId, setActiveId] = useState(ALL_SECTION_IDS[0]); - const observerRef = useRef(null); - - // Scrollspy via IntersectionObserver - useEffect(() => { - const candidates = ALL_SECTION_IDS.map((id) => - document.getElementById(id), - ).filter((el): el is HTMLElement => el !== null); - - if (candidates.length === 0) return; - - // Track which sections are visible; pick topmost visible one - const visible = new Set(); - - observerRef.current = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - visible.add(entry.target.id); - } else { - visible.delete(entry.target.id); - } - }); - - // Pick the topmost section that is currently visible - const next = ALL_SECTION_IDS.find((id) => visible.has(id)); - if (next) setActiveId(next); - }, - { - root: null, - // Trigger when section top enters top 60% of viewport - rootMargin: "-8px 0px -40% 0px", - threshold: 0, - }, - ); - - candidates.forEach((el) => observerRef.current!.observe(el)); - - return () => { - observerRef.current?.disconnect(); - }; - }, []); - - function handleAnchorClick( - e: React.MouseEvent, - targetId: string, - ) { - e.preventDefault(); - const el = document.getElementById(targetId); - if (el) { - el.scrollIntoView({ behavior: "smooth", block: "start" }); - } - setActiveId(targetId); - } - - return ( - - ); -} diff --git a/frontend/src/components/site-finder/analysis/MassingEconomics.tsx b/frontend/src/components/site-finder/analysis/MassingEconomics.tsx deleted file mode 100644 index 272c6239..00000000 --- a/frontend/src/components/site-finder/analysis/MassingEconomics.tsx +++ /dev/null @@ -1,410 +0,0 @@ -"use client"; - -/** - * MassingEconomics — LIVE financial KPI strip for «7. Концепция» (#1965 Stage 2b, - * epic #1953). - * - * Driven by the interactive 3D MassingScene: every time the user drags the - * этажность / секций sliders, Section7Concept maps the scene's `computeModel` - * result + the analysis context into a `MassingProgram` and hands it here via - * `program`. We POST it to `/api/v1/concepts/recompute` (debounced ~250 ms) and - * render the recomputed ТЭП + финмодель (NPV / IRR / выручка / себестоимость / - * прибыль / ROI). - * - * Robustness: - * • debounce — slider drags fire many programs; only the settled one is sent. - * • latest-wins — an in-flight request is superseded by a newer one via a - * monotonic request id; a stale response is dropped, never overwriting a - * fresher result (mutateAsync + id guard, no UI flicker from out-of-order). - * • last-good — on a failed recompute we keep the last successful values and - * show a subtle inline note rather than blanking the panel. - * • skeleton — a plain grey fade KPI grid while the FIRST recompute is in - * flight (no shimmer, per ui-conventions). - * - * Light-theme only (Section7 is light): the 3D viewport stays dark-canvas, but - * this strip uses the light KPI tokens via the shared KpiCard. - */ - -import { useEffect, useRef, useState } from "react"; -import { AlertTriangle } from "lucide-react"; - -import { KpiCard } from "@/components/analytics/KpiCard"; -import { Section } from "@/components/analytics/Section"; -import { - priceSourceCaption, - useRecomputeMassing, - type FinancialModel, - type MassingProgram, - type MassingRecomputeOutput, - type Teap, -} from "@/lib/concept-api"; - -const DEBOUNCE_MS = 250; - -// ── Formatters (ru microcopy, shared shape with ConceptVariantsResult) ───────── - -const nf = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 }); - -/** Compact ₽ for headline figures: "2.4 млрд ₽", "145 млн ₽". */ -function formatMoneyCompact(rub: number): string { - const abs = Math.abs(rub); - if (abs >= 1e9) return `${(rub / 1e9).toFixed(1)} млрд ₽`; - if (abs >= 1e6) return `${(rub / 1e6).toFixed(0)} млн ₽`; - return `${nf.format(Math.round(rub))} ₽`; -} - -function formatInt(n: number): string { - return nf.format(Math.round(n)); -} - -function formatPct(fraction: number): string { - return `${(fraction * 100).toFixed(1)}%`; -} - -function formatFar(far: number): string { - return far.toLocaleString("ru-RU", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }); -} - -// ── KPI grid ─────────────────────────────────────────────────────────────────── - -interface KpiGridProps { - teap: Teap; - financial: FinancialModel; - /** Регламентная КСИТ-цель (max_far) — to flag the КСИТ over-cap. */ - farTarget: number; - /** True → факт-КСИТ превышает регламентный потолок (model.over). */ - ksitOver: boolean; - /** Dim the strip while a fresher recompute is in flight (last-good values). */ - stale: boolean; -} - -function KpiGrid({ - teap, - financial, - farTarget, - ksitOver, - stale, -}: KpiGridProps) { - const netPositive = - financial.net_profit_rub > 0 - ? true - : financial.net_profit_rub < 0 - ? false - : null; - - return ( -
- {/* ТЭП */} -
- - - - -
- - {/* Финмодель */} -
- - - - - 0 - ? true - : financial.npv_rub < 0 - ? false - : null, - }} - hint={`Сумма дисконтированных помесячных денежных потоков по графику стройки и продаж (ставка дисконта ${formatPct( - financial.discount_rate_used, - )} годовых).`} - /> - financial.discount_rate_used - ? true - : false, - }} - hint="Годовая внутренняя ставка доходности тех же денежных потоков (ставка, при которой NPV = 0)." - /> - -
-
- ); -} - -// ── Skeleton (grey fade, no shimmer — ui-conventions) ────────────────────────── - -function SkeletonGrid() { - const cells = Array.from({ length: 7 }); - return ( - diff --git a/tradein-mvp/backend/tests/test_version_api.py b/tradein-mvp/backend/tests/test_version_api.py new file mode 100644 index 00000000..767e6b87 --- /dev/null +++ b/tradein-mvp/backend/tests/test_version_api.py @@ -0,0 +1,99 @@ +"""Tests for GET /api/v1/trade-in/version (build metadata) — app/core/version.py + +app/api/v1/version.py. + +Isolated FastAPI app (no full app.main import, no DB) — same pattern as +tests/test_geocode_reverse_api.py: mount only the router under test. +""" + +from __future__ import annotations + +import importlib +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.v1 import version as version_module +from app.core import version as version_core + + +@pytest.fixture +def app() -> FastAPI: + application = FastAPI() + application.include_router(version_module.router, prefix="/api/v1/trade-in") + return application + + +# ── GET /api/v1/trade-in/version ───────────────────────────────────────────── + + +def test_version_endpoint_shape(app: FastAPI) -> None: + client = TestClient(app) + r = client.get("/api/v1/trade-in/version") + assert r.status_code == 200 + body = r.json() + assert set(body.keys()) == {"version", "sha", "built_at"} + assert isinstance(body["version"], str) and body["version"] + assert isinstance(body["sha"], str) and body["sha"] + assert isinstance(body["built_at"], str) and body["built_at"] + + +def test_version_endpoint_matches_core_constants(app: FastAPI) -> None: + client = TestClient(app) + body = client.get("/api/v1/trade-in/version").json() + assert body["version"] == version_core.APP_VERSION + assert body["sha"] == version_core.BUILD_SHA + assert body["built_at"] == version_core.BUILD_DATE + + +def test_version_path_is_public_no_auth_required() -> None: + """rbac_guard must let this path through without X-Authenticated-User / + session — see app/core/rbac.py::_PUBLIC_PATHS. Not a secret, no DB call.""" + from app.core.rbac import _PUBLIC_PATHS + + assert "/api/v1/trade-in/version" in _PUBLIC_PATHS + + +# ── app/core/version.py — product_version_line / format_build_date_human ──── + + +def test_product_version_line_format() -> None: + line = version_core.product_version_line("Мера") + assert line.startswith("Мера v") + parts = line.split(" · ") + assert len(parts) == 3, f"expected 'name vX.Y.Z · sha · date', got {line!r}" + + +def test_format_build_date_human_parses_iso_utc() -> None: + assert version_core.format_build_date_human("2026-08-10T12:00:00Z") == "10.08.2026" + + +def test_format_build_date_human_falls_back_on_garbage_without_raising() -> None: + assert version_core.format_build_date_human("not-a-date") == "not-a-date" + + +# ── Fallback when APP_VERSION/BUILD_SHA/BUILD_DATE env vars are absent ────── +# (local `uvicorn` run without a Docker build — see module docstring in +# app/core/version.py). Reloading the module re-executes its module-level +# env reads; nothing here may raise. + + +def test_module_import_falls_back_without_build_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("APP_VERSION", raising=False) + monkeypatch.delenv("BUILD_SHA", raising=False) + monkeypatch.delenv("BUILD_DATE", raising=False) + + reloaded = importlib.reload(version_core) + + assert reloaded.BUILD_SHA == "dev" + assert reloaded.APP_VERSION # non-empty: VERSION file content or "0.0.0" default + assert reloaded.BUILD_DATE.endswith("Z") + # format/product helpers must still work off the fallback values (no crash). + assert reloaded.product_version_line("Мера").startswith("Мера v") + + # Reload once more so any test running later in this process sees a module + # state consistent with whatever env pytest was actually invoked under. + importlib.reload(version_core) diff --git a/tradein-mvp/frontend/Dockerfile b/tradein-mvp/frontend/Dockerfile index 9abbd35a..5def62b7 100644 --- a/tradein-mvp/frontend/Dockerfile +++ b/tradein-mvp/frontend/Dockerfile @@ -30,6 +30,29 @@ ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL ARG NEXT_PUBLIC_ENABLE_PREVIEW="" ENV NEXT_PUBLIC_ENABLE_PREVIEW=$NEXT_PUBLIC_ENABLE_PREVIEW +# Версия продукта («Мера») + короткий git SHA + дата сборки — ДОЛЖНЫ быть +# build-time ARG (не runtime env): Next.js инлайнит NEXT_PUBLIC_* в статические +# бандлы на `npm run build`, а этот build context (./tradein-mvp/frontend) не +# видит tradein-mvp/VERSION (он на уровень выше, вне build context) — источник +# правды читает CI ДО вызова `docker build` (.forgejo/workflows/deploy-tradein.yml, +# job build-frontend) и передаёт сюда готовыми значениями. Пустые дефолты — для +# локальной сборки без CI; фолбэк на "VERSION-файл/dev/дата сборки" делает уже +# frontend-код, потребляющий эти env (Dockerfile сам файл не читает). +ARG NEXT_PUBLIC_APP_VERSION="" +ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION +ARG NEXT_PUBLIC_BUILD_SHA="" +ENV NEXT_PUBLIC_BUILD_SHA=$NEXT_PUBLIC_BUILD_SHA +ARG NEXT_PUBLIC_BUILD_DATE="" +ENV NEXT_PUBLIC_BUILD_DATE=$NEXT_PUBLIC_BUILD_DATE + +# CHANGELOG.md — источник для /versions (src/lib/changelog.ts). Живёт на +# уровень выше этого build context (tradein-mvp/CHANGELOG.md), поэтому CI +# копирует его СЮДА (tradein-mvp/frontend/CHANGELOG.md) непосредственно +# перед `docker build` (см. .forgejo/workflows/deploy-tradein.yml, job +# build-frontend) — `COPY . .` ниже подхватывает её автоматически вместе с +# остальным контекстом. Локальная сборка без этого шага CI просто не находит +# файл — readChangelog() уже умеет деградировать (пустая история), сам +# Docker-билд при этом не падает (см. glob-COPY в runner stage ниже). COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build @@ -49,6 +72,13 @@ ENV NODE_ENV=production \ COPY --from=builder --chown=node:node /app/public ./public COPY --from=builder --chown=node:node /app/.next/standalone ./ COPY --from=builder --chown=node:node /app/.next/static ./.next/static +# /versions — Server Component, statically prerendered at `npm run build` +# (see src/app/versions/page.tsx) — CHANGELOG.md's content is already baked +# into .next/standalone above. This is a defensive fallback ONLY, in case that +# page ever stops being static: glob (trailing `*`) makes it a no-op when the +# builder stage doesn't have the file either (local build without the CI +# pre-copy step, see builder stage comment above) — never fails the build. +COPY --from=builder --chown=node:node /app/CHANGELOG.md* ./ USER node EXPOSE 3000 diff --git a/tradein-mvp/frontend/src/app/v2/layout.tsx b/tradein-mvp/frontend/src/app/v2/layout.tsx index bf603195..c7d0543a 100644 --- a/tradein-mvp/frontend/src/app/v2/layout.tsx +++ b/tradein-mvp/frontend/src/app/v2/layout.tsx @@ -4,6 +4,7 @@ import { IBM_Plex_Mono, Manrope } from "next/font/google"; import { SupportButton } from "@/components/trade-in/v2/SupportButton"; import { SupportChatProvider } from "@/components/trade-in/v2/SupportChatContext"; import { pageBg } from "@/components/trade-in/v2/tokens"; +import { VersionFooter } from "@/components/trade-in/VersionFooter"; // Manrope — primary sans typeface of the МЕРА HUD. next/font is bundled // (no package.json change). Cyrillic + latin so RU labels render correctly. @@ -53,6 +54,15 @@ export default function TradeInV2Layout({ products without the МЕРА brand that don't need a support link. */} + {/* Real build-version indicator (task: показать реальную версию + продукта «Мера» в вебе). Deliberately OUTSIDE SupportChatProvider — + it needs no chat context — but still scoped to this /v2 layout for + the same reason SupportButton is: other basePath routes + (/scrapers/**, /sale-share) are unrelated products without the + МЕРА brand. Portals to document.body itself (see VersionFooter.tsx + docstring), so its position in this tree only matters for mount + order, not DOM placement. */} + ); } diff --git a/tradein-mvp/frontend/src/app/versions/page.tsx b/tradein-mvp/frontend/src/app/versions/page.tsx new file mode 100644 index 00000000..7c82a2ab --- /dev/null +++ b/tradein-mvp/frontend/src/app/versions/page.tsx @@ -0,0 +1,120 @@ +// /versions (→ `/trade-in/versions` behind basePath) — «История версий». +// +// Server Component, deliberately NOT "use client": `readChangelog()` reads +// `tradein-mvp/CHANGELOG.md` off disk via `fs.readFileSync` at build/render +// time and gets statically embedded — no client-side fetch, no network hop +// (see src/lib/changelog.ts for the exact read/parse contract + a known +// build-context gap, flagged there). +// +// Auth: this route has NO guard of its own — it lives inside the same +// app-router segment as every other closed МЕРА page (history/, cache/, +// team/), so `app/layout.tsx`'s `` already gates it exactly +// like the rest of the product. No new RBAC path was added; whatever the +// backend `auth/roles.yaml` wildcard already allows for `/trade-in/**` +// covers this page too. +import type { Metadata } from "next"; +import Link from "next/link"; + +import "@/components/trade-in/trade-in.css"; +import { APP_VERSION, formatRuDate } from "@/lib/buildInfo"; +import { readChangelog } from "@/lib/changelog"; + +export const metadata: Metadata = { + title: "История версий — МЕРА", +}; + +export default function VersionsPage() { + const entries = readChangelog(); + + return ( +
+

+ ← К оценке +

+ +

+ История версий +

+

+ Текущая версия:{" "} + {APP_VERSION === "dev" ? "dev-сборка" : `v${APP_VERSION}`} +

+ + {entries.length === 0 ? ( +

+ История изменений пока не опубликована. +

+ ) : ( +
+ {entries.map((entry) => { + const isCurrent = entry.version === APP_VERSION; + return ( +
+
+

+ v{entry.version} + {isCurrent && ( + + текущая + + )} +

+
{formatRuDate(entry.date)}
+
+
+ {entry.sections.length === 0 ? ( +

+ Без описания изменений. +

+ ) : ( + entry.sections.map((section) => ( +
+

+ {section.title} +

+
    + {section.items.map((item, i) => ( +
  • + {item} +
  • + ))} +
+
+ )) + )} +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/VersionFooter.tsx b/tradein-mvp/frontend/src/components/trade-in/VersionFooter.tsx new file mode 100644 index 00000000..426e46df --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/VersionFooter.tsx @@ -0,0 +1,80 @@ +"use client"; + +// VersionFooter — small build-version indicator for the МЕРА product, +// showing the REAL deployed version. This is the SINGLE place on /trade-in/v2 +// that renders a version number — `v2/TopNav.tsx` and `v2/Footer.tsx` used to +// each carry their own hardcoded "v2.0.6" literal (`./ui-config`'s `version`) +// next to the МЕРА wordmark; both were removed (three independent "versions" +// on one screen, see PR review) — the wordmark stays in both places, just +// without a number attached. Values here come from build-time +// `NEXT_PUBLIC_*` env vars via `@/lib/buildInfo` — no runtime API call, no +// useEffect fetch. +// +// Mounted in `app/v2/layout.tsx` (not `app/v2/page.tsx` — that file is +// off-limits for this change), right next to ``. +// +// Portaled to document.body — same reasoning/pattern as SupportButton.tsx: +// /v2 renders its HUD inside a fixed-size "artboard" that gets +// `transform: scale(...)` on narrow viewports (app/v2/page.tsx), and a +// `position: fixed` descendant of a transformed ancestor is positioned +// relative to THAT ancestor, not the real viewport corner — portaling +// sidesteps that entirely, exactly like the support button already does. +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; + +import { tokens } from "@/components/trade-in/v2/tokens"; +import { formatVersionLabel } from "@/lib/buildInfo"; + +const styles = ` +.version-footer{opacity:.72;transition:opacity .15s;} +.version-footer:hover{opacity:1;} +.version-footer a{color:${tokens.muted2};text-decoration:underline;text-underline-offset:2px;} +.version-footer a:hover{color:${tokens.ink};} +@media (max-width: 480px){ + .version-footer{left:10px !important;bottom:10px !important;padding:3px 7px !important;font-size:9px !important;gap:6px !important;} +} +`; + +export function VersionFooter() { + // Portal-mount guard (SSR-safe): `document` only exists after mount + // (mirrors SupportButton.tsx / MapPicker.tsx). + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + if (!mounted) return null; + + return createPortal( + <> + +
+ {formatVersionLabel()} + История версий +
+ , + document.body, + ); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx index 9dab743a..3b04d0b7 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx @@ -1,10 +1,13 @@ // Report footer for the /trade-in/v2 "МЕРА Оценка" design port. // Faithful markup port of the design footer (МЕРА Оценка.dc.html, lines 426-439): // report id / date / valid-until on the left, a decorative centre line, and the -// МЕРА v2.0.6 wordmark on the right. Static markup, id/date/validUntil via `data`. +// МЕРА wordmark on the right. Static markup, id/date/validUntil via `data`. +// The trailing "v2.0.6" badge that used to sit next to the wordmark was a +// hardcoded literal (./ui-config `version`), independent of the real deployed +// build — removed. The real version is shown once, by `` +// (see app/v2/layout.tsx), not duplicated here. import { tokens } from "./tokens"; -import { version } from "./ui-config"; import type { Report } from "./types"; interface FooterProps { @@ -122,16 +125,6 @@ export function Footer({ data, hasEstimate }: FooterProps) { > МЕРА - - {version} - ); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx index 9bf5a19d..70674679 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx @@ -2,10 +2,13 @@ // Top navigation bar for the /trade-in/v2 "МЕРА Оценка" design port. // Faithful markup port of the design header (МЕРА Оценка.dc.html, lines 42-90): -// inline SVG logo + version + 5 nav tabs (active underline/triangle) + user menu. +// inline SVG logo + 5 nav tabs (active underline/triangle) + user menu. // Tabs change only local UI state via onNavigate; the user dropdown owns its -// own useState. No data fetching — labels/version come from ./ui-config, the user +// own useState. No data fetching — labels come from ./ui-config, the user // identity is fed in from the page (real useMe), colours from tokens. +// The build-version badge that used to sit next to the logo (hardcoded +// "v2.0.6") was removed — the real deployed version is shown once, by +// `` (app/v2/layout.tsx), not duplicated here. import { useState } from "react"; import type { CSSProperties } from "react"; @@ -13,7 +16,7 @@ import type { CSSProperties } from "react"; import { API_BASE_URL } from "@/lib/api"; import { tokens } from "./tokens"; -import { navLabels, version } from "./ui-config"; +import { navLabels } from "./ui-config"; import { useSupportChat } from "./SupportChatContext"; // Real logged-in user identity, derived by the page from useMe() @@ -70,15 +73,29 @@ const menuItemStyle: CSSProperties = { tokens.muted), что и остальные иконки этого дропдауна. */ function UsersIcon() { return ( -
-
- {version} -
{/* Nav tabs */} @@ -412,7 +417,13 @@ export default function TopNav({ aria-disabled="true" title="Раздел «Профиль» скоро появится" > -
- -